Levelwise
English
Testing

Unit testing with xUnit

A Unit Test checks a small piece of logic apart from the database and the network. A good test is fast, independent and reliable, and it checks behavior, not implementation details. We replace slow or outside dependencies with a Fake, a Stub or a Mock.

Not reviewedWritten with AI helpReading time: 14 minExample of discounts in an online shopC# and xUnit v3 code

Author: bezzad

The problem: every change brings fear of breaking something

In our online shop we have a discount rule:

  • A special customer (VIP) gets a ten percent discount.
  • On Fridays, everyone gets an extra five percent discount.
  • A negative or zero amount is not accepted.

Now the sales team wants a new rule. A developer changes the code. The question is: how do we know the old rules are not broken?

The manual way is to run the app, log in, buy an item and look at the number. This is slow. And each time we may forget a case.

A better way is to write each rule once in code and check it each time in a few milliseconds. We call this code a Unit Test.

What exactly is a Unit Test?

A unit test runs a small piece of logic apart from the rest of the system. The database, the network, files and the system clock are not inside it. Because of this:

  1. It is very fast. Thousands of tests run in a few seconds.
  2. It shows exactly where the error is. If a test fails, we know the problem is in that one class.
  3. It always gives the same result. Because it does not depend on anything outside the code.

These tests are the base of the test pyramid. Most of your tests should be of this type. A few Integration tests and a small number of end-to-end (E2E) tests come above them.

E2EFewIntegrationSomeUnitVery manySlow and costlyFast and cheapWhole-system trustExact error spot
The higher we go, the slower and more expensive the test gets, but it checks a bigger part of the system together.
What does unit mean? A unit is not always one class or one method. A unit means one behavior. Sometimes one behavior is built with two or three small classes. The test of that behavior is still a unit test, as long as the database and the network are not inside it.

The shape of each test: arrange, act, assert

All good tests have one shape. We call this shape Arrange-Act-Assert:

Arrange1. PrepareVIP and a Friday clocknew FakeTimeProvider(...)Act2. RunCall only one thingcalc.Apply(1000m, vip)Assert3. Check the resultIs it the expected result?Assert.Equal(850m, result)Each test checks only one behavior
  1. Arrange. We create the data and objects we need.
  2. Act. We call only one thing. The same behavior we are testing.
  3. Assert. We compare the result with the expected value.

Code: testing the discount rule

First let us see the code itself. We get the clock from outside. The TimeProvider class in .NET is made for this job. If the code uses DateTime.Now directly, we cannot create a “Friday” in the test.

public sealed record Customer(bool IsVip);

public sealed class DiscountCalculator(TimeProvider clock)
{
    public decimal Apply(decimal total, Customer customer)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(total);

        var percent = customer.IsVip ? 10 : 0;
        if (clock.GetUtcNow().DayOfWeek == DayOfWeek.Friday)
            percent += 5;

        return total - total * percent / 100;
    }
}

Now the tests with xUnit. The Fact attribute means “one test”. The Theory attribute means “one test with several inputs”. The FakeTimeProvider class comes from the Microsoft.Extensions.TimeProvider.Testing package and gives us a fixed clock.

public class DiscountCalculatorTests
{
    // 2026-10-05 is a Monday, 2026-10-09 is a Friday.
    private static readonly DateTimeOffset Monday = new(2026, 10, 5, 12, 0, 0, TimeSpan.Zero);
    private static readonly DateTimeOffset Friday = new(2026, 10, 9, 12, 0, 0, TimeSpan.Zero);

    [Theory]
    [InlineData(false, 1000)]
    [InlineData(true, 900)]
    public void Monday_discount_depends_only_on_vip(bool isVip, int expected)
    {
        // Arrange
        var calc = new DiscountCalculator(new FakeTimeProvider(Monday));

        // Act
        var result = calc.Apply(1000m, new Customer(isVip));

        // Assert
        Assert.Equal(expected, result);
    }

    [Fact]
    public void Vip_on_friday_gets_both_discounts()
    {
        var calc = new DiscountCalculator(new FakeTimeProvider(Friday));

        var result = calc.Apply(1000m, new Customer(IsVip: true));

        Assert.Equal(850m, result);
    }

    [Fact]
    public void Zero_total_is_rejected()
    {
        var calc = new DiscountCalculator(new FakeTimeProvider(Monday));

        Assert.Throws<ArgumentOutOfRangeException>(
            () => calc.Apply(0m, new Customer(IsVip: false)));
    }
}

Look at the test names. The name of each test is a sentence about behavior. If a test fails, we know from the name which rule is broken.

What makes a good test?

  1. It is fast. If tests are slow, nobody runs them.
  2. It is independent. The run order does not matter. No test depends on the data of another test.
  3. It is repeatable. Today and tomorrow, on a laptop and on a server, it has one result. So a real clock, random numbers and the network are not inside it.
  4. It reports the result by itself. It is either green or red. Nobody needs to read the output with their eyes.
  5. It tests behavior, not implementation. If we rewrite the code and the behavior does not change, the test must not break.
The biggest sign of a bad test: After a simple rewrite, ten tests turn red, but the app has no problem. This means the tests depend on inside details, not on behavior.

Dependencies: Stub, Fake and Mock

The OrderService class in our shop has three dependencies: it gets the item price from the catalog, saves the order and sends a confirmation email. In a unit test we do not want any of these to be real. Instead we put a test replacement (Test Double) in their place.

OrderServiceThe code we testStubIPriceCatalogOnly returns a ready answerFakeInMemoryOrdersSimple but working, like a listMockIEmailSenderAfter the run, we check if it was called
Each type of replacement fits one job.
Type What does it do? When does it fit?
Stub It only returns a fixed answer. When our code reads data from the dependency.
Fake A simple but working version. For example, an in-memory order store. When the state matters and we want to read it later.
Mock It records which method was called with which input. We check it later. When the call itself is the result of the work. Like sending an email.

A hand-made Fake is usually only a few lines:

public sealed class InMemoryOrders : IOrderRepository
{
    public List<Order> Saved { get; } = [];

    public Task AddAsync(Order order, CancellationToken ct)
    {
        Saved.Add(order);
        return Task.CompletedTask;
    }
}

For a Mock, we usually use a library like NSubstitute or Moq. This test checks that after an order is placed, exactly one email is sent:

[Fact]
public async Task Placing_an_order_saves_it_and_sends_one_email()
{
    var ct = TestContext.Current.CancellationToken;
    var catalog = Substitute.For<IPriceCatalog>();
    catalog.GetPrice(7).Returns(500m);               // stub
    var orders = new InMemoryOrders();                // fake
    var email = Substitute.For<IEmailSender>();       // mock
    var service = new OrderService(catalog, orders, email);

    await service.PlaceAsync(customerEmail: "ali@shop.test", productId: 7, ct);

    Assert.Single(orders.Saved);
    Assert.Equal(500m, orders.Saved[0].Total);
    await email.Received(1).SendAsync("ali@shop.test", Arg.Any<string>(), Arg.Any<CancellationToken>());
}
A simple rule for Mocks: As much as possible, check the result with state (like the Saved list). Only check the call when the call itself is the result of the work and can be seen outside the system. Each extra Mock ties the test closer to the implementation and makes it more fragile.

Important rules

  1. Replace only the borders of the system. The database, the network, email, the clock and random numbers. Use your own simple inside classes for real.
  2. Do not test a private method directly. Test it through the public method. If a private method is so important that it needs its own test, it should probably be a separate class.
  3. One behavior in each test. Several Asserts are fine, as long as they are all about that one behavior.
  4. Code coverage (Code Coverage) is not the goal. One hundred percent coverage only says each line ran once. It does not say the result was checked correctly. Use it to find parts without tests, not to give a score.
  5. Do not forget edge cases. Zero, negative, an empty list, the maximum value and the last day of the month. Most bugs are here.
  6. A test is also code. Keep it clean. But in tests, readability matters more than removing repetition.

Common mistakes

Mistake Result The right way
Using DateTime.Now directly in code The test is green only on some days. Get the clock from outside with TimeProvider.
Mocking everything, even simple classes The test breaks with every rewrite. Replace only the borders.
Checking the order of calls to inside methods The test locks the implementation. Check the result and the final state.
Shared data between tests (a static field) Tests depend on the run order. Each test builds its own data.
A test without an Assert It is always green, even if the code is wrong. Each test must be able to turn red.
Names like Test1 When it fails, nobody understands what is broken. The test name is a sentence about behavior.

What should we check with Unit Tests?

Good fit

  • Business rules, like discount, tax and shipping cost.
  • Calculations and conversions.
  • Decisions that have many conditions.
  • Logic inside an Aggregate and a Value Object.

Bad fit

  • Database queries. Write Integration tests for them.
  • DI and Middleware settings in ASP.NET Core.
  • Code that only moves data from one place to another and has no logic.
  • Other people’s libraries themselves.

Summary in six lines

  1. A unit test checks a small behavior apart from the database and the network.
  2. Most tests in a project should be of this type, because they are fast and exact.
  3. Each test has three parts: arrange, act and assert.
  4. A good test is fast, independent and repeatable, and it tests behavior, not implementation.
  5. A Stub for reading data, a Fake for state, and a Mock for checking an outside message.
  6. Get the clock and random things from outside, so the test always has one result.