A unit test checks a small unit of behavior in an isolated and repeatable way.
It consists of testing the smallest possible piece of code (usually a method) in a completely isolated manner.
- No database.
- No network.
- No calls to other APIs.
If the test fails, it should point to a specific unit of behavior. If it depends on the database being up, we are already talking about an integration test (which we will see later).
In the .NET ecosystem, one of the most widely used tools for testing is xUnit.
Setting Up the Test Project
Tests don’t live in your main project (MyApi). They live in a separate project, typically a class library.
The typical structure of a professional solution is:
📂 MySolution
├── 📂 src
│ └── 📜 MyApi
└── 📂 tests
└── 📜 MyApi.UnitTests (xUnit Project)To create this from the terminal:
# 1. Create the test project
dotnet new xunit -n MyApi.UnitTests
# 2. Add a reference to the project we want to test
cd MyApi.UnitTests
dotnet add reference ../../src/MyApi/MyApi.csprojAnatomy of a Test: The AAA Pattern
Let’s imagine we have a simple service with pure business logic.
// In MyApi (src)
public class DiscountCalculator
{
public decimal Calculate(decimal price, bool isVip)
{
if (price < 0) throw new ArgumentException("Price cannot be negative");
if (isVip) return price * 0.90m; // 10% discount
return price;
}
}To test this, we create a class in our test project. A well-written test follows the AAA pattern:
Arrange: Instantiate the class and prepare the data.
Act: Execute the method you want to test.
Assert: Verify that the result is the expected one.
using Xunit; // The main library
public class DiscountCalculatorTests
{
[Fact] // 👈 This marks the method as a Test
public void Calculate_IfUserIsVip_Applies10PercentDiscount()
{
// 1. Arrange
var calculator = new DiscountCalculator();
decimal originalPrice = 100m;
// 2. Act
decimal result = calculator.Calculate(originalPrice, isVip: true);
// 3. Assert
Assert.Equal(90m, result); // We expect 90
}
}Running the Tests
You can use the Test Explorer in Visual Studio or run in the terminal:
dotnet test
Fact and Theory
xUnit has two main attributes:
[Fact]: a test with no parameters representing a single case.[Theory]: a parameterized test that runs multiple times with different data.
We want to test that the discount is not applied to regular users. Should we test with 100? What about 50? What about 0?
[Theory]
[InlineData(100, 100)] // Input 100, Output 100
[InlineData(50, 50)] // Input 50, Output 50
[InlineData(0, 0)] // Input 0, Output 0
public void Calculate_IfNotVip_DoesNotApplyDiscount(decimal price, decimal expected)
{
// Arrange
var calculator = new DiscountCalculator();
// Act
var result = calculator.Calculate(price, isVip: false);
// Assert
Assert.Equal(expected, result);
}With [Theory] and [InlineData], we’ve written 3 tests in the space of one.
Testing Exceptions
A good test not only tests the “happy path,” but also tests that the code fails when it should. Our calculator throws an exception if the price is negative. How do we test that?
[Fact]
public void Calculate_IfPriceNegative_ThrowsException()
{
// Arrange
var calculator = new DiscountCalculator();
// Act & Assert
// Capture the exception
Assert.Throws<ArgumentException>(() =>
{
calculator.Calculate(-10, true);
});
}Improving Readability
xUnit’s Assert.Equal(expected, actual) works, but sometimes it’s confusing (which one was the expected, the first or the second?).
For years, FluentAssertions has been a very popular library for making tests read like natural language.
Since version 8, FluentAssertions has changed its license, and commercial use requires reviewing its terms. In enterprise projects, consider using xUnit’s Assert, pinning a compatible version, or using alternatives like Shouldly.
dotnet add package FluentAssertionsSee the difference:
using FluentAssertions;
// Classic xUnit
Assert.Equal(90m, result);
Assert.StartsWith("Price", exception.Message);
// FluentAssertions
result.Should().Be(90m);
exception.Message.Should().StartWith("Price");
list.Should().NotBeEmpty().And.HaveCount(3);The Problem of Dependencies
So far, everything has been easy because DiscountCalculator didn’t depend on anyone. It was pure logic.
In a real application, services look more like this:
public class ProductService
{
private readonly IProductRepository _repo; // Dependency
public ProductService(IProductRepository repo)
{
_repo = repo;
}
public Product Get(int id)
{
var product = _repo.GetById(id); // Calls DB
if (product == null) throw new KeyNotFoundException();
return product;
}
}If you try to test this by doing new ProductService(...), the compiler will ask for a repository. And you can’t pass the real repository because:
- The unit test must not touch the database.
- It would be extremely slow.
At this point, Mocks appear. We need to create a “fake repository” that behaves the way we want to trick the service.