Test-Driven Development in C# · Lesson 3 of 6
Outside-In TDD — Starting from the API
Two Schools of TDD
Classic TDD (Chicago/Inside-Out):
→ Start with the smallest unit: domain objects, value objects
→ Build up: domain → application → infrastructure → API
→ Real collaborators where possible — no mocks
→ Tests are granular; integration emerges from combining units
Outside-In TDD (London):
→ Start with the behaviour the user sees: an API endpoint, a use case
→ Write a high-level acceptance test that fails
→ Mock collaborators to isolate the unit under test
→ Drive design "inside" — handlers, repositories, domain objects emerge
from what the acceptance test requires
Neither is universally better.
Outside-in is useful when you're building a new feature from the API contract down.
It keeps you focused on user-visible behaviour rather than implementation details.Step 1 — Write the Acceptance Test (Outermost Layer)
// Acceptance test: "A clinician can approve a Warfarin prescription via the API"
// This is a WebApplicationFactory test — real HTTP stack, mocked infrastructure
public sealed class ApprovePrescriptionApiTests
: IClassFixture<ClinicalApiFactory>
{
private readonly HttpClient _client;
public ApprovePrescriptionApiTests(ClinicalApiFactory factory) =>
_client = factory.CreateClient();
[Fact]
public async Task POST_ApprovePrescription_Returns200_AndPrescriptionIsApproved()
{
// Arrange — prescription already exists in the system
var prescriptionId = Guid.NewGuid();
await Seed.CreateDraftPrescription(_client, prescriptionId, "Warfarin", 5m, "mg");
var request = new
{
InrValue = 2.5,
CheckedAt = DateTime.UtcNow.AddHours(-1)
};
// Act
var response = await _client.PostAsJsonAsync(
$"/api/prescriptions/{prescriptionId}/approve", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var getResponse = await _client.GetAsync($"/api/prescriptions/{prescriptionId}");
var dto = await getResponse.Content.ReadFromJsonAsync<PrescriptionDto>();
dto!.Status.Should().Be("Approved");
}
}Run: RED — endpoint doesn't exist yet.
Now work downwards: create the endpoint, handler, domain logic to make this pass.Step 2 — Write the Handler Test (Application Layer)
// Drive the handler design from the acceptance test requirement
// Mock the repository (infrastructure boundary)
public sealed class ApprovePrescriptionHandlerTests
{
private readonly IPrescriptionRepository _repository =
Substitute.For<IPrescriptionRepository>();
[Fact]
public async Task Handle_ValidCommand_ApprovesAndSavesPrescription()
{
// Arrange
var prescriptionId = PrescriptionId.Of(Guid.NewGuid());
var draft = Prescription.CreateDraft(
PatientId.Of(Guid.NewGuid()),
MedicationName.Of("Warfarin"),
DosageValue.Of(5m, "mg"));
_repository.GetByIdAsync(prescriptionId, Arg.Any<CancellationToken>())
.Returns(draft);
var handler = new ApprovePrescriptionHandler(_repository);
var command = new ApprovePrescriptionCommand(
prescriptionId.Value, 2.5m, DateTime.UtcNow.AddHours(-1), Guid.NewGuid());
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
await _repository.Received(1).SaveAsync(
Arg.Is<Prescription>(p => p.Status == PrescriptionStatus.Approved),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PrescriptionNotFound_ReturnsFailure()
{
_repository.GetByIdAsync(Arg.Any<PrescriptionId>(), Arg.Any<CancellationToken>())
.Returns((Prescription?)null);
var handler = new ApprovePrescriptionHandler(_repository);
var command = new ApprovePrescriptionCommand(
Guid.NewGuid(), 2.5m, DateTime.UtcNow, Guid.NewGuid());
var result = await handler.Handle(command, CancellationToken.None);
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Prescription.NotFound");
}
}Step 3 — Write the Domain Test (Innermost Layer)
// Now the domain rule is driven by what the handler needs
public sealed class PrescriptionApprovalDomainTests
{
[Theory]
[InlineData(2.0, true)] // INR in therapeutic range — valid
[InlineData(3.0, true)]
[InlineData(1.9, false)] // Below range — clinical concern
[InlineData(3.1, false)] // Above range — clinical concern
public void Approve_InrRangeValidation(decimal inrValue, bool expectedSuccess)
{
var prescription = Prescription.CreateDraft(
PatientId.Of(Guid.NewGuid()),
MedicationName.Of("Warfarin"),
DosageValue.Of(5m, "mg"));
var result = prescription.Approve(inrValue, DateTime.UtcNow, Guid.NewGuid());
result.IsSuccess.Should().Be(expectedSuccess);
}
}The Outside-In Design Loop
Outermost test (API) fails
→ Create endpoint
→ Endpoint test passes? No — handler not wired
→ Create handler interface (from what the endpoint needs)
→ Handler test fails
→ Create handler (from what the test describes)
→ Handler needs a repository — write repository interface
→ Repository is mocked in handler test
→ Domain test fails
→ Create domain method (from what the handler calls)
→ Domain test passes
→ Handler test passes (with mock repository)
→ Acceptance test passes (with real infrastructure wired up)
At each layer: the test drives the interface of the next layer down.
You never implement something that isn't required by a test above it.Collaborator Mocking with NSubstitute
// NSubstitute syntax for outside-in TDD
// Arrange: set up return values
_repository.GetByIdAsync(prescriptionId, Arg.Any<CancellationToken>())
.Returns(draft);
// Verify: confirm the handler called Save with the right state
await _repository.Received(1).SaveAsync(
Arg.Is<Prescription>(p =>
p.Status == PrescriptionStatus.Approved &&
p.InrValue == 2.5m),
Arg.Any<CancellationToken>());
// Verify NOT called (e.g., on failure path)
await _repository.DidNotReceive().SaveAsync(
Arg.Any<Prescription>(),
Arg.Any<CancellationToken>());
// Throw from a mock to test error handling
_repository.GetByIdAsync(Arg.Any<PrescriptionId>(), Arg.Any<CancellationToken>())
.Throws(new SqlException("Connection timeout"));Production issue I've seen: A team wrote tests bottom-up (inside-out), building perfect unit tests for every domain class in isolation. When they wired everything together, the API endpoint returned 422 for valid requests. The handler was calling
repository.GetByIdAsync(id)but the repository interface had been defined asGetByIdAsync(PrescriptionId id)— a value object, not a plain Guid. The endpoint was passing a raw Guid. No test had covered the full stack. An outside-in acceptance test written first would have driven the handler to accept a Guid command (since the HTTP request carries a Guid) and convert it internally — the mismatch would have been caught in minutes, not after 3 days of debugging.
Key Takeaway
Outside-in TDD starts with the failing behaviour the user sees (API test), then drives the design inward: handler, domain, repository interface. Each layer's test mocks the layer below it, letting you define interfaces before implementing them. This keeps you focused on user-visible behaviour and ensures every collaborator interface is driven by real need, not speculation. Finish with real integration tests (no mocks) to validate the full stack once the design is settled.