← Open in the full interactive course (progress tracking, search & more)

Part XI gave you five different ways to be confident code works. OrderFlow's codebase is where you finally have to decide, file by file, which one actually earns its cost.

Lessons 308 through 314 built a complete testing toolkit: unit tests (308) for fast, isolated logic checks; integration tests (309) against real dependencies; mocking (310) to isolate a unit from its collaborators; Testcontainers (311) to make "real dependency" mean an actual ephemeral database instead of an approximation; API testing (312) to verify a whole HTTP contract; performance and load testing (313-314) to verify speed and behavior under concurrency. Every one of those lessons taught the mechanics in isolation. OrderFlow is where they all have to coexist in one real codebase, and where "which kind of test for which piece of code" stops being a rhetorical question and becomes a decision you make dozens of times.

This lesson doesn't re-teach any of those five techniques — it applies all of them to OrderFlow's actual layers: what's worth a fast unit test with a mocked PaymentService, what needs a real Testcontainers-backed Postgres instance to mean anything, and what only an API test hitting a real, JWT-authenticated endpoint can actually verify.

What Is It?

The Simple Explanation

Testing OrderFlow well means matching each of Part XI's five techniques to the specific layer of the Clean Architecture (333-334) it's actually good at verifying — pure business logic gets fast unit tests with mocked collaborators, real EF Core queries get a real Testcontainers-backed database, and the whole authenticated HTTP surface gets an API test — instead of reaching for one favorite technique everywhere, or worse, testing everything through the slowest, most realistic layer "just to be safe."

The Technical Definition

OrderService's pure decision logic — total calculation, discount rules, order-state transitions — is unit tested (308) against mocked (310) IPaymentService, IInventoryService, and repository abstractions from lesson 336, so a test run takes milliseconds and never touches a network. The repository implementations themselves — the actual EF Core LINQ queries lesson 336 wrote against Order/OrderItem/Customer/Product — are integration tested (309) against a real, ephemeral PostgreSQL container spun up by Testcontainers (311), because a mocked DbContext can't tell you whether a LINQ query actually translates to correct SQL. The full checkout flow, including JWT authentication (335) and the outbox write (339), is verified with API tests (312) via WebApplicationFactory against real HTTP requests.

Why Does It Exist?

The Problem — One Technique Applied Everywhere Fails in Both Directions

Mock every dependency, including the database, and you can write a "unit test" that verifies OrderService.PlaceOrderAsync calls _repository.AddAsync exactly once — and learn absolutely nothing about whether the actual EF Core mapping between Order and OrderItem is even correct. Go the other direction and spin up a real Testcontainers Postgres instance for every single test, including ones that only check a discount-percentage calculation, and a test suite that should run in seconds starts taking minutes — slow enough that developers stop running it locally, which defeats the entire point of having fast tests in the first place.

The Solution — Match the Tool to What's Actually Being Verified

The fix is the same principle Clean Architecture (333-334) already taught you to apply to production code, turned toward tests: OrderFlow's core business logic doesn't know EF Core exists, so its tests shouldn't need EF Core to exist either — a mocked repository interface is not just faster, it's the more honest test of logic that was deliberately written to not care where its data comes from. The EF Core queries themselves, by contrast, are exactly the layer where "does this actually work against a real database" is the only question worth asking, which is precisely what Testcontainers (311) exists to answer cheaply and reproducibly.

Big Picture — Matching Part XI's Toolkit to OrderFlow's Layers

OrderFlow layerWhat lives thereRight tool
Domain / application logic (334)OrderService's total calculation, discount rules, state transitionsUnit tests (308) with mocked IPaymentService/IInventoryService/repositories (310)
Infrastructure — EF Core repositories (336)Actual LINQ queries against Order/OrderItem/Customer/ProductIntegration tests (309) against a real Testcontainers Postgres (311)
Infrastructure — caching (337)IDistributedCache reads/writes around the product catalogIntegration test against a real Testcontainers Redis, or a unit test of the cache-aside logic with a mocked cache
API surface (335)JWT-authenticated endpoints, request/response contractsAPI tests (312) via WebApplicationFactory, asserting on real HTTP responses
Messaging (339)Outbox row written in the same transaction as the order; publisher behaviorIntegration test verifying the outbox row exists after a real DB write; a separate, focused test for publisher retry/backoff logic
Whole checkout path under concurrencyRealistic multi-user checkout traffic, exactly the class of bug lesson 321's incident traced back to an untested data shapePerformance testing (313) and load testing (314)

How It Works — The Same Feature, Verified at Three Levels

"APPLY A 10% DISCOUNT FOR ORDERS OVER $100" — THREE TESTS, THREE JOBS
1. UNIT TEST — DOES THE DISCOUNT MATH ITSELF WORK?
2. INTEGRATION TEST — DOES THE DISCOUNTED ORDER ACTUALLY PERSIST CORRECTLY?
3. API TEST — DOES A REAL, AUTHENTICATED CLIENT SEE THE DISCOUNT?

None of these three tests is redundant with the others, even though all three exercise "the discount." Each one fails for a different reason and tells you something different when it does: the unit test fails only if the math itself is wrong; the integration test fails only if persistence/mapping is wrong even though the math was right; the API test fails only if something between the HTTP layer and the database — routing, serialization, authorization — is wrong even though both of the layers underneath it are correct.

Simple Example — Mocking PaymentService in an OrderService Unit Test

public class OrderServiceTests { [Fact] public async Task PlaceOrderAsync_MarksOrderFailed_WhenPaymentDeclines() { // Arrange — mock every collaborator OrderService depends on (310) var mockPayments = new Mock<IPaymentService>(); mockPayments .Setup(p => p.ChargeAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>())) .ReturnsAsync(new ChargeResult(Succeeded: false, IsRetryable: false, DeclineReason: "InsufficientFunds")); var mockRepository = new Mock<IOrderRepository>(); var sut = new OrderService(mockRepository.Object, mockPayments.Object); var order = new Order { CustomerId = 42, Items = { new OrderItem { ProductId = 7, Quantity = 1, UnitPrice = 150m } } }; // Act await sut.PlaceOrderAsync(order, CancellationToken.None); // Assert — no real database, no real payment gateway, milliseconds to run Assert.Equal(OrderStatus.PaymentFailed, order.Status); mockRepository.Verify(r => r.UpdateAsync(order, It.IsAny<CancellationToken>()), Times.Once); } }

Meaning: Nothing about this test involves a network call, a real payment gateway, or a real database — it's purely asking "given a decline, does OrderService's own logic correctly transition the order's state?" That question deserves a millisecond-fast answer, which is exactly what mocking IPaymentService and IOrderRepository delivers.

Real-World Example — A Testcontainers Postgres Catching What Mocking Never Could

public class OrderRepositoryTests : IAsyncLifetime { private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build(); public Task InitializeAsync() => _postgres.StartAsync(); public Task DisposeAsync() => _postgres.DisposeAsync().AsTask(); [Fact] public async Task GetByIdAsync_LoadsOrderWithItems_NoN1Query() { await using var context = CreateContext(_postgres.GetConnectionString()); await context.Database.MigrateAsync(); context.Orders.Add(new Order { CustomerId = 42, Items = { new OrderItem { ProductId = 7, Quantity = 2, UnitPrice = 19.99m } } }); await context.SaveChangesAsync(); var repository = new OrderRepository(context); var order = await repository.GetByIdAsync(1, CancellationToken.None); // Only a real database round-trip can confirm the .Include() this lesson's // sibling (344) checks for is actually present and actually eager-loads — // a mocked repository would return whatever the test told it to, correct or not. Assert.Single(order!.Items); } }

This is precisely the class of bug lesson 321's incident traced back to: a dropped .Include() that a mocked repository test could never catch, because a mock returns exactly what it was told to return — it has no opinion on whether the real LINQ query underneath actually works. Only a real database, even an ephemeral, disposable one spun up by Testcontainers for the duration of this one test, can answer "does this query actually do what it claims."

Analogy

A Table Read, a Dress Rehearsal, and Opening Night

A play's cast doesn't test the whole production only once, on opening night, in front of a live audience. A table read verifies the script itself — do the lines make sense, does the story hold together — with no set, no costumes, no lighting, fast and cheap to redo after every rewrite. A dress rehearsal verifies the physical production — do the costume changes work in time, does the set rotate correctly — with everything real except the audience. Opening night is the only time the whole thing, script and production and audience reaction together, is verified at once. Skipping the table read and going straight to dress rehearsals means catching a plot hole after the costumes are already built. Skipping the dress rehearsal and going straight to opening night means finding out a costume change takes ninety seconds too long in front of a paying audience. OrderFlow's unit tests are the table read, its Testcontainers integration tests are the dress rehearsal, and its API tests are opening night — each one exists because the other two genuinely can't catch what it catches.

Under the Hood — Why the Test Pyramid's Shape Matters for OrderFlow's CI Time

Testcontainers (311) is genuinely real — it starts an actual PostgreSQL server, in an actual container, for the duration of the test run. That reality is exactly what makes it trustworthy, and exactly why it's slower than a mock: a container has to actually start, actually accept connections, and actually run migrations before a single assertion can run. Multiply a few hundred milliseconds of container startup across hundreds of tests, and a suite that's mostly integration tests can take many minutes where a suite that's mostly unit tests, with a focused layer of integration tests around only the infrastructure boundary, takes seconds. This is the concrete, measurable reason OrderFlow's test suite deliberately has far more unit tests than integration tests, and far more integration tests than full API tests — not a stylistic preference, but a direct consequence of what each layer actually costs to run, repeated potentially thousands of times a day across a whole team's CI pipeline.

Common Confusion

1. "A mocked repository test and a Testcontainers test verify the same thing, just at different speeds" — no, they verify genuinely different things

It's tempting to think of Testcontainers tests as "the slow version of the same test." They're not — a mocked-repository unit test can never fail because of a wrong LINQ translation, a missing index, or a dropped .Include(), because the mock was never asking the database anything real in the first place. The Real-World Example's N+1 bug is invisible to every mocked test, no matter how many of them exist, and only visible to a test that hits a real database.

2. "More integration tests means more confidence, so lean toward them by default" — confidence per test isn't the only cost that matters

An individual integration test genuinely does give more end-to-end confidence than an individual unit test. But confidence has to be weighed against the cost of running the suite at all — a test suite too slow to run before every commit stops being run before every commit, which quietly erodes the exact confidence the extra realism was supposed to buy.

Common Mistakes

Mistake 1 — Unit testing OrderService with a mocked DbContext instead of a mocked repository

Mocking EF Core's DbSet<Order> directly, chasing a fragile in-memory approximation of LINQ query behavior that doesn't actually match how the real provider translates queries.

Mock the repository interface (336) that OrderService actually depends on — the whole point of the Repository Pattern is that business logic never needs to know EF Core exists, and neither should its unit tests.

Mistake 2 — Testing the discount calculation only through a full API test

Writing the only test for the 10%-over-$100 discount rule as a WebApplicationFactory-hosted HTTP call — correct, but slow, and when it fails, it doesn't say whether the bug is in the math, the persistence, or the serialization.

Test the math itself at the unit level, where a failure points precisely at the one thing that could have broken it — reserve the API test for verifying the whole stack agrees, not for re-deriving business logic correctness from scratch.

Mistake 3 — Skipping performance/load testing because "the unit and integration tests all pass"

Treating a green test suite as sufficient sign-off for a checkout endpoint, without ever running it under realistic concurrent load or against production-shaped data volume.

Remember lesson 321's own diagnosis directly: the N+1 regression there passed unit and integration tests fine, and only performance testing (313) or load testing (314), run against realistic data and concurrency, would have caught it before it shipped.

When Should I Use It?

Rule of thumb: Ask "what, specifically, would this test fail to catch?" before writing it. If the honest answer is "nothing a cheaper test wouldn't also catch," you've picked the wrong layer.

Mental Model

Unit test + mocks = is the logic itself correct? Fast, no I/O, tests OrderService in isolation.
Integration test + Testcontainers = does the real query/cache/broker actually work? Slower, but the only thing that can catch a real N+1 or a wrong mapping.
API test = does the whole authenticated stack agree, end to end? Slowest, reserved for the critical paths.
Performance/load test = does it still work at realistic data volume and concurrency? The layer lesson 321's incident showed was missing.

Remember: match the tool to what you're actually trying to catch — not to habit, and not to "more realistic always wins."

Key Takeaway


Check Your Understanding

You've matched Part XI's testing toolkit to OrderFlow's actual layers. Let's confirm it clicked.

1. Why does this lesson recommend mocking IOrderRepository rather than EF Core's DbSet<Order> directly, when unit testing OrderService's discount logic?

Show answer

Correct: B

Why B is correct: This is exactly Mistake 1's reasoning — the whole point of the Repository Pattern (336) is that OrderService depends on an abstraction, not EF Core directly, so its unit test should mock that same abstraction rather than fighting to approximate EF Core's real query translation.

Why A is incorrect: DbSet<T> can technically be referenced, which is exactly why the mistake is possible and worth calling out — the issue is architectural fit, not a hard technical barrier.

Why C is incorrect: Speed isn't the reasoning given — the concern is architectural correctness and avoiding an unreliable approximation of real query behavior.

Why D is incorrect: Mocking frameworks are used throughout this lesson's unit tests (via Mock<IPaymentService>) — there's no such restriction.

Reinforcement: Mock at the same abstraction boundary the production code actually depends on.

2. According to the Real-World Example, why could only a Testcontainers-backed integration test catch the missing .Include() bug from lesson 321's incident, and not a mocked-repository unit test?

Show answer

Correct: B

Why B is correct: This is the precise distinction drawn in Common Confusion #1 and the Real-World Example — a mock is only ever as correct as what it was configured to return, so it structurally cannot detect a wrong or incomplete real query.

Why A is incorrect: Moq and similar frameworks fully support async methods, as the lesson's own ChargeAsync mock setup demonstrates.

Why C is incorrect: Testcontainers specifically runs real dependency containers (databases, brokers) for tests — it's not a general C# execution requirement.

Why D is incorrect: Unit tests check object properties constantly — that's not the limiting factor here at all.

Reinforcement: A mock verifies your code's interaction with an interface; it can never verify that a real implementation behind that interface is actually correct.

3. Why does OrderFlow's test suite deliberately have far more unit tests than Testcontainers-backed integration tests?

Show answer

Correct: B

Why B is correct: This is exactly the Under the Hood reasoning — container startup and real I/O add real, measurable time per test, and a slow suite is a suite people stop running frequently, which is a genuine, practical cost that shapes the test pyramid's shape.

Why A is incorrect: The lesson explicitly frames integration tests as MORE trustworthy for what they cover (real query behavior) — the issue is cost, not trust.

Why C is incorrect: There's no such fixed limit — this isn't a technical constraint the lesson describes.

Why D is incorrect: Common Confusion #1 explicitly rejects this — the two test types verify genuinely different things, not the same thing at different speeds.

Reinforcement: The test pyramid's shape is a direct, practical consequence of what each layer costs to run — not a stylistic preference.

4. A team has 100% passing unit and integration tests for OrderFlow's checkout endpoint. According to this lesson, is that sufficient confidence to ship?

Show answer

Correct: B

Why B is correct: This is Mistake 3's explicit callback to lesson 321 — the N+1 regression there passed unit and integration tests without issue, and only a performance or load test run against realistic data/concurrency would have caught the actual production-shaped problem.

Why A is incorrect: The lesson explicitly warns against treating a green suite at these two layers as sufficient — that's precisely the mistake it names.

Why C is incorrect: The lesson frames all the layers as complementary, each catching something the others can't — it never suggests any one layer alone is sufficient.

Why D is incorrect: The whole point of performance/load testing (313-314) is to catch this class of bug BEFORE a production incident, not to accept that only an incident can reveal it.

Reinforcement: Every layer of testing catches a specific class of bug — a full green suite across some layers still leaves the classes only the missing layers can catch.

Next up: lesson 343 takes this same tested, monitored, logged OrderFlow codebase and puts it in a container — applying Part X's Docker and health-probe lessons to OrderFlow's actual Dockerfile and Kubernetes configuration.


dotnetmadeeasy.com — Learn C# and .NET, the right way.