TDD in C# with xUnit and NSubstitute: red-green-refactor on a domain aggregate

.NET Architecture & Design Patterns November 13, 2026 csharp

Red -> Green -> Refactor: first I write a failing test that describes the behavior, then the minimum code to make it pass, then I refactor with the test as a safety net.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// ======================================================================
// Q: Do you practice TDD? Show me.
//
// Red -> Green -> Refactor: first I write a failing test that describes the
// behavior, then the minimum code to make it pass, then I refactor with the test
// as a safety net. Benefits: it pushes toward good design (testable code is
// loosely coupled), it serves as living documentation and it protects against
// regressions.
// - Test pyramid: many fast unit tests (domain), fewer integration tests
//   (WebApplicationFactory + Testcontainers, Q13), a few E2E tests.
// - Tools: xUnit / NUnit, NSubstitute or Moq, Testcontainers, Verify (snapshot
//   tests), Stryker.NET (mutation testing: checks whether the tests actually
//   catch bugs).
// - Mock only at the boundaries (ERP, email, time), not the DbContext. Test
//   names describe behavior: Behavior_condition_expected.
// TDD with AI: I write the tests (they are the spec) and the agent implements
// until they are green. If the AI writes both the tests and the code, the tests
// merely confirm whatever it wrote (Q2).
// ======================================================================

// Domain unit tests (red-green-refactor)
public class OrderTests
{
    [Fact]                                                     // 1. RED: the rule does not exist yet
    public void Cannot_add_line_to_placed_order()
    {
        // Arrange
        var order = OrderBuilder.Draft().WithLine("SKU-1", 2).Build();
        order.Place();

        // Act
        var act = () => order.AddLine("SKU-2", 1, new Money(10m, "EUR"));

        // Assert
        Assert.Throws<DomainException>(act);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-3)]
    public void Quantity_must_be_positive(int qty)
    {
        var order = OrderBuilder.Draft().Build();
        Assert.Throws<DomainException>(() => order.AddLine("SKU-1", qty, new Money(10m, "EUR")));
    }
}
// 2. GREEN: add the check to Order.AddLine (Q19)
// 3. REFACTOR: clean up the code, the tests stay green

// Application service test, with fakes only at the boundaries
[Fact]
public async Task Placing_an_order_saves_it_and_raises_OrderPlaced()
{
    var order = OrderBuilder.Draft().WithLine("SKU-1", 2).Build();
    var repo  = Substitute.For<IOrderRepository>();          // NSubstitute
    repo.GetAsync(order.Id, Arg.Any<CancellationToken>()).Returns(order);
    var uow   = Substitute.For<IUnitOfWork>();

    var sut = new PlaceOrderHandler(repo, uow);
    await sut.Handle(new PlaceOrder(order.Id), CancellationToken.None);

    Assert.Equal(OrderStatus.Placed, order.Status);
    Assert.Contains(order.Events, e => e is OrderPlaced);
    await uow.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}