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

A unit test can prove your discount logic is flawless in isolation, and still tell you nothing about whether the endpoint that calls it is even wired up correctly.

Lesson 308 gave you a suite of fast, isolated unit tests for a DiscountCalculator. Every one of them passes. Ship it — except the endpoint that's supposed to call it was registered with the wrong route, or the JSON serializer silently renamed the response field, or a middleware component swallowed the exception before it ever reached the calculator at all. None of that is a bug a unit test could ever catch, by design — a unit test never runs the real pipeline those problems live in.

That gap — between "each piece works in isolation" and "the pieces actually work together" — is precisely what integration testing exists to close. It's the next layer up the testing pyramid from lesson 308, and it's where a surprising number of real-world bugs actually get caught.

In this lesson, you'll learn what integration tests verify that unit tests structurally can't, how ASP.NET Core's WebApplicationFactory<TEntryPoint> spins up your entire app in-process for testing, and how to keep integration tests reliable by giving each one a clean, isolated slice of state.

What Is It?

The Simple Explanation

An integration test checks that several real pieces of your system genuinely work correctly together — a real HTTP request traveling through your real middleware pipeline into a real controller, or a repository talking to a real database — instead of a single piece tested in artificial isolation.

The Technical Definition

An integration test exercises the collaboration between two or more real components — real dependency injection wiring, real middleware, a real (if often disposable or in-memory) data store — deliberately trading away some of a unit test's speed and isolation in exchange for confidence that the actual, assembled system behaves correctly, not just its individual pieces in a vacuum.

Unit test (lesson 308)

Integration test (this lesson)

Why Does It Exist?

The Problem — a Whole Category of Bugs Live Only in the Wiring

Unit tests deliberately exclude real dependencies — that's exactly what makes them fast and reliable, and exactly what makes them structurally blind to an entire category of real bugs: a service registered with the wrong lifetime in DI, a route attribute with a typo, an authorization policy applied to the wrong endpoint, a middleware component registered in the wrong order so it never gets a chance to run, a JSON property that serializes under a different name than the client expects. Every one of these is invisible to a unit test that never touches the real pipeline — and every one of these is exactly the kind of bug that reaches production silently.

The Solution — Test the Real, Assembled System, in a Controlled Way

Integration tests deliberately run more of the real system at once — the actual startup configuration, the actual middleware pipeline, the actual routing table — while still keeping things fast and repeatable enough to run routinely. ASP.NET Core's own WebApplicationFactory<TEntryPoint> is the standard tool for exactly this: it boots your real application in-process, in memory, without needing a real network port or a deployed server, and hands your test an HttpClient that talks to it as if it were fully deployed.

Big Picture — One Layer Up the Pyramid

Picking back up the testing pyramid from lesson 308: integration tests sit directly above unit tests — fewer of them, each one slower, each one covering a broader slice of the system at once.

WHY FEWER, WHY SLOWER, WHY BROADER
FEWER
SLOWER
BROADER

A healthy suite keeps this trade-off honest: use unit tests (308) for the volume of business-logic edge cases, and reserve integration tests for the specific, real collaborations worth verifying end-to-end — this is also exactly where lesson 310's mocking and lesson 311's Testcontainers come in, as tools for keeping those broader tests fast and reliable rather than slow and flaky.

How It Works — WebApplicationFactory<TEntryPoint>

WebApplicationFactory<TEntryPoint> is the standard ASP.NET Core type for in-memory integration testing. The generic type parameter TEntryPoint is your application's entry-point class — in a modern minimal-hosting ASP.NET Core project, that's simply Program (made accessible to the test project via a public partial class Program { } declaration at the bottom of Program.cs, since top-level statements generate an internal Program class by default).

WHAT WebApplicationFactory ACTUALLY DOES
1. BOOTS YOUR REAL Program.cs — IN-PROCESS
2. HOSTS IT ON AN IN-MEMORY TEST SERVER
3. HANDS YOU A REAL HttpClient
4. LETS YOU OVERRIDE SERVICES FOR THE TEST RUN

Simple Example

An integration test that verifies a real GET endpoint returns the right status and shape, through the real pipeline:

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public ProductsApiTests(WebApplicationFactory<Program> factory) { _client = factory.CreateClient(); } [Fact] public async Task GetProduct_ExistingId_Returns200WithProductBody() { // Act — a real request through the real middleware pipeline var response = await _client.GetAsync("/api/products/1"); // Assert response.EnsureSuccessStatusCode(); var product = await response.Content.ReadFromJsonAsync<ProductDto>(); Assert.NotNull(product); Assert.Equal(1, product!.Id); } [Fact] public async Task GetProduct_UnknownId_Returns404() { var response = await _client.GetAsync("/api/products/999999"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } }

Meaning: IClassFixture<WebApplicationFactory<Program>> tells xUnit to create the factory once and share it across every test in this class — booting the whole in-process app is real work, so this avoids paying that cost for every single test method. Every request in these two tests genuinely travels through routing, model binding, the controller action, and every registered middleware component — exactly the wiring a unit test could never exercise.

Real-World Example — Swapping the Database for the Test Run

A more realistic integration test needs a database to actually query — but it should never point at your team's shared development database, for reasons the "Common Mistakes" section below spells out. WithWebHostBuilder lets a test replace the real database registration with a test-scoped one:

public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly WebApplicationFactory<Program> _factory; public OrdersApiTests(WebApplicationFactory<Program> factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Remove the app's real DbContext registration... services.RemoveAll<DbContextOptions<OrdersDbContext>>(); // ...and register a fresh, uniquely-named in-memory // database, isolated per test class: services.AddDbContext<OrdersDbContext>(options => options.UseInMemoryDatabase($"OrdersTestDb-{Guid.NewGuid()}")); }); }); } [Fact] public async Task PostOrder_ValidOrder_Returns201WithLocationHeader() { var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/orders", new { ProductId = 1, Quantity = 2 }); Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.NotNull(response.Headers.Location); } }

Notice the uniquely-named database per test class — that's the isolation strategy this lesson's "Common Mistakes" section calls out explicitly. Also worth flagging honestly: EF Core's in-memory provider is convenient here, but it doesn't behave identically to a real relational database in every respect — lesson 311 (Testcontainers) picks up exactly this limitation and gives you a way to test against a real database engine instead, when that gap actually matters.

Analogy

Line Rehearsal vs. Dress Rehearsal

A unit test is one actor rehearsing their own lines alone in a room — fast, focused, and it proves that actor knows their part perfectly. But it says nothing about whether their entrance cue actually works, whether the lighting change lands on time, or whether the prop is where it needs to be when they reach for it. A dress rehearsal — the whole cast, the real set, the real lighting board — is slower and more expensive to run, and you wouldn't do it for every single line change. But it's the only rehearsal that actually catches the problems that only exist in how the pieces come together, which is exactly what integration testing is for.

Under the Hood — Test Isolation Between Runs

The single hardest part of writing reliable integration tests isn't the HTTP call — it's making sure one test's data never bleeds into another's. A test that creates an order with ID 1, followed by another test that assumes ID 1 doesn't exist yet, produces a suite that passes when run alone and fails when run alongside its siblings — one of the most frustrating categories of bug in a whole codebase, precisely because it's non-deterministic.

Isolation strategyHow it worksTrade-off
Fresh database per test classA uniquely-named database (as in the example above) created for each test class, torn down afterSimple, reliable; some setup cost per class rather than per test
Transaction rollback per testWrap each test in a database transaction, roll it back at the end instead of committingVery fast; requires the code under test not to depend on committed transactions
Respawn / reset between testsA library truncates and reseeds known tables between each testWorks well against a real database engine (lesson 311); adds a reset step per test

Whichever strategy a team picks, the underlying goal is the same one FIRST already named back in lesson 308: Isolated and Repeatable aren't just unit-test properties — they're exactly as important once real state enters the picture, arguably more so, because the failure mode when they're missing is far harder to diagnose.

Common Confusion

1. "Integration test means it opens a real network socket" — usually not, with WebApplicationFactory

WebApplicationFactory's in-memory TestServer deliberately avoids opening a real TCP port — requests are handed directly to the ASP.NET Core pipeline in-process. It's still a genuine integration test, because it exercises the real middleware pipeline, DI wiring, and routing; it just avoids the extra overhead and flakiness of real network I/O where that overhead buys nothing.

2. "Two classes calling each other is already an integration test" — not quite

A unit test that constructs two of your own plain C# classes and lets them collaborate, with no real I/O anywhere, is still fundamentally a unit test — it's still fast, isolated, and deterministic. The term "integration test" in this course's usage specifically implies crossing a real infrastructure boundary: an actual HTTP pipeline, an actual database, an actual external system — not merely "more than one class involved."

Common Mistakes

Mistake 1 — Pointing integration tests at a shared development database

Running tests against the same database another developer (or a running dev instance of the app) is actively using — tests intermittently fail because of data another process changed, and CI runs collide with each other, producing "it passed on my machine" mysteries.

Give each test run its own isolated database state — a fresh in-memory database, a uniquely-named disposable database, or a real container scoped to the test run (lesson 311). Never share mutable state with anything outside the test itself.

Mistake 2 — Letting integration tests silently depend on run order

A test that assumes "the previous test already created an order" — pass this test alone, and it fails; run the suite in a different order, and a different test fails instead.

Every test should set up exactly the state it needs itself, in its own Arrange step, and never assume anything left behind by another test.

Mistake 3 — Writing an integration test for logic a unit test already fully covers

Re-verifying every edge case of a business rule through a full HTTP round-trip, when lesson 308's unit tests already proved the logic correct in isolation — slow, redundant, and it inflates the pyramid's middle layer for no real gain.

Let integration tests verify the wiring and the collaboration — one or two representative cases through the real pipeline — and let unit tests own the exhaustive edge-case coverage of the underlying logic.

When Should I Use It?

Rule of thumb: If a bug could only exist because of how pieces are wired together — not because of what any one piece computes — it belongs to an integration test. If it's about what one piece computes, it belongs to a unit test.

Mental Model

Integration test = real components, wired together, verified through the real pipeline.
WebApplicationFactory<Program> = your real app, booted in-process, handed to you as an HttpClient.
Isolation still matters — fresh, disposable state per test, never a shared database.

Unit tests (308) prove each piece is correct. Integration tests prove the assembled system actually holds together.

Key Takeaway


Check Your Understanding

You've seen what integration tests catch that unit tests can't, and how WebApplicationFactory makes them practical. Let's confirm it clicked.

1. A middleware component is accidentally registered after the endpoint routing middleware instead of before it, so it never actually runs. Which kind of test is most likely to catch this?

Show answer

Correct: B

Why B is correct: Middleware ordering is a wiring concern that only manifests when the real, composed pipeline actually runs — exactly what an integration test through WebApplicationFactory exercises.

Why A is incorrect: A unit test of the middleware class alone never runs it inside the real, ordered pipeline — it can't observe an ordering mistake at all.

Why C is incorrect: This is exactly the kind of bug automated integration testing exists to catch reliably, without needing a human to notice it by hand.

Why D is incorrect: A controller-action unit test bypasses the middleware pipeline entirely — it would never observe this problem.

Reinforcement: Wiring and ordering bugs live only in the real, assembled pipeline — exactly the gap integration tests close.

2. What does the generic type parameter TEntryPoint in WebApplicationFactory<TEntryPoint> typically refer to?

Show answer

Correct: B

Why B is correct: TEntryPoint points WebApplicationFactory at your real application's startup class so it can boot the actual, real configuration and pipeline for the test — in a modern minimal-hosting project, that's Program.

Why A is incorrect: WebApplicationFactory boots your real app; it doesn't supply a mock database on its own — that's done separately via WithWebHostBuilder if needed.

Why C is incorrect: xUnit's own internals are unrelated to this generic parameter — it identifies your application, not the test framework.

Why D is incorrect: There's no such configuration file involved — WebApplicationFactory discovers your app's real routes by actually running its real startup code.

Reinforcement: TEntryPoint is what lets WebApplicationFactory boot your genuine app, not a stand-in for it.

3. Two integration tests fail only when run together in a specific order, but each passes fine when run alone. What is the most likely root cause, based on this lesson?

Show answer

Correct: B

Why B is correct: This is the exact symptom of the isolation failure this lesson calls out — shared, un-reset state between tests causing order-dependent failures. Fixing it means giving each test (or test class) its own fresh, disposable state.

Why A is incorrect: This isn't a framework bug — order-dependent failures are almost always caused by shared state the tests themselves failed to isolate.

Why C is incorrect: WebApplicationFactory works fine across multiple test classes; nothing about it limits this.

Why D is incorrect: Order-dependent failures are specifically a hallmark of missing isolation in tests that touch shared state — a genuine unit test, with no shared external state at all, cannot exhibit this symptom.

Reinforcement: Order-dependent failures are a direct signal of a shared-state isolation problem — give every test its own clean slice of state.

4. A team already has thorough unit test coverage (308) of a discount calculation's dozen edge cases. What's the most appropriate integration-test coverage for the endpoint that uses it?

Show answer

Correct: B

Why B is correct: This is exactly the division of labor the lesson describes — unit tests exhaustively cover logic edge cases cheaply; integration tests verify the wiring around that logic with a smaller, representative set of cases, avoiding redundant, slow re-coverage.

Why A is incorrect: This is Mistake 3 from the lesson — redundantly re-verifying logic already proven by fast unit tests, at a much higher cost per test.

Why C is incorrect: Unit tests can't catch wiring problems — routing, DI, middleware — no matter how thorough their logic coverage is; some integration coverage is still valuable.

Why D is incorrect: This inverts the pyramid — trading many fast, precise unit tests for fewer, slower, less-precise integration tests loses exactly the fast feedback loop unit tests exist to provide.

Reinforcement: Let unit tests own exhaustive logic coverage; let integration tests verify the real collaboration around that logic, with a smaller, representative footprint.

You now know how to verify real components working together with WebApplicationFactory, and how to keep those tests reliable through proper isolation. Next: what to do when a dependency genuinely shouldn't be part of the test at all — mocking.


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