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

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.

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.

A good test checks one behaviour, has a readable name, depends on no other test, runs fast and gives the same result every time.

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.

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.

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

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.

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.

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.

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.

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.

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

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.

Common mistakes

Interview questions

Related