C# Unit Testing
Introduction
Unit testing is a fundamental practice in modern software development that helps ensure your code works as expected. In C#, unit testing involves writing small, focused tests that verify individual units of code (typically methods) function correctly. By incorporating unit tests into your development workflow, you can catch bugs early, refactor with confidence, and ensure your application maintains its expected behavior over time.
This guide will introduce you to unit testing in C#, covering the basic concepts, popular testing frameworks, and best practices to help you write effective tests for your C# applications.
Why Unit Testing Matters
Before diving into the technical details, let's understand why unit testing is essential:
- Bug Detection: Catch issues early before they make it to production
- Documentation: Tests serve as living documentation of how your code should behave
- Design Improvement: Writing testable code often leads to better software design
- Refactoring Safety: Change code confidently knowing tests will catch regressions
- Continuous Integration: Automated tests are crucial for CI/CD pipelines
C# Testing Frameworks
C# offers several popular testing frameworks. Let's look at the three most common ones:
1. MSTest
MSTest is Microsoft's built-in testing framework that integrates tightly with Visual Studio.
2. NUnit
NUnit is a widely-used, open-source testing framework ported from JUnit.
3. XUnit
XUnit is a newer, open-source testing framework designed to be more extensible and modern.
For this guide, we'll focus primarily on MSTest since it comes integrated with Visual Studio, but the concepts apply to all frameworks.
Setting Up Your First Unit Test Project
Let's start by creating a simple unit test project:
- Open Visual Studio
- Go to File > New > Project
- Select "MSTest Test Project" (or the equivalent for your chosen framework)
- Name your project (conventionally ending with ".Tests")
Project Structure
A typical test project structure might look like:
MyApplication.Tests/
├── CalculatorTests.cs
├── StringUtilsTests.cs
└── MyApplication.Tests.csproj
Writing Your First Unit Test
Let's create a simple Calculator class and test its Add method:
First, here's our Calculator class:
// Calculator.cs in your main project
namespace MyApplication
{
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
public int Subtract(int a, int b)
{
return a - b;
}
public int Multiply(int a, int b)
{
return a * b;
}
public double Divide(int a, int b)
{
if (b == 0)
{
throw new DivideByZeroException("Cannot divide by zero");
}
return (double)a / b;
}
}
}
Now, let's write a test for this class:
// CalculatorTests.cs in your test project
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MyApplication;
namespace MyApplication.Tests
{
[TestClass]
public class CalculatorTests
{
[TestMethod]
public void Add_TwoPositiveNumbers_ReturnsCorrectSum()
{
// Arrange
Calculator calculator = new Calculator();
int a = 5;
int b = 7;
int expected = 12;
// Act
int result = calculator.Add(a, b);
// Assert
Assert.AreEqual(expected, result);
}
}
}
Anatomy of a Unit Test
Let's break down the components of our test:
- TestClass attribute: Marks a class containing test methods
- TestMethod attribute: Identifies a method as a test
- Arrange-Act-Assert pattern:
- Arrange: Set up the test data and objects
- Act: Execute the method being tested
- Assert: Verify the result matches expectations
Testing Different Scenarios
Let's expand our tests to cover more scenarios:
[TestMethod]
public void Add_NegativeAndPositive_ReturnsCorrectSum()
{
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.Add(-5, 10);
// Assert
Assert.AreEqual(5, result);
}
[TestMethod]
public void Subtract_PositiveNumbers_ReturnsCorrectDifference()
{
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.Subtract(10, 4);
// Assert
Assert.AreEqual(6, result);
}
[TestMethod]
public void Multiply_TwoNumbers_ReturnsCorrectProduct()
{
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.Multiply(5, 3);
// Assert
Assert.AreEqual(15, result);
}
Testing Exceptions
We should also test error conditions, such as trying to divide by zero:
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void Divide_DenominatorIsZero_ThrowsDivideByZeroException()
{
// Arrange
Calculator calculator = new Calculator();
// Act
calculator.Divide(10, 0);
// Assert is handled by ExpectedException attribute
}
Alternatively, we can use a more modern approach with Assert.ThrowsException:
[TestMethod]
public void Divide_DenominatorIsZero_ThrowsDivideByZeroException()
{
// Arrange
Calculator calculator = new Calculator();
// Act & Assert
Assert.ThrowsException<DivideByZeroException>(() => calculator.Divide(10, 0));
}
Test Initialization and Cleanup
Often you'll need to set up common objects or data before each test. MSTest provides attributes for this:
[TestClass]
public class CalculatorTests
{
private Calculator _calculator;
[TestInitialize]
public void TestInitialize()
{
// This runs before each test
_calculator = new Calculator();
}
[TestCleanup]
public void TestCleanup()
{
// This runs after each test
_calculator = null;
}
[TestMethod]
public void Add_TwoPositiveNumbers_ReturnsCorrectSum()
{
// _calculator is already initialized
int result = _calculator.Add(5, 7);
Assert.AreEqual(12, result);
}
// More tests...
}
Test-Driven Development (TDD)
Unit testing is often associated with Test-Driven Development, a development methodology where you:
- Write a failing test for the functionality you want to implement
- Write the simplest code to make the test pass
- Refactor the code while keeping the test passing
Let's see TDD in action by adding a new feature to our calculator:
[TestMethod]
public void Max_FirstNumberLarger_ReturnsFirstNumber()
{
// Arrange - our calculator doesn't have Max method yet
// Act
int result = _calculator.Max(10, 5);
// Assert
Assert.AreEqual(10, result);
}
This test will fail because we haven't implemented the Max method yet. Now, let's add it:
// Add to Calculator.cs
public int Max(int a, int b)
{
return a > b ? a : b;
}
Now our test will pass. We follow up with another test:
[TestMethod]
public void Max_SecondNumberLarger_ReturnsSecondNumber()
{
int result = _calculator.Max(5, 10);
Assert.AreEqual(10, result);
}
Code Coverage
Code coverage measures how much of your code is covered by tests. Visual Studio Enterprise includes a code coverage tool. To use it:
- Run tests with code coverage (Test > Analyze Code Coverage)
- View the results to see which lines are covered or missed
Aim for high code coverage, but remember that 100% coverage doesn't guarantee bug-free code. Focus on testing important logic and edge cases.
Mocking Dependencies
Real-world classes often depend on other classes, databases, or external services. To isolate the unit being tested, we use mocking frameworks like Moq or NSubstitute.
Here's an example using Moq:
// Service with dependency
public class OrderProcessor
{
private readonly IPaymentGateway _paymentGateway;
public OrderProcessor(IPaymentGateway paymentGateway)
{
_paymentGateway = paymentGateway;
}
public bool ProcessOrder(Order order)
{
// Some business logic
// Use payment gateway
bool paymentSuccess = _paymentGateway.ProcessPayment(
order.CustomerId,
order.Amount
);
return paymentSuccess;
}
}
// Interface
public interface IPaymentGateway
{
bool ProcessPayment(int customerId, decimal amount);
}
Testing with Moq:
[TestMethod]
public void ProcessOrder_PaymentSuccessful_ReturnsTrue()
{
// Arrange
var mockPaymentGateway = new Mock<IPaymentGateway>();
mockPaymentGateway
.Setup(pg => pg.ProcessPayment(It.IsAny<int>(), It.IsAny<decimal>()))
.Returns(true);
var orderProcessor = new OrderProcessor(mockPaymentGateway.Object);
var order = new Order { CustomerId = 123, Amount = 99.95m };
// Act
bool result = orderProcessor.ProcessOrder(order);
// Assert
Assert.IsTrue(result);
mockPaymentGateway.Verify(
pg => pg.ProcessPayment(123, 99.95m),
Times.Once
);
}
Best Practices for Unit Testing
- Test One Thing Per Test: Each test should verify a single behavior or scenario
- Use Descriptive Test Names: Name tests clearly with pattern [MethodBeingTested][Scenario][ExpectedResult]
- Keep Tests Independent: Tests should not depend on each other and should run in any order
- Avoid Testing Private Methods: Test public behavior instead
- Use Setup/Teardown Properly: Initialize common objects but keep them simple
- Write Testable Code: Use dependency injection, avoid static methods, and separate concerns
- Test Edge Cases: Include tests for boundary conditions and error scenarios
- Maintain Tests: Update tests when requirements change; treat test code as production code
- Run Tests Often: Integrate into your development workflow and CI pipeline
Real-World Example: Testing a User Service
Let's create a more complex example of a user service with database operations:
// UserService.cs
public class UserService
{
private readonly IUserRepository _userRepository;
private readonly IEmailService _emailService;
public UserService(IUserRepository userRepository, IEmailService emailService)
{
_userRepository = userRepository;
_emailService = emailService;
}
public bool RegisterUser(string email, string password)
{
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
return false;
if (password.Length < 8)
return false;
if (_userRepository.ExistsWithEmail(email))
return false;
var user = new User
{
Email = email,
HashedPassword = HashPassword(password)
};
bool saved = _userRepository.Save(user);
if (saved)
{
_emailService.SendWelcomeEmail(email);
}
return saved;
}
private string HashPassword(string password)
{
// In a real app, use a proper password hashing algorithm
return Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes(password)
);
}
}
Now let's test this service:
[TestClass]
public class UserServiceTests
{
private Mock<IUserRepository> _mockRepository;
private Mock<IEmailService> _mockEmailService;
private UserService _userService;
[TestInitialize]
public void Setup()
{
_mockRepository = new Mock<IUserRepository>();
_mockEmailService = new Mock<IEmailService>();
_userService = new UserService(_mockRepository.Object, _mockEmailService.Object);
}
[TestMethod]
public void RegisterUser_ValidUserDetails_ReturnsTrue()
{
// Arrange
string email = "[email protected]";
string password = "password123";
_mockRepository.Setup(r => r.ExistsWithEmail(email)).Returns(false);
_mockRepository.Setup(r => r.Save(It.IsAny<User>())).Returns(true);
// Act
bool result = _userService.RegisterUser(email, password);
// Assert
Assert.IsTrue(result);
_mockRepository.Verify(r => r.Save(It.IsAny<User>()), Times.Once);
_mockEmailService.Verify(e => e.SendWelcomeEmail(email), Times.Once);
}
[TestMethod]
public void RegisterUser_ExistingEmail_ReturnsFalse()
{
// Arrange
string email = "[email protected]";
string password = "password123";
_mockRepository.Setup(r => r.ExistsWithEmail(email)).Returns(true);
// Act
bool result = _userService.RegisterUser(email, password);
// Assert
Assert.IsFalse(result);
_mockRepository.Verify(r => r.Save(It.IsAny<User>()), Times.Never);
_mockEmailService.Verify(e => e.SendWelcomeEmail(It.IsAny<string>()), Times.Never);
}
[TestMethod]
public void RegisterUser_ShortPassword_ReturnsFalse()
{
// Arrange
string email = "[email protected]";
string password = "short"; // Less than 8 characters
// Act
bool result = _userService.RegisterUser(email, password);
// Assert
Assert.IsFalse(result);
_mockRepository.Verify(r => r.Save(It.IsAny<User>()), Times.Never);
}
}
Testing Asynchronous Code
Modern C# applications often use async/await. Here's how to test async methods:
// Async method in service
public async Task<User> GetUserByIdAsync(int id)
{
return await _userRepository.GetByIdAsync(id);
}
// Testing async method
[TestMethod]
public async Task GetUserByIdAsync_ExistingId_ReturnsUser()
{
// Arrange
int userId = 1;
var expectedUser = new User { Id = userId, Name = "Test User" };
_mockRepository
.Setup(r => r.GetByIdAsync(userId))
.ReturnsAsync(expectedUser);
// Act
var result = await _userService.GetUserByIdAsync(userId);
// Assert
Assert.IsNotNull(result);
Assert.AreEqual(userId, result.Id);
Assert.AreEqual("Test User", result.Name);
}
Data-Driven Tests
When you need to test the same method with different inputs, use data-driven tests:
[DataTestMethod]
[DataRow(2, 3, 5)]
[DataRow(0, 0, 0)]
[DataRow(-5, 5, 0)]
[DataRow(int.MaxValue, 1, int.MinValue)] // Overflow case
public void Add_VariousInputs_ReturnsExpectedSum(int a, int b, int expected)
{
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.Add(a, b);
// Assert
Assert.AreEqual(expected, result);
}
Summary
Unit testing is an essential skill for C# developers that helps ensure code reliability and maintainability. In this guide, we covered:
- The importance and benefits of unit testing
- Popular C# testing frameworks
- Creating and structuring test projects
- Writing effective unit tests
- Test-driven development
- Mocking dependencies
- Testing best practices
- Real-world testing scenarios
- Testing asynchronous code
- Data-driven tests
By implementing these practices in your C# projects, you'll build more robust applications with fewer bugs and make future maintenance and enhancements easier.
Additional Resources
- Microsoft Docs: Unit Testing in .NET
- MSTest Documentation
- xUnit Documentation
- NUnit Documentation
- Moq GitHub Repository
- Book: "The Art of Unit Testing" by Roy Osherove
- Book: "Unit Testing Principles, Practices, and Patterns" by Vladimir Khorikov
Exercises
- Create a
StringUtils
class with methods for common string operations (Reverse, IsPalindrome, CountWords) and write comprehensive tests for it. - Build a
ShoppingCart
class that manages items and calculates totals, then test it thoroughly using TDD. - Create a simple banking application with
Account
andTransaction
classes, and test money transfer between accounts. - Add a logging feature to an existing application and write tests that verify log messages are created correctly (using mocks).
- Refactor an existing untested class to make it testable, then add comprehensive tests.
Happy testing!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)