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.
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.
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 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.
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.
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.
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.
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).
factory.CreateClient() returns an ordinary HttpClient wired 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).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.
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.
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.
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.
WebApplicationFactoryWebApplicationFactory'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.
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."
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.
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.
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.
WebApplicationFactory<Program> = your real app, booted in-process, handed to you as an HttpClient.WebApplicationFactory<TEntryPoint> (typically Program) boots your actual ASP.NET Core app in-process and hands your test a real HttpClient; WithWebHostBuilder lets you override specific services — most commonly the database — for the test run.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?
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?
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?
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?
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.