.NET Unit Testing
Introduction
Unit testing is a fundamental practice in modern software development that helps ensure your code functions as expected. In the .NET ecosystem, unit testing involves writing code that verifies the behavior of individual units or components of your application in isolation.
In this guide, we'll explore how to implement effective unit tests in .NET applications. Whether you're building web applications with ASP.NET Core, desktop apps with WPF, or libraries for general use, the concepts and techniques covered here will help you create more reliable and maintainable code.
Why Unit Testing Matters
Before diving into the technical details, let's understand why unit testing is crucial:
- Bug detection: Find issues early in the development process
- Code quality: Encourages better, more modular code design
- Documentation: Tests serve as living documentation of how code should work
- Confidence: Build confidence in making changes without breaking existing functionality
- Faster development: Ultimately saves time by reducing debugging and manual testing
Unit Testing Frameworks in .NET
The .NET ecosystem offers several testing frameworks to help you write and run unit tests:
1. MSTest (Microsoft Test)
This is Microsoft's built-in testing framework, integrated directly with Visual Studio.
2. NUnit
One of the most popular open-source testing frameworks, offering a rich set of assertions and test attributes.
3. xUnit.net
A newer, community-focused testing tool for .NET, designed with extensibility in mind.
For this guide, we'll primarily focus on MSTest and xUnit, but most concepts apply across all frameworks.
Getting Started with MSTest
MSTest is the easiest framework to start with for beginners as it's integrated into Visual Studio.
Setting Up Your First Test Project
- In Visual Studio, right-click on your solution and select Add > New Project
- Search for "test" and select MSTest Test Project
- Name your project (typically your main project name with ".Tests" appended)
Writing Your First Test
Let's create a simple calculator class and test its addition function:
First, in your main project, create a calculator class:
namespace MyApp
{
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
public int Subtract(int a, int b)
{
return a - b;
}
}
}
Now, in your test project, write a test:
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MyApp;
namespace MyApp.Tests
{
[TestClass]
public class CalculatorTests
{
[TestMethod]
public void Add_TwoNumbers_ReturnsCorrectSum()
{
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.Add(5, 3);
// Assert
Assert.AreEqual(8, result);
}
}
}
This test follows the AAA pattern:
- Arrange: Set up the test conditions
- Act: Execute the code being tested
- Assert: Verify the results match expectations
Running Tests
To run your tests:
- Open Test Explorer (Test > Test Explorer)
- Click "Run All" or right-click on specific tests to run them
Unit Testing with xUnit
xUnit is a more modern framework with some different conventions.
Setting Up xUnit
- Add a new .NET Class Library project to your solution
- Install the xUnit NuGet packages:
- xunit
- xunit.runner.visualstudio
Writing Tests in xUnit
The same calculator test in xUnit would look like:
using Xunit;
using MyApp;
namespace MyApp.Tests
{
public class CalculatorTests
{
[Fact]
public void Add_TwoNumbers_ReturnsCorrectSum()
{
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.Add(5, 3);
// Assert
Assert.Equal(8, result);
}
}
}
Notice the differences:
[TestClass]
is not needed[TestMethod]
becomes[Fact]
Assert.AreEqual
becomesAssert.Equal
Parameterized Tests in xUnit
xUnit makes it easy to run the same test with different inputs:
[Theory]
[InlineData(5, 3, 8)]
[InlineData(0, 0, 0)]
[InlineData(-5, 5, 0)]
[InlineData(int.MaxValue, 1, int.MinValue)] // Overflow case
public void Add_MultipleScenarios_ReturnsExpectedResults(int a, int b, int expected)
{
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
This approach allows you to test multiple scenarios without duplicating test code.
Best Practices for Unit Testing
1. Test One Thing per Test
Each test should verify a single behavior or outcome:
// Good: Tests one specific behavior
[TestMethod]
public void Add_NegativeNumbers_ReturnsCorrectSum()
{
Calculator calculator = new Calculator();
Assert.AreEqual(-8, calculator.Add(-5, -3));
}
// Bad: Testing multiple behaviors in one test
[TestMethod]
public void Calculator_Operations_Work()
{
Calculator calculator = new Calculator();
Assert.AreEqual(8, calculator.Add(5, 3));
Assert.AreEqual(2, calculator.Subtract(5, 3));
// More assertions...
}
2. Use Descriptive Test Names
Name your tests clearly to indicate:
- What's being tested
- Under what circumstances
- What the expected outcome is
// Good name
public void Subtract_WhenSecondNumberIsLarger_ReturnsNegativeNumber()
// Poor name
public void SubtractTest()
3. Arrange-Act-Assert Pattern
Structure tests with clear sections:
[TestMethod]
public void Divide_NonZeroDenominator_ReturnsCorrectResult()
{
// Arrange
Calculator calculator = new Calculator();
// Act
double result = calculator.Divide(10, 2);
// Assert
Assert.AreEqual(5, result);
}
4. Test Edge Cases
Don't just test the happy path; consider boundaries and special cases:
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void Divide_ByZero_ThrowsException()
{
Calculator calculator = new Calculator();
calculator.Divide(10, 0);
}
Mocking Dependencies
Real-world code often has dependencies. For true unit tests, we should isolate the code under test using mocks.
Using Moq Framework
First, install the Moq NuGet package.
Let's test a service that depends on a repository:
public interface IProductRepository
{
Product GetById(int id);
}
public class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public string GetProductName(int id)
{
var product = _repository.GetById(id);
return product?.Name ?? "Not found";
}
}
Here's how to test it with Moq:
[TestMethod]
public void GetProductName_ExistingProduct_ReturnsName()
{
// Arrange
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(repo => repo.GetById(1))
.Returns(new Product { Id = 1, Name = "Test Product" });
var service = new ProductService(mockRepo.Object);
// Act
var result = service.GetProductName(1);
// Assert
Assert.AreEqual("Test Product", result);
}
[TestMethod]
public void GetProductName_NonExistingProduct_ReturnsNotFound()
{
// Arrange
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(repo => repo.GetById(It.IsAny<int>()))
.Returns((Product)null);
var service = new ProductService(mockRepo.Object);
// Act
var result = service.GetProductName(999);
// Assert
Assert.AreEqual("Not found", result);
}
Real-World Example: Testing an ASP.NET Core Controller
Let's create and test a simple API controller:
public class TodoItem
{
public int Id { get; set; }
public string Title { get; set; }
public bool IsComplete { get; set; }
}
public interface ITodoRepository
{
IEnumerable<TodoItem> GetAll();
TodoItem GetById(int id);
void Add(TodoItem item);
}
[ApiController]
[Route("api/[controller]")]
public class TodoController : ControllerBase
{
private readonly ITodoRepository _repository;
public TodoController(ITodoRepository repository)
{
_repository = repository;
}
[HttpGet]
public ActionResult<IEnumerable<TodoItem>> GetAll()
{
return Ok(_repository.GetAll());
}
[HttpGet("{id}")]
public ActionResult<TodoItem> GetById(int id)
{
var item = _repository.GetById(id);
if (item == null)
{
return NotFound();
}
return item;
}
}
Now let's test this controller:
[TestClass]
public class TodoControllerTests
{
[TestMethod]
public void GetAll_ReturnsOkResult_WithAllItems()
{
// Arrange
var mockRepo = new Mock<ITodoRepository>();
var testItems = new List<TodoItem>
{
new TodoItem { Id = 1, Title = "Item 1", IsComplete = false },
new TodoItem { Id = 2, Title = "Item 2", IsComplete = true }
};
mockRepo.Setup(repo => repo.GetAll())
.Returns(testItems);
var controller = new TodoController(mockRepo.Object);
// Act
var result = controller.GetAll();
// Assert
var okResult = result.Result as OkObjectResult;
Assert.IsNotNull(okResult);
var returnedItems = okResult.Value as IEnumerable<TodoItem>;
Assert.IsNotNull(returnedItems);
Assert.AreEqual(2, returnedItems.Count());
}
[TestMethod]
public void GetById_ExistingId_ReturnsOkResult()
{
// Arrange
var testItem = new TodoItem { Id = 1, Title = "Test Item", IsComplete = false };
var mockRepo = new Mock<ITodoRepository>();
mockRepo.Setup(repo => repo.GetById(1))
.Returns(testItem);
var controller = new TodoController(mockRepo.Object);
// Act
var result = controller.GetById(1);
// Assert
var actionResult = result.Result;
Assert.IsNull(actionResult); // ActionResult will be null because the item is returned directly
var returnedItem = result.Value;
Assert.IsNotNull(returnedItem);
Assert.AreEqual(1, returnedItem.Id);
Assert.AreEqual("Test Item", returnedItem.Title);
}
[TestMethod]
public void GetById_NonExistingId_ReturnsNotFound()
{
// Arrange
var mockRepo = new Mock<ITodoRepository>();
mockRepo.Setup(repo => repo.GetById(999))
.Returns((TodoItem)null);
var controller = new TodoController(mockRepo.Object);
// Act
var result = controller.GetById(999);
// Assert
var actionResult = result.Result;
Assert.IsInstanceOfType(actionResult, typeof(NotFoundResult));
}
}
Test-Driven Development (TDD)
Unit testing is closely related to Test-Driven Development, a methodology that follows this cycle:
- Red: Write a failing test for the functionality you want to implement
- Green: Write the simplest code that makes the test pass
- Refactor: Clean up the code while keeping the tests passing
Let's demonstrate TDD by building a string utility class:
Step 1: Write a failing test
[TestMethod]
public void ReverseString_NonEmptyString_ReturnsReversedString()
{
// Arrange
StringUtils utils = new StringUtils();
// Act
string result = utils.ReverseString("hello");
// Assert
Assert.AreEqual("olleh", result);
}
Step 2: Write minimal implementation to pass
public class StringUtils
{
public string ReverseString(string input)
{
char[] chars = input.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
}
Step 3: Add more tests and refine
[TestMethod]
public void ReverseString_EmptyString_ReturnsEmptyString()
{
StringUtils utils = new StringUtils();
Assert.AreEqual("", utils.ReverseString(""));
}
[TestMethod]
public void ReverseString_NullInput_ThrowsArgumentNullException()
{
StringUtils utils = new StringUtils();
Assert.ThrowsException<ArgumentNullException>(() => utils.ReverseString(null));
}
Step 4: Improve the implementation
public string ReverseString(string input)
{
if (input == null)
throw new ArgumentNullException(nameof(input));
if (input.Length <= 1)
return input;
char[] chars = input.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
Code Coverage and Quality Metrics
Code coverage tools help you understand how much of your code is tested:
-
Enable code coverage in Visual Studio:
- Test > Analyze Code Coverage > All Tests
-
View the results to identify untested code sections
-
Aim for high coverage, but remember that 100% coverage doesn't guarantee perfect tests.
Summary
Unit testing is an essential skill for .NET developers that leads to higher quality, more maintainable code. In this guide, we've covered:
- The fundamentals of unit testing in .NET
- Setting up and using MSTest and xUnit frameworks
- Writing effective unit tests with clear structure and assertions
- Mocking dependencies to isolate code under test
- Real-world examples with ASP.NET Core
- Test-Driven Development approach
- Measuring code coverage
By incorporating these practices into your development workflow, you'll build more reliable applications and catch issues earlier in the development process.
Additional Resources and Exercises
Resources:
- Microsoft's official unit testing documentation
- xUnit.net official documentation
- Moq GitHub repository
Exercises:
- Create a string utility class with methods for common operations (reverse, capitalize, truncate) and write tests for each method.
- Build a simple calculator with advanced operations (square root, power, etc.) using TDD.
- Create a web API controller for a bookstore with CRUD operations and write comprehensive tests.
- Refactor an existing untested code by adding appropriate tests.
- Try to achieve at least 80% code coverage on a small project.
By consistently practicing these concepts, you'll become proficient in unit testing and improve the overall quality of your .NET applications.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)