Outside-In TDD: The Double Loop, From Acceptance Test to Domain, and London vs Classical in Practice
Outside-in TDD builds an outer loop with an acceptance test that stays red until the feature is done, and an inner loop of unit tests that grow the pieces to make it pass. We start POST /api/cart/{id}/coupon as a walking skeleton with WebApplicationFactory, discover collaborators with mocks, test the same handler the classical way and compare, with real output, which tests break under a behavior-preserving refactoring.
Version note: The code in this post was built and run with .NET 10, ASP.NET Core 10, xUnit.net v3 4.0.3, Microsoft.AspNetCore.Mvc.Testing 10.0.15, NSubstitute 6.2.0 and Microsoft.Extensions.TimeProvider.Testing 10.11.0.
Predict First
We added an "apply a coupon to the cart" endpoint to Bookstore and wrote two test classes for the ApplyCouponHandler behind it. The first replaces all of the handler's collaborators with NSubstitute and verifies that SaveAsync was called. The second works with real in-memory repositories and the real CouponValidator and looks at the state of the cart in the end. Each class has two tests, and all four are green.
Then a change arrives for performance and consistency: so that two requests can't apply a coupon to the same cart at the same moment, the repository gets a FindForUpdateAsync that reads the row with a lock, and the handler calls it instead of FindAsync. The endpoint's behavior from the outside is exactly the same. How many of the four tests turn red? Which ones? We look at the real output near the end of the post.
The Double Loop: Two Loops, One Inside the Other

In the previous posts we practiced TDD on a single class, with code that had no dependencies. A real feature, though, starts from an HTTP request, passes through several layers and touches a data source. In that situation outside-in TDD builds two loops. In the outer loop you write an acceptance test that describes the feature from the user's point of view. This test stays red until the feature is done; that can take hours, sometimes days. In the inner loop the familiar red-green-refactor turns: every piece needed for the outer test to pass is grown with unit test loops that take minutes.
The book that popularized this approach is Steve Freeman and Nat Pryce's Growing Object-Oriented Software, Guided by Tests (GOOS, 2009). The outer test has two jobs. The first is to give direction: you decide what to write in the inner loop by looking at why the outer test doesn't pass yet. The second is to define done: when the outer test turns green the feature is finished, and anything beyond is speculation.
The starting point GOOS recommends is the walking skeleton. Named by Alistair Cockburn, the idea is to build the thinnest slice of real functionality that can be built automatically and tested end to end. Its value is that it surfaces integration problems before feature work: routing, serialization, dependency injection configuration and the test infrastructure work from day one.
The Outer Loop: An Acceptance Test That Stays Red
Our first test calls the endpoint from the outside. We use WebApplicationFactory to start ASP.NET Core in process; the details of setting it up are in the integration test post of the ASP.NET Core Web API series. In .NET 10 a source generator makes the Program class public automatically, so the public partial class Program line people used to add is no longer needed.
public sealed class ApplyCouponAcceptanceTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Applying_a_valid_coupon_returns_the_discounted_cart()
{
CancellationToken ct = TestContext.Current.CancellationToken;
HttpClient client = factory.CreateClient();
HttpResponseMessage response = await client.PostAsJsonAsync("/api/cart/42/coupon", new { Code = "BOOK10" }, ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
CartSummary? cart = await response.Content.ReadFromJsonAsync<CartSummary>(ct);
Assert.Equal(new CartSummary(42, 200m, 20m, 180m, "BOOK10"), cart);
}
}
After adding the CartSummary record and running with an empty Program.cs, the walking skeleton's first red arrives:
failed Bookstore.Api.Tests.ApplyCouponAcceptanceTests.Applying_a_valid_coupon_returns_the_discounted_cart (160ms)
Assert.Equal() Failure: Values differ
Expected: OK
Actual: NotFound
This red tells us the infrastructure works: the application started, the request went out, a response came back. Only the feature itself is missing. Notice that the test's arrange section is empty: we don't know yet where cart 42 and the BOOK10 coupon will come from. The inner loop will tell us.
The Inner Loop, the London Way: Discovering Collaborators with Mocks
In the mock-based (London, or mockist) style of outside-in, the next step is to design the object right behind the endpoint. We know what the handler should do, but not who will give it the data. We answer these questions while writing the test, and each answer becomes an interface:
public sealed class ApplyCouponHandlerLondonTests
{
private readonly ICartRepository carts = Substitute.For<ICartRepository>();
private readonly ICouponRepository coupons = Substitute.For<ICouponRepository>();
private readonly ICouponPolicy policy = Substitute.For<ICouponPolicy>();
[Fact]
public async Task Applies_an_accepted_coupon_and_saves_the_cart()
{
CancellationToken ct = TestContext.Current.CancellationToken;
Cart cart = new(42, 200m);
Coupon coupon = Coupon.Percentage("BOOK10", 10m);
carts.FindAsync(42, ct).Returns(cart);
coupons.FindByCodeAsync("BOOK10", ct).Returns(coupon);
policy.Validate(coupon, 200m).Returns(CouponResult.Accepted(20m));
ApplyCouponHandler handler = new(carts, coupons, policy);
ApplyCouponOutcome outcome = await handler.HandleAsync(42, "BOOK10", ct);
Assert.Equal(new ApplyCouponOutcome.Applied(new CartSummary(42, 200m, 20m, 180m, "BOOK10")), outcome);
await carts.Received(1).SaveAsync(cart, ct);
}
// Second test: SaveAsync is never called for a rejected coupon (DidNotReceive)
}
The compiler reports three missing interfaces: ICartRepository, ICouponRepository, ICouponPolicy. These are what GOOS calls "role interfaces": small interfaces that define the role the handler needs, from the handler's point of view. The repositories are natural roles; the data will come from a database and we want an abstraction at that boundary anyway. ICouponPolicy is different: it is a role placed in front of the CouponValidator we grew test-first in the previous posts. Let's keep that in mind.
After writing the interfaces and an empty handler that returns NotFound, both London tests are red for the right reason: Expected: Applied { ... }, Actual: NotFound { }. The handler's implementation:
public sealed class ApplyCouponHandler(ICartRepository carts, ICouponRepository coupons, ICouponPolicy policy)
{
public async Task<ApplyCouponOutcome> HandleAsync(int cartId, string code, CancellationToken ct)
{
Cart? cart = await carts.FindAsync(cartId, ct);
Coupon? coupon = await coupons.FindByCodeAsync(code, ct);
if (cart is null || coupon is null)
{
return new ApplyCouponOutcome.NotFound();
}
CouponResult result = policy.Validate(coupon, cart.Subtotal);
if (result.Rejection is { } reason)
{
return new ApplyCouponOutcome.Rejected(reason);
}
cart.ApplyCoupon(coupon.Code, result.Discount);
await carts.SaveAsync(cart, ct);
return new ApplyCouponOutcome.Applied(cart.ToSummary());
}
}
Both London tests green, the acceptance test still red. What the outer loop tells us: the real implementations of the roles and the endpoint are missing. We close them one by one. For ICouponPolicy we write a one-line adapter that delegates to CouponValidator. For the repositories, in-memory implementations for now; the version that works with a real database, and Testcontainers, belong to the upcoming Integration and E2E (end-to-end) Testing series. Finally the endpoint:
app.MapPost("/api/cart/{id:int}/coupon", async (int id, ApplyCouponRequest request, ApplyCouponHandler handler, CancellationToken ct) =>
await handler.HandleAsync(id, request.Code, ct) switch
{
ApplyCouponOutcome.Applied applied => Results.Ok(applied.Cart),
ApplyCouponOutcome.Rejected rejected => Results.Problem(
title: "Coupon rejected", detail: rejected.Reason.ToString(), statusCode: StatusCodes.Status422UnprocessableEntity),
_ => Results.NotFound()
});
Now we can write the acceptance test's arrange section, because the inner loop decided where the data goes:
CancellationToken ct = TestContext.Current.CancellationToken;
await factory.Services.GetRequiredService<ICartRepository>().SaveAsync(new Cart(42, 200m), ct);
factory.Services.GetRequiredService<InMemoryCouponRepository>().Add(Coupon.Percentage("BOOK10", 10m));
HttpClient client = factory.CreateClient();
Test run summary: Passed!
total: 3
failed: 0
succeeded: 3
duration: 562ms
The outer loop is closed. The next acceptance scenario (a 422 for an expired coupon) starts a new outer loop.
The Same Feature, Inside-Out: The Classical Way
In the classical (Detroit) school the same feature usually grows from the inside out. First the domain: CouponValidator was already written test-first in the previous two posts. Then the handler, with its real collaborators. This school uses test doubles only at boundaries that are really slow or uncontrollable; here that boundary is the database, and in its place we have in-memory fakes we wrote ourselves. The endpoint and the acceptance test come last.
public sealed class ApplyCouponHandlerClassicalTests
{
private readonly InMemoryCartRepository carts = new();
private readonly InMemoryCouponRepository coupons = new();
private ApplyCouponHandler CreateHandler()
{
CouponValidator validator = new(new FakeTimeProvider(new DateTimeOffset(2026, 11, 14, 9, 0, 0, TimeSpan.Zero)));
return new ApplyCouponHandler(carts, coupons, new CouponValidatorPolicy(validator));
}
[Fact]
public async Task Rejected_coupon_leaves_the_cart_unchanged()
{
CancellationToken ct = TestContext.Current.CancellationToken;
await carts.SaveAsync(new Cart(42, 100m), ct);
coupons.Add(Coupon.Percentage("BOOK10", 10m) with { MinimumSubtotal = 150m });
ApplyCouponOutcome outcome = await CreateHandler().HandleAsync(42, "BOOK10", ct);
Assert.Equal(new ApplyCouponOutcome.Rejected(CouponRejection.BelowMinimumSubtotal), outcome);
Cart? saved = await carts.FindAsync(42, ct);
Assert.Null(saved?.CouponCode);
}
// First test: an accepted coupon shows up in the saved cart
}
The difference between the two tests is in what they verify. The London test locks down how the handler talks to its collaborators: which method, with which arguments, how many times. The classical test verifies the outcome and observable state: if the coupon was rejected, the saved cart has no coupon. The London test doesn't even know the coupon was rejected because of the minimum subtotal; it makes the stub say so with Returns. The classical test runs the real CouponValidator.
Measuring Brittleness: A Refactoring That Doesn't Change Behavior
Now we make the change from "Predict First". ICartRepository gets FindForUpdateAsync; the in-memory implementation does the same as FindAsync, the real repository will read the row with a lock. One line changes in the handler:
Cart? cart = await carts.FindForUpdateAsync(cartId, ct);
failed Bookstore.Api.Tests.ApplyCouponHandlerLondonTests.Does_not_save_the_cart_when_the_coupon_is_rejected (31ms)
Expected: Rejected { Reason = BelowMinimumSubtotal }
Actual: NotFound { }
failed Bookstore.Api.Tests.ApplyCouponHandlerLondonTests.Applies_an_accepted_coupon_and_saves_the_cart (1ms)
Expected: Applied { Cart = CartSummary { Id = 42, Subtotal = 200, Discount = 20, Total = 180, CouponCode = BOOK10 } }
Actual: NotFound { }
Test run summary: Failed!
total: 5
failed: 2
succeeded: 3
Both London tests red, both classical tests and the acceptance test green. Why? The London tests had stubbed FindAsync. The handler now calls FindForUpdateAsync; NSubstitute returns the default value, null, for a call that wasn't set up, and the handler thinks it couldn't find the cart. In the classical tests the in-memory repository implemented the new method once, and none of the tests had to be touched.
These reds are false alarms: the code works correctly and the endpoint answers correctly. In the first post of the Unit Test series we saw the same phenomenon under the name "resistance to refactoring". What matters here is that outside-in TDD doesn't make this brittleness inevitable: where you use mocks is what decides it.
A Mock Is a Design Tool, Not a Verification Tool
In GOOS the real job of mocks is to design the conversation between objects. While writing the handler test we discovered three roles, and that discovery was valuable: how the handler reaches data went behind an interface from day one, and the database decision could be postponed. Here the mock did its job as a design tool.
The problem is that mocks keep living in the test suite as permanent verifiers after the discovery is over. The balance that works in practice: use a mock to discover a boundary; once the boundary is clear, replace what is behind it with a real or close-to-real fake. An in-memory fake for the repositories, the real CouponValidator for ICouponPolicy. Mocking a deterministic domain service that runs in process and that we wrote ourselves is exactly what the over-mocking post in the Unit Test series warns about. The "don't mock what you don't own" rule from the same post finds its place in this flow too: the handler sees not Entity Framework Core's DbContext but the ICartRepository we defined. What gets mocked is always a role we defined; compatibility with a third-party API is verified in the integration tests of that role's real implementation.
Seen this way, ICouponPolicy and its adapter are an extra layer left behind by mock-driven design. On the inside-out path the handler would take CouponValidator directly. Which is right depends on whether the policy will really change later; if multiple coupon sources or A/B-tested rules are coming, the role interface lives on; if not, it is removed in a refactor step.
In Production
The level of the acceptance test. The speed of the outer loop sets the TDD rhythm. The acceptance test in this post ran in process, and all five tests finished in half a second. Had we written the same test through the browser with UI automation, every run would take seconds, perhaps minutes, and the outer loop would become a day of waiting. Write the outer test at the lowest level that is still meaningful from the user's point of view: for most backend features that is the HTTP level.
The number of acceptance tests. Writing an acceptance test for every behavior slows the suite down and tests the same thing twice. Outer tests carry the main paths of a feature, inner tests the edge cases. Details like coupon rounding and Istanbul midnight stay at the unit level; acceptance tests cover the "accepted", "rejected" and "not found" paths.
Shared state. IClassFixture shares the same application instance across the tests in a class. If different tests write the same cart id into the in-memory repositories, the tests affect each other. A separate id or a separate factory per test prevents surprises when tests run in parallel.
Common Pitfalls
Writing the acceptance test through the UI and building a slow loop. If every run of the outer test takes minutes, the developer stops running it and the double loop collapses into a single loop. UI tests are needed, but not as the outer loop of outside-in.
Mocking every collaborator and testing the implementation. Verifying every call inside the handler with Received turns the test into a copy of the code. If an assertion that verifies behavior (the returned result, the saved state) is enough, don't add interaction verification. Verify interactions only when the interaction is the behavior itself: was the email sent, was the payment system called.
Never seeing the outer test red. Writing the endpoint first and adding the acceptance test afterwards leaves it unclear what the test really protects. The walking skeleton's 404 red is also proof that the infrastructure is set up correctly.
Forgetting the outer test during the inner loop. It's easy to say "done" while the unit tests are green. The feature is done when the outer test turns green; if the inner loop grows something the outer test doesn't ask for, speculative code is being written.
A Small Experiment
★☆☆ Adapt the London tests to the FindForUpdateAsync change. How many lines did you change? What if the same change hit ten handler tests?
★★☆ Write a new acceptance test for an expired coupon: 422 with Expired in the problem details. Wire time to a FakeTimeProvider with WithWebHostBuilder and ConfigureTestServices. Did the inner loop need a new unit test, or did the behavior already exist? If it went green right away, apply the breaking check from an earlier post.
★★★ Develop the same endpoint inside-out this time: let the handler take CouponValidator directly, with no ICouponPolicy at all. At the end of the two paths, how many interfaces, tests and lines of code did you have? Which path is better prepared for a future where the policy changes?
In the next post we take the same rhythm to the frontend: with Angular 22 and Vitest we develop first plain TypeScript logic and then a signal-based component test-first.