Testing: integration tests, Testcontainers and Playwright
How a web product is tested from the inside out: the test pyramid and the testing trophy, xUnit, integration tests with WebApplicationFactory and fakes, in-memory databases against a real PostgreSQL in Testcontainers, architecture and approval tests, Vitest, browser walks with Playwright and axe, flaky tests, test data and CI - and the choices {{brand}} made.
In short
Testing means running your code in controlled conditions and checking the result by machine, so a change cannot break something unseen. Modern web products use several layers: unit tests for small rules, integration tests that start the whole API inside the test process with WebApplicationFactory, a real PostgreSQL in Docker (Testcontainers) for what a fake database cannot show, and Playwright browser walks of the built app, with axe checking accessibility. Design rules can be tests too. {{brand}} runs about 11,000 backend tests and more than 5,000 web tests this way (the run of 20 October 2026; the real-database tests need Docker and run in CI).
After this lesson you can Explain the test pyramid and the testing trophy and choose a shape for a project, write an ASP.NET Core integration test with fakes and a test sign-in, decide when an in-memory database is enough and when to start PostgreSQL in Docker, turn a design rule into a test, and find the cause of a flaky test instead of retrying it.
Words you need
- Unit test Checks one function or class alone, with everything it talks to replaced. Fast, but blind to how the parts fit together.
- Integration test Checks several real parts together - here the HTTP pipeline, the handlers, the access rules and a database.
- End-to-end test Drives the finished app in a real browser, as a person would, against a real server and database.
- Test double A stand-in for a real dependency. A fake works (an in-memory store), a stub returns canned answers, a mock records calls so the test can check them.
- Test host The real application started inside the test process, with fakes swapped in for outside services. In ASP.NET Core it is built with WebApplicationFactory.
- Fixture Setup a test framework builds once and hands to many tests - a database, a host, a small world of users.
- In-memory provider EF Core's database that keeps rows in .NET collections. Very fast, but not a relational database: no SQL, constraints or real transactions.
- Testcontainers A library that starts real services - PostgreSQL, Redis, a message broker - in Docker from test code, and throws them away afterwards.
- Approval test Compares output with a reviewed file kept in the repository; any difference fails until a person approves it. Snapshot tests are the same idea.
- Architecture test A test that reads the code itself - references, routes, source text - and fails when a design rule is broken.
- Flaky test A test that passes and fails on the same code. The cause is almost always time, shared state, order or load.
- Arrange-Act-Assert The three parts of a readable test: set up what you need, do one thing, check one result.
The test pyramid and the testing trophy
Two famous pictures describe how many tests of each kind a project should have. The test pyramid (Mike Cohn, 2009) says: many fast unit tests at the bottom, fewer integration tests, very few end-to-end tests at the top. The testing trophy (Kent C. Dodds) adds static analysis at the base - types and linters - and makes integration tests the widest part, because they give the most confidence for each minute spent.
- Binding A JSON field with another name, a date sent with an offset, a missing query value: the request never reaches the handler the way the unit test assumed.
- Access A route that forgets its permission check, or answers 403 where it should answer 404 and so reveals that a record exists.
- Queries A filter that should keep one organization's rows away from another, or a soft-deleted row that still appears.
- Errors An exception mapped to 500 instead of 400 or 409.
- Between components An event one part publishes that another part handles wrongly, or not at all.
A unit test with all of these faked cannot see any of them. That is why {{brand}} is shaped like a trophy: the compiler (warnings are errors) and linters at the base, unit tests for pure rules - calculations, parsers, policies - and most effort in integration tests through the real HTTP pipeline.
xUnit: how a .NET test runs
xUnit.net is the most used test framework for modern .NET. A test is a public method marked [Fact]; a [Theory] runs the same method once for every row of data from [InlineData] or [MemberData]. The runner behind dotnet test finds tests by reflection and reports each result - pass, fail or skip with a reason.
- Arrange Build the input and the host: a fresh object, a test client, a few rows.
- Act Call the code or send the request - one action.
- Assert Check one outcome. The first failed assertion ends the test and shows the expected and actual values.
- Name Give the test a name that is a sentence of behaviour, so a failure says which promise broke.
A good test checks one behaviour, has a readable name, depends on no other test, runs fast and gives the same result every time.
- Discover The runner loads the test assembly and finds every Fact and every Theory row.
- Group into collections By default each test class is its own collection. Collections run in parallel; tests inside one collection run one after another.
- New instance per test xUnit creates a new object for every test, so a field can never leak from one test into the next. The constructor is the setup.
- Inject fixtures A class fixture or collection fixture is built once and passed to the constructor of every test that asks for it.
- Run and assert Assert.Equal, Assert.Throws, Assert.Single... The first failed assert throws and ends the test, with expected and actual values in the message.
- Tear down Dispose or DisposeAsync runs after each test; a fixture is disposed after the last test that used it.
A test that cannot run on this machine can use Skip = "reason", for example when Docker is missing, so the report says what to do. A skipped test is not a passing test.
Name tests as sentences {{brand}} names tests as sentences of behaviour, for example: a note in someone else's personal notebook is invisible however it is anchored. When it fails, the name alone says which promise broke.
Integration tests with WebApplicationFactory
WebApplicationFactory<TEntryPoint> (package Microsoft.AspNetCore.Mvc.Testing) starts your real application - the same Program, middleware, routes and dependency injection - inside the test process. It swaps the Kestrel web server for TestServer, an in-memory server, so a request never opens a socket or a port. You get an HttpClient that talks straight to the pipeline.
- Find the entry point The factory runs your Program but stops it before it listens, and keeps the host it built. Program must be visible to the test project (public partial class Program;).
- Configure for tests ConfigureWebHost runs after the app registered its services, so a service the test registers replaces the real one: the last registration wins. Settings the app reads directly in Program.cs while it builds may already be read by then: prefer reading them through options or services, or set them with UseSetting.
- Build lazily The host is built the first time you ask for Server, Services or a client. Every setting must be made before that moment.
- Send requests CreateClient returns an HttpClient whose handler passes each request to TestServer in memory - routing, binding, filters and error handling all run.
- Dispose The host, its services and its background workers stop with the factory.
Only the identity is faked. A test authentication scheme reads a header and turns it into a signed-in user with just an id. By default, roles, membership and permissions are then decided by the real code. Many module suites swap the permission check for an allow-everything stand-in to focus on their own behaviour, and the access suites run it for real - so a test that says user 2 may not see user 1's order tests the real access rules only when it keeps them.
Two database providers at once Since EF Core 9, AddDbContext also registers a configuration callback. Remove only DbContextOptions<T> and add the in-memory provider, and both providers are configured: EF Core refuses to start. Remove IDbContextOptionsConfiguration<T> too, as in the example above.
Fakes, stubs, mocks and test clocks
{{brand}} uses no mocking library. Almost all doubles are small hand-written classes, and the common ones are shared by every suite. A fake behaves like the real thing, so a test checks behaviour instead of which methods were called, and a refactoring that renames an internal method breaks no test. The rule of thumb: fake the edges - email, payments, AI models, outgoing HTTP, the clock - and run everything inside for real.
Rename or split the internal Send call and the first test breaks although nothing a user sees has changed; the second still passes.
Time needs special care. Code that reads the time through TimeProvider (.NET 8 and later) can be given a clock the test controls. {{brand}} keeps a clock frozen at one instant (so a per-second budget is tested inside one second, however slow the machine), one whose time and stopwatch move together, and one whose timers fire only when the test advances it - an hour of waiting becomes one line.
AI features are tested without a live model. A scripted model returns fixed answers, so the test checks what the code does with an answer; measured evaluation sets guard safety filters; a live model is used only when someone supplies a key, and the test skips otherwise.
In-memory databases against a real PostgreSQL
EF Core's in-memory provider keeps rows in plain .NET collections. It is perfect for proving what an endpoint does with data, and a new store starts in milliseconds. But it is not a relational database, and Microsoft itself advises against using it to test database behaviour. Everything in the table below passes in memory and can still fail in production.
- Is there a server? A connection string in an environment variable wins (a developer's server, a CI service). Else, if Docker runs, Testcontainers starts PostgreSQL 17 in a container. Else every test is skipped with a message saying how to provide one.
- Start once The container starts once per test run, the first time a test needs it, on a random free port.
- A database of its own Each test, or each collection of tests, creates a database with a unique name on that server.
- Migrate and seed The same migrations and seeders a deployment runs bring the database up to date - so the migrations are tested too.
- Run Tests use the real provider; race tests use real, separate connections.
- Drop The database is dropped, forcing any open connection off. When the run ends, Testcontainers' resource reaper removes the container.
- Prepare the racers Create eight contexts, each with the same row to insert, and open each connection first so connecting is not part of the race.
- Wait at a barrier Each racer counts itself in and waits on one shared signal.
- Release together The last racer to arrive releases them all at once. The signal is created with RunContinuationsAsynchronously so no racer runs on the thread that releases the others.
- Count the winners With a unique index exactly one insert wins; every other one must fail with the unique-violation error the code knows how to handle.
A skipped suite is not a passing suite On a laptop without Docker the real-database tests skip, with a message. In CI that would hide every database bug, so the pipeline fails the job if any of them was skipped.
The options in the market
- EF Core in-memory provider Rows in .NET collections - the fast tier, used for most tests.; Starts in milliseconds; Nothing to install; A fresh store for every host; Not relational: no constraints, transactions or SQL; Hides races and time-zone bugs
- SQLite in memory A real SQL engine inside the process.; Fast and relational; Enforces constraints; A different SQL dialect from PostgreSQL; Some types and functions behave differently; Still not what production runs
- Real PostgreSQL in Testcontainers The production engine in a throwaway container - the real tier, used for what memory cannot show.; Exactly what production runs; Real migrations, constraints and races; Needs Docker; Seconds to start and slower per test
- One shared development database Every test against the same server and data.; No setup at all; Tests see each other's rows; Order-dependent and flaky; Can damage real work
- A real database reset between tests A rollback or a cleanup tool such as Respawn.; Real engine with fast cleanup; Code that opens its own transaction or connection escapes a rollback; Tests cannot run in parallel on one database
- Playwright Microsoft's library for Chromium, Firefox and WebKit.; Isolated browser contexts, cheap to create; Auto-waiting before every action; One API for Node, .NET, Python and Java; Emulates phones, themes, locales and time zones; Downloads its own patched browser builds
- Cypress Runs inside the browser next to the app.; Excellent interactive debugger; Time-travel snapshots of each step; JavaScript and TypeScript only; No multiple tabs; WebKit only experimental
- Selenium WebDriver The W3C WebDriver standard.; Every browser and many languages; Large grid and cloud ecosystem; More waiting code to write by hand; Slower round trips per command
- Puppeteer Google's library over the DevTools protocol.; Close to Chrome's own tools; Light and simple; Chrome and Firefox only; Few test helpers built in
- xUnit The framework ASP.NET Core's own docs use.; A new class instance per test: no accidental shared state; Parallel collections by default; Fixtures by constructor injection; No setup attributes - by design, which surprises NUnit users
- NUnit The long-standing .NET framework.; Rich attributes: TestCase, Values, Combinatorial; Familiar to Java JUnit users; One instance per fixture: fields can leak between tests
- MSTest Microsoft's framework, in Visual Studio templates.; Strong in Microsoft-centred teams; Modern v3 with good analyzers; Smaller open-source ecosystem of extensions
- TUnit A newer, source-generated framework.; Tests found at compile time, fast start; Async and parallel first; Young: fewer examples and integrations
What {{brand}} chose, and why
{{brand}} is a business application built from framework parts: HTTP binding, access middleware, query filters, events between modules. Its bugs live between those parts, so most tests run through the real pipeline on the in-memory provider, which keeps thousands of hosts fast. What the fake cannot show - constraints, races, migrations, timestamps - runs on real PostgreSQL in Testcontainers. Rules that people forget become architecture and approval tests, and what only a built app shows is caught by Playwright walks.
- Slower than pure unit tests: every integration test boots a host. In one project, test hosts took tens of minutes because each compiled hundreds of endpoint delegates at start-up; generating that code at build time cut it to under a minute.
- Two database worlds: a behaviour can pass in memory and fail on PostgreSQL. The rule is simple - anything about constraints, concurrency, SQL translation or time goes into the real-database suite.
- Hand-written fakes are code to maintain: but the common ones are shared by every suite, so a fake is fixed once for all of them.
- Walks need a running system: they are run against a fresh database before a release and after large changes, not on every commit.
When another choice is right A library of pure algorithms should be a pyramid of unit tests. A system of many small services needs contract tests between them (for example Pact). A team without Docker can start with SQLite in memory as a middle step, knowing its SQL is not PostgreSQL's.
Architecture and approval tests: rules nobody can forget
Some rules live only in a document: a layer must not reach into another; every route is authorized; no handler hides errors; no user-facing sentence is typed into code. People forget documents. An architecture test reads the code - the compiled assembly references, the running app's route table or the source text - and fails the build with the reason in its message.
An approval test applies the same idea to output that is long and important. The test produces the actual text, compares it with an approved file in the repository, and fails on any difference, listing what appeared and what vanished. A person reads the difference and, if it is right, approves it by regenerating the file - so the change shows up in code review next to the code that caused it. Teams use the same idea for lists that matter, such as every public route of an API or every permission an administrator role holds.
- Generate The test builds the actual list from the running code, sorted so it is stable.
- Compare It compares that list with the approved file, line by line.
- Explain The failure lists the lines not reviewed yet and the lines that are gone.
- Decide A person decides each new line - for example: should this route be public, and who may call it?
- Approve A script rewrites the file, and the difference is reviewed with the code change.
When many parts of a system must keep the same promise - a removed member sees nothing, an outsider is answered as if the record did not exist - write the promise once as an abstract test class. Each part inherits it and adds a small adapter that says how its records are made and opened. A new part gets every test the day it joins, and no part can quietly behave a little differently.
Exception lists must expire A rule with allowed exceptions needs a reason on every line, and the test should fail when an exception is no longer needed. A good pattern goes further: a test that is expected to fail is marked, it must keep failing, and the day it passes the mark has to be removed.
Is a test any good? Coverage and mutation
Code coverage says which lines ran during the tests, not whether anything checked them: a test with no assert can cover a whole file. Mutation testing asks the better question. It changes the code on purpose - flips a condition, removes a check, returns a constant - and expects some test to fail; a change that survives shows a test that proves nothing. Tools such as Stryker (Stryker.NET, StrykerJS) do this automatically.
{{brand}} does not gate on a coverage percentage. Instead, when a security or access check is added, the change is proved the mutation way by hand: remove the check, watch a test fail, put it back. Reports of that work say how many checks were shown to fail a test when removed.
Vitest for the web app
Vitest is a test runner built on Vite, the web app's build tool. It reuses the same configuration - path aliases, plugins, the TypeScript and JSX transforms - so a test imports source files exactly as the app does, with no separate Babel or Jest setup. Its API matches Jest's: describe, it and expect, plus vi for mocks, spies and fake timers.
- Collect Vitest finds the *.test.ts and *.test.tsx files under the folder it is given - in {{brand}}, next to the code they test.
- Transform Vite turns TypeScript and JSX into JavaScript on the fly, with the app's own configuration.
- Isolate Test files run in parallel workers, each with its own module graph, so a mock in one file never leaks into another.
- Set up Setup files run first in every worker. {{brand}}'s puts all of the English text in place, so no test waits for a translation file.
- Run and report A failure shows the difference between expected and received; watch mode re-runs only the tests whose imports changed.
Most of {{brand}}'s web tests are pure logic: what a function returns for a given state. Components are tested without a browser DOM: the test renders the component to an HTML string with react-dom/server (renderToStaticMarkup) and checks the markup. That is fast and needs no DOM setup, unlike jsdom with Testing Library, which can click, type and move focus but is slower and needs configuring. The price is that a static render has no clicks or focus, so event logic is kept in pure functions and tested directly. Hooks that fetch data are replaced with vi.mock, so the test decides what the server said.
Test the checker too The architecture checker and the translation checks have their own unit tests, run before they check the app. A rule that silently stops matching is worse than no rule, because everyone believes it still holds.
Browser walks with Playwright and axe
Some defects exist only in a production build against a real server: a stylesheet the bundler dropped, a service missing from the container, a layout that breaks at phone width, a raw translation key on screen. {{brand}} finds them with walks: scripts that drive the built app with Playwright against the real API and a throwaway PostgreSQL, screen by screen, turning every problem they see into a finding.
- Build and start The production build on a preview server, the API in Release mode, a fresh database, and a stand-in AI server that gives fixed, valid answers.
- Make its own data Accounts and records are created through the API, so a walk never depends on what is already there.
- Open contexts One browser context per width - phone, tablet, desktop - and theme - light, dark - with a fixed time zone, so dates look the same on every machine.
- Listen Console errors, page errors, failed requests and 4xx or 5xx answers are collected while the page works.
- Capture and inspect A screenshot, then checks inside the page: wider than the screen, a raw translation key, technical text such as undefined or NaN, an error alert.
- Check accessibility axe runs the WCAG 2.2 A and AA rules; a serious or critical violation is a finding.
- Report Every finding, with its screen and address, goes into one report; any finding fails the walk.
The tags select rule sets; impact says how bad a violation is (minor, moderate, serious, critical); here only serious and critical fail the run.
Exceptions need reasons An axe rule may be silenced only with a written reason - usually for one named element, for example a third-party widget's own link that you cannot change. The script refuses an exception without a reason, so exceptions stay reviewable. A full walk covers hundreds of screens at phone, tablet and desktop widths in light and dark themes.
How Playwright itself works - browser contexts, locators, auto-waiting, traces - is explained in the browser automation lesson.
Flaky tests: causes and cures
A flaky test passes and fails on the same code. It is worse than no test: people learn to rerun until green, and a real failure hides among the false ones. Every flake has a cause - find it and remove it.
- Reproduce Run the test alone in a loop, then under load: every project at once, or the process pinned to two cores.
- Measure Low CPU with many busy threads means a lock, not load. Stack dumps show where the threads wait.
- Find the shared thing A clock, a worker, a static field, a port, a database row, a cache.
- Remove the cause Control the time, isolate the state, wait for a condition, fix the race.
- Prove it Run it many times under the same load, and leave a note in the code saying why it is written that way.
Two real examples. In one project, test hosts took tens of minutes because every host compiled hundreds of endpoint delegates at start-up and the threads queued behind one lock in the runtime; generating that code at build time cut it to under a minute. In another, test processes grew past 9 GB because a framework component was never disposed and kept every finished host alive; the test host now releases it, and a test fails if a framework update moves it.
Do not retry blindly Automatic retries turn a flaky test green and keep the bug. {{brand}}'s pipeline has no retries. Instead, a test that hangs is stopped after 15 minutes and named as a failure, so a stuck test cannot hold the whole job.
Test data that does not lie
Good test data is owned by the test, readable and reproducible. A test that depends on rows someone else made breaks when they change; a test with random data that cannot be replayed fails once and is never understood.
Property tests with a seed Instead of writing ten examples, generate many random shapes from a fixed seed and check a rule that must always hold - for example, that removing a user never gives anyone more access than before. Libraries such as FsCheck and CsCheck add shrinking to the smallest failing case; {{brand}} grows its shapes through the real API, so a failure names the seed that reproduces it.
Running tests in CI
- Build once Restore and build the whole solution in Release; warnings are errors.
- Backend tests Every test project in turn, with --no-build, a hang timeout and a cap on parallel threads, so memory use stays bounded.
- Real-database job A Linux job with Docker builds the PostgreSQL suite, runs it through Testcontainers and fails if any test skipped.
- Web job npm ci, then the full check: architecture, translations, Vitest, lint, the production build and bundle budgets.
- Other packages Other packages - a command-line tool, a browser extension - run their own tests as they ship.
- Generated files are current A test host writes the API's OpenAPI document (the machine-readable description of every route). The build fails if the committed copy differs, so documentation and clients cannot drift from the code.
Two habits keep CI honest. Tests do not depend on the network or on secrets: payments run through a fake provider whose test checkout raises the same events a real one would, and AI calls go to a scripted stand-in. A test that truly needs something an agent may not have - a live AI model with a real key - skips with a message saying what it needs, while the real-database job refuses to skip at all.
Try it yourself: an integration test and a real database
In about half an hour you can see, on your own machine, why both kinds of database test exist. You need the .NET SDK and Docker.
- Make a tiny API dotnet new webapi; one POST /orders endpoint (code below) that saves an order with EF Core, with a unique index on its number; then create the first migration.
- Add a test project dotnet new xunit; reference the API and Microsoft.AspNetCore.Mvc.Testing; add public partial class Program; to the API.
- Write the first test Use WebApplicationFactory<Program>, swap the database for the in-memory provider (code below), and POST the same order number twice.
- Watch it pass wrongly Expected result: 201 and 201. In memory both orders are saved: the unique index is not enforced.
- Add Testcontainers Reuse the Database fixture and the db collection from the Testcontainers section. Point a host at its connection string and migrate.
- Watch it fail rightly Expected result: 201, then 409. The second insert fails with 23505 and the endpoint answers 409 Conflict; the test passes for the right reason.
Common mistakes
- Mocking what you own: a test that mocks the repository a handler calls proves the call happens, not that the feature works. Fake the edges - email, payments, the clock, outgoing HTTP - and run the rest for real.
- Trusting the in-memory database with unique indexes, transactions, SQL translation or time zones. Put those tests on a real PostgreSQL.
- Sleeping instead of waiting: a fixed sleep passes on your laptop and fails on a busy agent. Wait for a condition, or control the clock.
- Retrying flaky tests until green: the flake becomes invisible and the bug ships. Find the shared state or timing and remove it.
- Sharing data between tests: one test's rows change another's answer. Give every test its own store, ids and names.
- Counting a skipped suite as a pass: no Docker or no key silently skips it. Fail CI when a suite that must run was skipped.
Interview questions
- What is the difference between the test pyramid and the testing trophy? The pyramid puts most tests at the unit level and very few end-to-end tests at the top. The trophy adds static analysis at the base and makes integration tests the widest part, because in web apps they catch the bugs between layers. Choose the shape by where your bugs live.
- How does WebApplicationFactory work? It runs the app's real entry point but replaces Kestrel with an in-memory TestServer, builds the host lazily, and lets the test override services after the app registered its own. CreateClient returns an HttpClient that calls the pipeline without a socket.
- How do you sign in inside integration tests? Register a test authentication scheme that turns a header into a user with just an id, and keep everything after it real. Only the identity is faked: by default roles, membership and permissions are still decided by the real code, although suites often swap the permission check for an allow-everything stand-in to focus on their own behaviour, and the access tests run it for real.
- When is the EF Core in-memory provider not enough? Whenever the database's own behaviour matters: unique and partial indexes, transactions, ExecuteUpdate, concurrency, SQL translation, time zones and migrations. Those need the real engine, for example PostgreSQL in Testcontainers.
- What are the kinds of test double and when do you use each? A dummy fills a parameter; a stub returns canned answers; a spy also records calls; a fake is a simple working copy such as an in-memory store; a mock verifies expected calls. Prefer fakes at the edges of the system, because they test behaviour instead of call shapes.
- How do you test a race condition? Open several real connections, hold them at a barrier, release them together and count the outcomes. With a unique index exactly one insert must win and the others must fail in a way the code handles - an in-memory store cannot show this.
- What are architecture and approval tests? Architecture tests read the code - references, routes, source - and fail when a design rule breaks. Approval tests compare output with a reviewed file in the repository, so any change must be looked at and approved in code review.
- What is a flaky test, and how do you fix one? A test that passes and fails on the same code, usually because of time, shared state, order, load or the network. Reproduce it under load, find the shared thing, then control or isolate it - never hide it with retries.
- Mock or fake? A mock verifies interactions, which ties the test to how the code is written; a fake is a small working implementation. Fakes at the system's edges keep tests about behaviour, and they survive refactoring.
- Is 100% code coverage a good goal? No. Coverage shows which lines ran, not whether anything was checked. Mutation testing, or removing a check and watching a test fail, shows whether a test really guards the code.
Related
- /help/technology/data
- /help/technology/browser-automation
- /help/technology/azure-devops