Learnixo

.NET & C# Development · Lesson 159 of 229

TDD in .NET — Red, Green, Refactor in Practice

TDD in .NET — Red, Green, Refactor in Practice

Test-Driven Development means writing the test before writing the code. The discipline forces you to design the interface before the implementation — and leaves you with a comprehensive test suite.


The Red-Green-Refactor Cycle

RED:     Write a test that fails (the feature doesn't exist yet)
GREEN:   Write the minimum code to make the test pass (no cleanup yet)
REFACTOR: Clean up the code without breaking any test

Repeat for the next requirement.

Why this order matters:
  - Writing the test first forces you to define the API before implementing it
  - "Minimum code to pass" stops over-engineering
  - Refactoring with green tests gives you a safety net

Step 1: Red — Write a Failing Test

C#
// We want to build an Order domain entity.
// Start with the simplest requirement: an order can be created.

public class OrderTests
{
    [Fact]
    public void Create_WithValidCustomer_SetsStatusToPending()
    {
        // Arrange + Act
        var order = Order.Create(customerId: 42, items: [new OrderItem(1, 1, 9.99m)]);

        // Assert
        Assert.Equal(42, order.CustomerId);
        Assert.Equal(OrderStatus.Pending, order.Status);
        Assert.NotEmpty(order.Items);
    }
}

// Run: dotnet test → FAILS — Order.Create does not exist yet ✓ (Red)

Step 2: Green — Minimum Code to Pass

C#
// Minimum implementation to make the test green
public class Order
{
    public int         CustomerId { get; private set; }
    public OrderStatus Status     { get; private set; }
    public IReadOnlyList<OrderItem> Items { get; private set; } = [];

    private Order() { }

    public static Order Create(int customerId, IReadOnlyList<OrderItem> items)
    {
        return new Order
        {
            CustomerId = customerId,
            Status     = OrderStatus.Pending,
            Items      = items,
        };
    }
}

// Run: dotnet test → PASSES ✓ (Green)

Step 3: Red — Add the Next Requirement

C#
// Requirement: an order cannot be created with zero items

[Fact]
public void Create_WithNoItems_ThrowsDomainException()
{
    // Act + Assert
    var ex = Assert.Throws<DomainException>(() =>
        Order.Create(customerId: 42, items: []));

    Assert.Contains("at least one item", ex.Message, StringComparison.OrdinalIgnoreCase);
}

// Run: dotnet test → FAILS (Order.Create doesn't throw) ✓ (Red)

Step 4: Green — Extend the Implementation

C#
public static Order Create(int customerId, IReadOnlyList<OrderItem> items)
{
    if (items.Count == 0)
        throw new DomainException("An order must have at least one item.");

    return new Order
    {
        CustomerId = customerId,
        Status     = OrderStatus.Pending,
        Items      = items,
    };
}

// Run: dotnet test → ALL PASS ✓ (Green)

Step 5: Refactor

C#
// Both tests still pass. Now clean up:
// - Extract validation into a private method for readability
// - Add guard clause pattern

public static Order Create(int customerId, IReadOnlyList<OrderItem> items)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(customerId);
    if (items.Count == 0)
        throw new DomainException("An order must have at least one item.");

    return new Order
    {
        CustomerId = customerId,
        Status     = OrderStatus.Pending,
        Items      = items,
    };
}

// Run: dotnet test → ALL PASS ✓ (still Green after refactor)

Testing a Command Handler with NSubstitute

C#
// Add NSubstitute and FluentAssertions for better test experience
// dotnet add package NSubstitute
// dotnet add package FluentAssertions

public class CreateOrderHandlerTests
{
    private readonly IOrderRepository _repo  = Substitute.For<IOrderRepository>();
    private readonly IEventBus        _bus   = Substitute.For<IEventBus>();
    private readonly CreateOrderHandler _sut;

    public CreateOrderHandlerTests()
    {
        _sut = new CreateOrderHandler(_repo, _bus);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesAndReturnsOrderId()
    {
        // Arrange
        var command = new CreateOrderCommand(
            CustomerId: 42,
            Items: [new OrderItemDto(1, 2, 9.99m)]);

        _repo.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
             .Returns(Task.CompletedTask);

        // Capture what gets saved
        Order? savedOrder = null;
        await _repo.AddAsync(Arg.Do<Order>(o => savedOrder = o), Arg.Any<CancellationToken>());

        // Act
        var orderId = await _sut.Handle(command, CancellationToken.None);

        // Assert
        await _repo.Received(1).AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
        await _bus.Received(1).PublishAsync(Arg.Any<OrderCreatedEvent>(), Arg.Any<CancellationToken>());
        savedOrder!.CustomerId.Should().Be(42);
        savedOrder.Status.Should().Be(OrderStatus.Pending);
    }

    [Fact]
    public async Task Handle_EmptyItems_ThrowsDomainException()
    {
        var command = new CreateOrderCommand(42, Items: []);

        await Assert.ThrowsAsync<DomainException>(
            () => _sut.Handle(command, CancellationToken.None));

        // Verify nothing was saved
        await _repo.DidNotReceive().AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
    }
}

TDD for a Value Object

C#
// Email value object — TDD from the first test

// Test 1: valid email
[Theory]
[InlineData("user@example.com")]
[InlineData("user+tag@sub.domain.co")]
public void Create_ValidEmail_Succeeds(string rawEmail)
{
    var email = Email.Create(rawEmail);
    email.Value.Should().Be(rawEmail.ToLowerInvariant());
}

// Test 2: invalid email
[Theory]
[InlineData("")]
[InlineData("notanemail")]
[InlineData("@nodomain.com")]
public void Create_InvalidEmail_ThrowsDomainException(string rawEmail)
{
    Assert.Throws<DomainException>(() => Email.Create(rawEmail));
}

// Test 3: equality by value (value object semantics)
[Fact]
public void Emails_WithSameValue_AreEqual()
{
    var a = Email.Create("user@example.com");
    var b = Email.Create("USER@EXAMPLE.COM");   // case-insensitive
    a.Should().Be(b);
}
C#
// Implementation driven by the tests:
public sealed class Email : IEquatable<Email>
{
    private static readonly Regex EmailRegex =
        new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled);

    public string Value { get; }

    private Email(string value) => Value = value;

    public static Email Create(string raw)
    {
        if (string.IsNullOrWhiteSpace(raw))
            throw new DomainException("Email cannot be empty.");

        var normalised = raw.Trim().ToLowerInvariant();

        if (!EmailRegex.IsMatch(normalised))
            throw new DomainException($"'{raw}' is not a valid email address.");

        return new Email(normalised);
    }

    public bool Equals(Email? other) => other is not null && Value == other.Value;
    public override bool Equals(object? obj) => obj is Email e && Equals(e);
    public override int GetHashCode() => Value.GetHashCode();
}

Common TDD Mistakes

MISTAKE 1: Writing too much code before the test
  Write ONE test, make it pass, refactor. Don't implement the whole class first.

MISTAKE 2: Testing implementation details
  Test WHAT the code does (order.Status == Pending), not HOW (field assignment).
  Tests that know about private fields break on every refactor.

MISTAKE 3: Mocking everything
  Domain logic tests should not need mocks. If you're mocking a List, simplify.
  Mock at the boundary (IRepository, IEventBus) — not inside the domain.

MISTAKE 4: Skipping the Red step
  If you write the implementation before the test, how do you know the test would fail?
  A test that never failed might not test anything.

MISTAKE 5: Tests that pass with wrong implementation
  Assert.NotNull is not enough. Assert the actual value.
  FluentAssertions makes this easier: result.Should().Be(42).

Interview Answer

"TDD is red-green-refactor: write a failing test, write the minimum code to pass it, clean up with tests still green. The key discipline: write ONE test at a time, make it fail first (confirms the test is actually testing something), then write just enough to pass. Benefits: forces API design before implementation, gives a comprehensive regression suite, and keeps code focused because you only write what a test requires. In .NET: xUnit for tests, NSubstitute for mocking dependencies at the boundary (IRepository, IEventBus), and FluentAssertions for readable assertions. Domain logic is the easiest to TDD — pure functions, no mocks needed, fast. Command handlers need mocks for repositories and event buses — verify they were called with the right arguments using Received(). Common mistake: mocking too much, which ties tests to implementation details and makes refactoring painful. The test should care about observable output, not internal wiring."