Integration Testing in ASP.NET Core: Test the Real App
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)
- One isolated piece of logic
- No real I/O, ever
- Milliseconds per test
- Catches broken business logic
Integration test (this lesson)
- Several real components, wired together
- A real HTTP pipeline and/or a real database
- Tens to hundreds of milliseconds per test
- Catches broken wiring, routing, middleware, and serialization
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.
- You don't need an integration test for every single edge case of the discount rule from lesson 308 — unit tests already own that. Integration tests exist to check the wiring, not re-verify logic already proven in isolation.
- Spinning up an in-process host, running real middleware, and possibly touching a real (if disposable) database is real work — tens or hundreds of milliseconds instead of a fraction of one.
- One integration test can implicitly exercise routing, model binding, authorization, several middleware components, and a controller action — all at once, in a single test.
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).
- It runs the exact same service registration, middleware pipeline, and configuration your real app uses — no separate "test version" of your startup code to maintain and let drift out of sync.
- No real TCP port is opened — requests travel through an in-memory transport, which is both faster and avoids port-conflict flakiness between parallel test runs.
factory.CreateClient()returns an ordinaryHttpClientwired to talk to the in-memory server — your test code calls it exactly like it would call a real, deployed API.
WithWebHostBuilder(...)lets a test swap out specific real services — most commonly, replacing a real database provider with a test-friendly one, or a real external API client with a mock (lesson 310).
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
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 strategy | How it works | Trade-off |
|---|---|---|
| Fresh database per test class | A uniquely-named database (as in the example above) created for each test class, torn down after | Simple, reliable; some setup cost per class rather than per test |
| Transaction rollback per test | Wrap each test in a database transaction, roll it back at the end instead of committing | Very fast; requires the code under test not to depend on committed transactions |
| Respawn / reset between tests | A library truncates and reseeds known tables between each test | Works 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?
- Verifying an endpoint's full pipeline — routing, model binding, authorization, middleware, and the controller action — actually works together.
- Verifying that DI registrations resolve correctly and with the right lifetimes, in the real, composed application.
- Verifying a repository's queries actually produce correct results against a real (or realistically-behaving) database, not just that the C# compiles.
- Not the right layer for exhaustively covering every business-logic edge case — that's unit testing's (308) job, done far faster.
Mental Model
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
- An integration test verifies real components collaborating — routing, middleware, DI wiring, and often a real database — catching an entire class of "wiring" bugs unit tests structurally can't see.
WebApplicationFactory<TEntryPoint>(typicallyProgram) boots your actual ASP.NET Core app in-process and hands your test a realHttpClient;WithWebHostBuilderlets you override specific services — most commonly the database — for the test run.- Integration tests sit one layer above unit tests in the testing pyramid: fewer, slower, broader — used to verify collaboration, not to re-cover every business-logic edge case already owned by unit tests (308).
- Test isolation is critical: give every test (or test class) fresh, disposable state, never a shared database — flaky, order-dependent tests are one of the most damaging failure modes a suite can have.
- Next: how to keep an integration test's dependencies under control when a real dependency shouldn't be part of the test at all — mocking (310) — and how to get a real database's true behavior without the flakiness of a shared one — Testcontainers (311).
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.