Testing
Test levels, the xUnit model, mocks and their overuse, integration testing via WebApplicationFactory, testable time, and what coverage does not tell you.
13 questions
JuniorTheoryVery commonWhat is Arrange-Act-Assert, and what belongs in each of its three sections?
What is Arrange-Act-Assert, and what belongs in each of its three sections?
It splits a test into three blocks: Arrange builds the inputs and the system under test, Act invokes the one operation being tested, and Assert checks the observed outcome. A single Act per test means a failure points at exactly one behaviour.
Common mistakes
- ✗Packing several Act calls into one test, so a failure no longer names one behaviour
- ✗Putting assertions inside the Arrange block, blurring setup and verification
- ✗Believing Arrange-Act-Assert is a framework feature rather than a convention for the test body
Follow-up questions
- →How does the behaviour-driven phrasing
Given-When-Thenmap onto Arrange-Act-Assert? - →When is it acceptable for one test to carry more than a single assertion?
JuniorTheoryVery commonWhat separates a unit test, an integration test and an end-to-end test?
What separates a unit test, an integration test and an end-to-end test?
A unit test exercises one class in isolation, collaborators replaced. An integration test runs real components together — code plus a real database or HTTP pipeline. An end-to-end test drives the deployed system from outside. Cost grows per level.
Common mistakes
- ✗Calling a test that touches a real database a unit test
- ✗Treating the three levels as interchangeable rather than differing in scope and cost
- ✗Building a suite dominated by end-to-end tests because they feel more realistic
Follow-up questions
- →Why does the test pyramid put most of a suite at the unit level?
- →Where does a test that hits a real database but no HTTP layer belong?
JuniorTheoryCommonIn the test framework xUnit, when do you write [Fact] and when [Theory]?
In the test framework xUnit, when do you write [Fact] and when [Theory]?
[Fact] marks a test taking no arguments — a single scenario that must hold. [Theory] marks a parameterized test: it takes parameters and runs once per data case from [InlineData] or [MemberData], each case reported as its own test.
Common mistakes
- ✗Putting method parameters on a
[Fact], whichxUnitrejects at discovery - ✗Assuming all
[InlineData]rows collapse into one reported test rather than one test per case - ✗Reaching for
[Theory]when the scenarios differ in behaviour, not merely in input data
Follow-up questions
- →When would you prefer
[MemberData]over a long list of[InlineData]rows? - →How do you keep a
[Theory]readable when every case needs a complex object?
JuniorTheoryCommonHow do the test frameworks xUnit, NUnit and MSTest differ, and why does xUnit re-create the test class per test?
How do the test frameworks xUnit, NUnit and MSTest differ, and why does xUnit re-create the test class per test?
All three discover tests by attribute and report results, differing in style. NUnit and MSTest reuse one class instance with [SetUp]/[TearDown]. xUnit builds a fresh instance per test — the constructor is the setup — so state cannot leak.
Common mistakes
- ✗Thinking
xUnitreuses one test class instance across all of its tests - ✗Looking for
[SetUp]/[TearDown]inxUnit— its constructor andIDisposableplay those roles - ✗Relying on execution order or on state an earlier test left behind
Follow-up questions
- →How does the shared-context interface
IClassFixture<T>reuse expensive setup without sharing mutable state? - →What replaces
[TearDown]inxUnitwhen a test needs cleanup after itself?
MiddleTheoryCommonWhy does the anti-pattern of over-mocking make a suite brittle, and what should you mock instead?
Why does the anti-pattern of over-mocking make a suite brittle, and what should you mock instead?
Mocking every collaborator makes the test assert on interactions — which calls happened, in what order — so it pins the implementation, not the behaviour: it survives wrong logic but breaks on a refactor. Mock only boundaries you do not own.
Common mistakes
- ✗Asserting on
Verifycall counts when the outcome the caller observes is what actually matters - ✗Mocking value objects and in-process collaborators instead of only the boundaries you do not own
- ✗Reading a green over-mocked suite as evidence that the behaviour is correct
Follow-up questions
- →When is a hand-written in-memory fake preferable to a
Moqmock? - →How would you test a method whose only observable effect is a call to an external gateway?
SeniorDesignCommonYou own an ASP.NET Core service whose repository layer runs EF Core against PostgreSQL. It leans on unique constraints, cascade deletes, a decimal money column and a couple of hand-written SQL projections. The team wants a fast, trustworthy test suite for that layer and is weighing three options: the EF Core in-memory provider, SQLite in-memory, and a real PostgreSQL instance started per run by the disposable-container library Testcontainers. Design the strategy. Say what each option can and cannot prove, where the EF Core in-memory provider gives false confidence, how you would seed and isolate data between tests, and what you would still leave to a unit test with no database at all.
You own an ASP.NET Core service whose repository layer runs EF Core against PostgreSQL. It leans on unique constraints, cascade deletes, a decimal money column and a couple of hand-written SQL projections. The team wants a fast, trustworthy test suite for that layer and is weighing three options: the EF Core in-memory provider, SQLite in-memory, and a real PostgreSQL instance started per run by the disposable-container library Testcontainers. Design the strategy. Say what each option can and cannot prove, where the EF Core in-memory provider gives false confidence, how you would seed and isolate data between tests, and what you would still leave to a unit test with no database at all.
The EF Core in-memory provider is LINQ-to-objects: no constraints, no cascade deletes, no SQL translation — it passes queries the real database rejects. SQLite adds real SQL in another dialect; only Testcontainers validates the mapping. Seed inside a rolled-back transaction, and leave pure rules to database-free unit tests.
Common mistakes
- ✗Trusting the EF Core in-memory provider, which enforces no constraints and translates no SQL
- ✗Mocking
DbContextandDbSet<T>, which tests LINQ over a list rather than your query translation - ✗Sharing one database across tests with no reset, so results depend on execution order
Follow-up questions
- →How would you keep a
TestcontainersPostgreSQL run fast enough for a pre-commit suite? - →Which defects would SQLite in-memory still hide that only the real engine exposes?
MiddleTheoryOccasionalWhat does line coverage actually measure, and how does branch coverage differ?
What does line coverage actually measure, and how does branch coverage differ?
Line coverage is the share of executable lines a run touched; branch coverage is the share of conditional outcomes taken. One test through if (a && b) touches every line while leaving most branch combinations untried.
Common mistakes
- ✗Reading a covered line as a verified line, when coverage records execution and not assertion
- ✗Assuming full line coverage implies every conditional outcome was exercised
- ✗Treating 100% branch coverage as proof that every execution path was tried
Follow-up questions
- →How does a
[Theory]with well-chosen cases lift branch coverage that a single[Fact]cannot? - →Why can a test carrying no assertion at all still push coverage to 100%?
MiddleTheoryOccasionalWhat problem do the test-data patterns Test Data Builder and Object Mother solve?
What problem do the test-data patterns Test Data Builder and Object Mother solve?
Both keep Arrange short when a valid object needs many fields. An Object Mother exposes named canonical instances; a builder chains fluent setters over defaults, so a test states only the field it cares about. A new required field breaks one place.
Common mistakes
- ✗Copy-pasting a twenty-line object construction into every test instead of centralizing it
- ✗Letting an Object Mother hand back one shared mutable instance that tests then mutate
- ✗Loading a builder with so many options that the test reads worse than plain construction
Follow-up questions
- →How does a builder keep the case data of a
[Theory]readable when each case needs a complex object? - →When does an Object Mother's fixed set of named instances become a maintenance burden?
MiddleTheoryOccasionalWhat is snapshot testing, and when is it right rather than explicit assertions?
What is snapshot testing, and when is it right rather than explicit assertions?
It serializes the result, compares it with an approved file in the repo, and fails on any diff; you read the diff and re-approve deliberately. It suits large, stable outputs such as an API response, and is wrong where output is non-deterministic.
Common mistakes
- ✗Re-approving a failing snapshot without reading the diff, which quietly erases the regression
- ✗Snapshotting output that embeds a timestamp, a GUID or dictionary order, making the test flaky
- ✗Reaching for a snapshot where one explicit assertion would state the intent clearly
Follow-up questions
- →How do you keep a snapshot stable when the payload embeds generated ids or timestamps?
- →How would you scope one snapshot per case in a
[Theory]carrying several data rows?
MiddleTheoryOccasionalHow do you test logic reading DateTime.Now without freezing the clock globally?
How do you test logic reading DateTime.Now without freezing the clock globally?
Stop calling the ambient clock — inject a time abstraction the code queries. On .NET 8+ that is TimeProvider: production gets TimeProvider.System, the test gets FakeTimeProvider. A global override leaks between tests and kills parallelism.
Common mistakes
- ✗Replacing
DateTime.Nowwith a mutable static that leaks state between parallel tests - ✗Believing
Moqcan intercept a static property such asDateTime.Now - ✗Adding a
Thread.Sleepand a tolerance window instead of taking control of time
Follow-up questions
- →How does
FakeTimeProvider.Advancelet you test a timeout without actually waiting for it? - →What changes when the code schedules work with
Task.Delayrather than reading the clock?
MiddleTheoryOccasionalHow does the integration host WebApplicationFactory<T> let you test an ASP.NET Core app?
How does the integration host WebApplicationFactory<T> let you test an ASP.NET Core app?
It boots your real Program pipeline in-process — actual middleware, routing, filters, model binding — and hands you an HttpClient on an in-memory TestServer, with no socket. Overriding DI registrations lets you swap the database.
Common mistakes
- ✗Thinking
WebApplicationFactory<T>opens a real port instead of an in-memoryTestServer - ✗Registering a test double before the app's own registration, so the real one overwrites it
- ✗Calling it an end-to-end test when the browser, the network and the deployment are all absent
Follow-up questions
- →How do you replace the real
DbContextregistration with a test one insideConfigureWebHost? - →Why does a passing
WebApplicationFactory<T>test still not prove the deployment works?
SeniorDesignOccasionalYour CI suite fails on roughly one run in twenty, always in a different test, and every failure passes on a re-run; the team's habit is to press Retry. The suite mixes unit tests, integration tests on the host WebApplicationFactory<T> against a shared database, and a few snapshot tests over serialized API responses, and it runs in parallel across test classes. Explain what systemically makes a suite non-deterministic, how you would locate the actual sources here rather than guess, and how you would design determinism in — covering ambient time, state shared between parallel tests, ordering assumptions, and the non-deterministic parts of a serialized snapshot. Say also what policy you would set for a test that stays flaky.
Your CI suite fails on roughly one run in twenty, always in a different test, and every failure passes on a re-run; the team's habit is to press Retry. The suite mixes unit tests, integration tests on the host WebApplicationFactory<T> against a shared database, and a few snapshot tests over serialized API responses, and it runs in parallel across test classes. Explain what systemically makes a suite non-deterministic, how you would locate the actual sources here rather than guess, and how you would design determinism in — covering ambient time, state shared between parallel tests, ordering assumptions, and the non-deterministic parts of a serialized snapshot. Say also what policy you would set for a test that stays flaky.
Flakiness is shared mutable state under parallelism plus ambient nondeterminism: the real clock, a database shared across classes, tests leaning on leftovers, snapshots embedding timestamps. Inject TimeProvider, isolate each test in a rolled-back transaction, scrub volatile fields — and quarantine, never retry, what still flakes.
Common mistakes
- ✗Retrying a flaky test in CI, which hides a real race or ordering bug behind a green run
- ✗Blaming the runner while parallel test classes still share one database or a static cache
- ✗Snapshotting payloads that embed a timestamp or GUID, then re-approving the churn each time
Follow-up questions
- →How would you prove a specific test is order-dependent rather than time-dependent?
- →What isolation would you give each parallel test class against one shared PostgreSQL instance?
SeniorTheoryRareWhy is 100% coverage no proof of quality, and what does the fault-injection technique mutation testing measure?
Why is 100% coverage no proof of quality, and what does the fault-injection technique mutation testing measure?
Coverage records that a line executed, not that anything asserted on it — delete every assertion and it still reads 100%. Mutation testing instead injects small defects and reruns the suite: a surviving mutant is code your tests run but never check.
Common mistakes
- ✗Treating a covered line as a verified line, when a suite with no assertions still reports 100%
- ✗Reading a surviving mutant as an over-strict test rather than an unchecked behaviour
- ✗Setting a coverage gate as the quality bar while over-mocked tests silently satisfy it
Follow-up questions
- →Why is a mutation score expensive to compute, and how would you scope such a run inside CI?
- →Which mutants are worth writing off as equivalent, and who gets to decide that?