Learnixo
Back to blog
Backend Systemsintermediate

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

Practise Test-Driven Development in .NET: the red-green-refactor cycle, writing tests first with xUnit, using NSubstitute for mocks, testing domain logic, command handlers, and common TDD mistakes.

Asma Hafeez KhanMay 24, 20266 min read
.NETC#TDDtestingxUnitNSubstituteunit testing
Share:𝕏

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."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.