.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 netStep 1: Red — Write a Failing Test
// 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
// 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
// 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
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
// 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
// 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
// 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);
}// 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."