Anyone can write a passing test; the skill interviewers are actually probing at the lead level is whether a candidate can design a testing strategy — the right mix of test types, the right boundaries between them, and the judgment to know when 90% coverage is meaningless and when a thin layer of integration tests is worth more than a thousand mocked unit tests. These questions come up constantly in lead and staff loops because testing strategy sits at the intersection of technical depth and team leadership: you are not just answering "how do I test this," you are answering "how do I make forty engineers ship confidently without slowing down." The ten questions below cover the pyramid versus the testing trophy, unit-versus-integration boundaries, consumer-driven contract testing with Pact, Testcontainers, flaky tests, performance and load testing, testing in production, and mutation testing with Stryker.NET.
Q1 The test pyramid versus the testing trophy — which model would you apply to a typical .NET microservices system, and why?#
Short answer: The classic pyramid (many fast unit tests, fewer integration tests, a thin layer of end-to-end tests) still describes the right cost curve, but the testing trophy — a thin static-analysis base, a small unit layer, a much larger integration layer, and a thin end-to-end top — better matches where real defects actually live in a typical ASP.NET Core and EF Core service, because most bugs show up at the seams between components rather than inside a single class's logic.
A heavily mocked unit test can pass while the real integration it stands in for is broken: an EF Core query that doesn't translate the way you assumed, a JSON casing mismatch between services, a dependency that was never actually registered in the container. The trophy's re-weighting toward integration tests reflects a real shift in tooling — WebApplicationFactory combined with Testcontainers now runs a realistic integration test against a real database in seconds, not the minutes it took a decade ago, which removes the traditional reason to avoid that layer. A practical guideline: reach for pure, heavily mocked unit tests for complex logic with many branches — a pricing engine, a state machine, a parsing routine — where isolating the algorithm actually helps you reason about it, and default to integration tests for anything that touches a controller, EF Core, or a message handler, since the cost difference between the two is now small while the confidence difference is large. Keep true end-to-end tests thin regardless of which model you name, because a handful of critical user journeys through a real deployed system is still slow and comparatively fragile no matter how good your tooling is.
Treat "pyramid or trophy" as a spectrum to tune per codebase rather than a single correct answer: a library with intricate algorithms and few external dependencies skews toward the pyramid's unit-heavy shape, while a typical service that is mostly wiring, persistence and HTTP contracts skews toward the trophy.
What interviewers look for: awareness that the trophy is not just a rebrand of the pyramid but a reasoned response to where bugs actually occur and to what integration tests now cost, plus the judgment to adapt the shape to the codebase instead of reciting one model as universally correct.
Common mistakes:
- Treating the pyramid as dogma and writing hundreds of unit tests around heavily mocked EF Core or HTTP calls that verify the mock, not the system.
- Naming the trophy without being able to say why it fits — because integration tests got cheap, not because unit tests stopped mattering.
Q2 Where exactly do you draw the line between a unit test and an integration test in a .NET codebase?#
Short answer: A unit test exercises one unit of logic in complete isolation — no file system, no network, no database, no real clock — with every collaborator replaced by a test double, and it should be able to run in parallel with thousands of others in well under a second each. An integration test deliberately crosses at least one real boundary — an ASP.NET Core pipeline via WebApplicationFactory, a real database via Testcontainers, a real message broker — because the thing under test is precisely how those pieces work together, not how one class behaves alone.
The boundary is about intent, not raw speed. A test that spins up an in-memory SqliteConnection to "unit test" a repository is really an integration test wearing a unit test's badge: it is verifying SQL translation and mapping behavior, which is exactly the kind of seam integration tests exist for, and SQLite's dialect differences from your real production database (SQL Server, PostgreSQL) can hide real bugs. The clean rule: if a test's failure could point to more than one component being wrong together — a query, a mapping, a DI registration, a middleware ordering — it is an integration test, and it should run against the real technology in that slot wherever practical, via Testcontainers, not an in-memory substitute that behaves differently.
public class CatalogApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public CatalogApiTests(WebApplicationFactory<Program> factory) =>
_client = factory.CreateClient();
[Fact]
public async Task Get_product_returns_ok_for_existing_id()
{
using var response = await _client.GetAsync("/products/42");
response.EnsureSuccessStatusCode();
}
}On boundaries between services, treat the HTTP or message contract itself as the unit under test rather than mocking a downstream service's behavior from memory — that mocked assumption is exactly what contract testing formalizes, covered next.
What interviewers look for: a definition based on isolation and intent rather than test runner or file location, and the specific insight that an in-memory database swap is an integration test with a misleading label, not a fast unit test.
Follow-up questions:
- Where would you put a test that calls a repository backed by an in-memory
DbContextprovider? - How do you keep integration tests from becoming as slow as end-to-end tests as the suite grows?
Q3 How does consumer-driven contract testing with Pact work, and when does it replace a broader integration test?#
Short answer: In consumer-driven contract testing, the API consumer writes a test against a local mock of the provider that records the requests it makes and the responses it expects, producing a portable contract file; the provider then replays every recorded interaction against its real implementation to prove it satisfies what consumers actually depend on, which catches breaking changes at build time instead of after a service is deployed and another team's calls start failing.
Pact (with pact-net as the .NET implementation) formalizes an assumption teams otherwise carry around informally — "I think the orders service returns a total field as a decimal" — into an executable, versioned artifact both sides run in CI. The consumer-side test looks like an ordinary unit test against a mock HTTP server:
[Fact]
public async Task Requesting_an_order_returns_its_total()
{
pact.UponReceiving("a request for order 42")
.Given("order 42 exists")
.WithRequest(HttpMethod.Get, "/orders/42")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithJsonBody(new { id = 42, total = 129.99m });
await pact.VerifyAsync(async ctx =>
{
var client = new OrdersClient(ctx.MockServerUri);
var order = await client.GetOrderAsync(42);
Assert.Equal(129.99m, order.Total);
});
}That test produces a pact file the provider team pulls — typically via a Pact Broker — and replays against its real API in a separate verification step, failing the provider's build if it no longer matches what a consumer depends on. Contract testing does not replace integration testing inside a service; it replaces the specific, expensive practice of standing up every downstream dependency (or a hand-maintained mock of it) just to prove two services still agree on a shape, and it scales far better than a shared end-to-end environment as the number of services grows, because each pair of consumer and provider verifies independently and in parallel.
What interviewers look for: a correct description of the consumer-drives-the-contract direction (a common point of confusion — providers do not write the contract), and clarity on what contract testing replaces versus what it leaves for other test layers to cover.
Common mistakes:
- Describing provider-driven contracts instead of consumer-driven ones, or treating Pact as a stand-in for full end-to-end tests rather than for the specific problem of cross-service compatibility.
- Skipping the provider verification step, so only half the contract is ever actually checked.
Q4 How do you use Testcontainers to test against real dependencies without making CI painfully slow?#
Short answer: Use Testcontainers to start the real technology — PostgreSQL, Kafka, Redis — in a disposable Docker container scoped to a test run, so the test exercises actual SQL translation, actual serialization and actual broker semantics instead of an approximation; keep it fast by sharing one container per test class or per assembly rather than per test method, and by running those containers in parallel across CI agents rather than serially.
public class OrdersRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
public Task InitializeAsync() => _db.StartAsync();
public Task DisposeAsync() => _db.DisposeAsync().AsTask();
[Fact]
public async Task Saving_an_order_persists_its_line_items()
{
await using var context = CreateContext(_db.GetConnectionString());
var repository = new OrdersRepository(context);
await repository.SaveAsync(new Order(42, [new LineItem("SKU-1", 2)]));
var reloaded = await repository.GetAsync(42);
Assert.Single(reloaded.LineItems);
}
}The performance levers that matter in practice: start the container once per test class using IAsyncLifetime or a collection fixture instead of once per test, since container startup — even a lightweight Alpine image — dominates a small, fast query if you pay it every time; reset data between tests with a transaction rollback or a targeted DELETE/TRUNCATE rather than restarting the container; pin specific image tags so a test run is reproducible and doesn't silently pick up a new major version; and make sure CI runners actually have a Docker daemon available and enough parallelism, since Testcontainers-based suites are usually CPU- and I/O-bound on container startup rather than on the tests themselves. Reserve one shared, longer-lived container per suite for expensive dependencies like Kafka, and isolate test data by topic or key prefix instead of restarting the broker between tests.
What interviewers look for: concrete lifecycle management (class-scoped containers, data reset strategy, pinned images) rather than "we use Testcontainers," since the naive per-test-method container is the single most common reason teams abandon the approach as "too slow."
Common mistakes:
- Starting a fresh container per test method, which makes an otherwise fast suite take minutes.
- Not pinning image versions, so a suite that passed yesterday fails today against a new image release.
Q5 A test suite has become unreliable — some tests fail intermittently for no obvious reason. How do you diagnose and fix flaky tests systematically?#
Short answer: Treat flakiness as a bug in the test's determinism, not bad luck: quarantine the specific test immediately so it stops blocking unrelated pull requests, reproduce the failure reliably by running it in a tight loop or under load rather than waiting for CI to fail again by chance, and fix the actual non-determinism — shared mutable state, wall-clock dependence, unawaited async work, test order dependence, or a genuine race in the code under test — rather than papering over it with a retry.
The most common root causes, roughly in order of frequency: tests that share mutable state (a static field, a shared database row, a singleton with test-visible state) and therefore depend on execution order or parallelism; tests that depend on real wall-clock time (DateTime.UtcNow comparisons, fixed sleeps used as synchronization) instead of an injected, controllable time source; async code that isn't properly awaited, so a test can complete and assert before a background continuation actually runs; and genuinely racy production code that a flaky test is correctly, if inconveniently, exposing. TimeProvider and FakeTimeProvider, part of the base class library since .NET 8, remove almost all of the wall-clock category by letting a test control time explicitly instead of sleeping and hoping:
var timeProvider = new FakeTimeProvider();
var throttle = new RequestThrottle(timeProvider, TimeSpan.FromSeconds(30));
throttle.RegisterRequest();
timeProvider.Advance(TimeSpan.FromSeconds(31));
Assert.True(throttle.IsAllowed());Blanket automatic retries hide real bugs and should be a last resort, scoped narrowly and tracked, not a default CI setting — a test that "passes on retry" against genuinely racy production code is quietly letting that race ship. Track flaky tests as a first-class metric (failure rate per test over time in CI), and treat a test that fails more than roughly one time in a few hundred runs as broken, not merely "a bit flaky."
What interviewers look for: a diagnostic process rather than "we just re-run the pipeline," and specific root-cause categories with concrete fixes, especially the distinction between a flaky test and a flaky test correctly surfacing a real race condition.
Common mistakes:
- Adding a global retry policy as the first response instead of the last resort.
- Deleting or permanently skipping a flaky test instead of quarantining it with a tracked follow-up, which silently erodes coverage over time.
Follow-up questions:
- How would you find flaky tests proactively, before they block someone's release?
- Describe a race condition a flaky test once exposed in code you shipped.
Q6 How do you design a load and performance testing strategy for a .NET service, and what's the difference between load, stress, soak and spike testing?#
Short answer: Load testing confirms the system meets its latency and throughput targets at expected traffic; stress testing pushes past that to find the actual breaking point and how the system fails; soak (endurance) testing runs a sustained, realistic load for hours to catch slow leaks and degradation that short tests never surface; and spike testing throws a sudden burst at the system to check how autoscaling and backpressure behave under a traffic shock rather than a gradual ramp. A strategy uses all four, tied to concrete service level objectives, not a single "run it under load once" check.
For .NET-native tooling, NBomber lets you define a scenario in C# and run it standalone or from inside an xUnit or NUnit suite, which keeps performance tests in the same codebase and CI pipeline as everything else instead of a separate, easily-forgotten tool:
var scenario = Scenario.Create("get_product", async context =>
{
var response = await httpClient.GetAsync("/products/42");
return response.IsSuccessStatusCode ? Response.Ok() : Response.Fail();
})
.WithLoadSimulations(Simulation.Inject(rate: 200, interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromMinutes(5)));
NBomberRunner.RegisterScenarios(scenario).Run();Beyond picking a tool, a real strategy defines pass/fail thresholds up front — p50/p95/p99 latency and error rate against a target, not just "did it finish" — runs against an environment sized like production rather than a laptop, and separates the four test types by purpose: load and spike tests run routinely (every release or on a schedule) because they are fast and cheap relative to what they catch; soak tests run less often (before a major release, or continuously in a dedicated long-running environment) because a multi-hour run is expensive to run on every commit; stress tests run deliberately, on purpose, specifically to learn where the system breaks and validate that its failure mode is graceful (shedding load, returning 503s, backing off) rather than a hard crash or cascading failure into dependencies.
What interviewers look for: correct, precise definitions of all four test types rather than treating "performance testing" as one activity, plus evidence of tying tests to explicit SLOs and failure-mode expectations instead of a vague throughput number.
Common mistakes:
- Running only a load test and calling it "performance testing," never learning where the system actually breaks or how it behaves once it does.
- Defining success as "no errors" without a latency threshold, missing the case where the system stays up but becomes too slow to be useful.
Q7 What does "testing in production" actually mean, and which techniques make it safe?#
Short answer: Testing in production means deliberately, safely validating real behavior against real traffic and real infrastructure — because no staging environment fully replicates production scale, data shape and traffic patterns — using controlled techniques such as canary releases, feature flags, shadow traffic and synthetic monitoring, not "we skipped testing and found out from customers."
Canary releases route a small percentage of real traffic to a new version behind the same load balancer, with automated rollback if error rates or latency regress, so a bad deploy affects a bounded slice of users for a bounded time instead of everyone at once. Feature flags decouple deployment from release, letting a team ship code dark, then progressively enable it for internal users, then a percentage of real users, watching metrics at each step before widening further. Shadow traffic (dark launching) mirrors real production requests to a new code path whose response is discarded, comparing its output or behavior against the live path without any user-facing risk — valuable for validating a rewritten service against real request shapes before it ever serves a real response. Synthetic monitoring runs scripted transactions against production continuously, catching regressions in critical paths (login, checkout) between real user reports. On the resilience side, chaos engineering deliberately injects failure — latency, exceptions, dependency outages — into a controlled slice of production to verify that resilience policies actually work under real conditions rather than only in a unit test; Polly has integrated Simmy's chaos engineering support directly into its core since Polly 8.3, so the same resilience pipeline a service uses for retries and circuit breakers can also inject controlled faults for exactly this purpose.
resiliencePipeline = new ResiliencePipelineBuilder()
.AddChaosLatency(new ChaosLatencyStrategyOptions
{
Latency = TimeSpan.FromSeconds(2),
InjectionRate = 0.05,
Enabled = () => ValueTask.FromResult(environment.IsStaging())
})
.Build();What interviewers look for: an understanding that "testing in production" means controlled, observable, reversible techniques rather than recklessness, and concrete mechanisms (canary, flags, shadow traffic, chaos injection) rather than a single vague gesture at "monitoring."
Common mistakes:
- Conflating "testing in production" with "no pre-release testing," rather than a deliberate additional layer on top of it.
- Running chaos experiments against real production traffic with no rollback trigger or blast-radius limit defined in advance.
Q8 What is mutation testing, and what does a tool like Stryker.NET tell you that code coverage doesn't?#
Short answer: Mutation testing measures whether your tests would actually catch a bug, not merely whether they execute a line of code: a tool such as Stryker.NET systematically introduces small, deliberate faults ("mutants") into your source — flipping a > to >=, negating a boolean, changing a constant — reruns your test suite against each mutant, and reports a mutation score based on how many mutants your tests "killed" by failing versus how many "survived" by passing unchanged, which exposes assertion gaps that 100% line coverage happily hides.
Line coverage only proves a line executed during some test; it says nothing about whether any assertion actually depended on that line's result. A test that calls a method and asserts nothing about its return value achieves full coverage of that method while catching zero regressions in it — mutation testing catches exactly this, because a mutant that changes the method's behavior survives when no assertion would have noticed. Stryker.NET is installed as a .NET global tool and run from the test project:
dotnet tool install -g dotnet-stryker
dotnet strykerIt reports mutants as killed (a test failed, proving that logic is verified), survived (every test still passed, meaning nothing actually checks that behavior), or "no coverage" (nothing even executed that code), which gives a much more honest signal than a coverage percentage alone. Mutation testing is expensive to run — it reruns the suite once per mutant, so a large codebase can take a long time — which is why teams typically run it on a schedule or scoped to recently changed files in CI, rather than on every commit for the entire codebase, and treat a low or dropping mutation score on critical logic (a pricing or authorization path) as a much stronger signal to act on than a coverage percentage dipping by a point or two.
What interviewers look for: the precise distinction between coverage (did this code run) and mutation score (would a bug here actually be caught), and awareness of the practical cost trade-off that makes mutation testing a targeted tool rather than a blanket every-commit gate.
Common mistakes:
- Treating high code coverage as proof of good tests, without checking whether the tests assert anything meaningful.
- Running mutation testing across an entire large codebase on every commit and abandoning it once it makes CI unbearably slow, instead of scoping it to changed files or critical modules.
Q9 Your team ships a critical regression despite 90% code coverage and a green pipeline. How do you diagnose the gap and fix the testing strategy, not just the bug?#
Short answer: Start by classifying exactly why the existing tests missed it — a missing case entirely, an assertion that never checked the behavior that broke, a boundary the tests never crossed (the seam between two services, a real database, a real message ordering) — because the fix for each cause is different, and only after that diagnosis does it make sense to decide whether the gap is a missing test, a missing test type, or a missing category of testing altogether.
Work through the incident like a proper postmortem rather than jumping straight to "add a test for this exact bug." First, reproduce the regression with a failing test that captures the actual defect, and confirm it would have caught the shipped bug — that closes the specific hole, but is not yet a strategy fix. Second, ask what class of bug this was: if it's a boundary bug (a contract mismatch between two services, a database-specific query translation issue, a race under real concurrency), the fix is structural — more integration tests, a Pact contract, a Testcontainers-backed test — not another unit test with the same mocked assumptions that already missed it. Third, run mutation testing against the specific module that shipped the bug; a suite with 90% line coverage but a mutation score far below that on the same module is direct evidence that coverage was measuring the wrong thing. Fourth, look at the pipeline itself: was the regression's code path actually exercised by CI at all, or only by a manual or exploratory step that isn't automated — a genuinely uncovered path is a different problem from a covered path with a weak assertion. Report back with a concrete, scoped change (a new contract test between two specific services, a mutation-testing gate on one critical module) rather than a vague commitment to "write more tests," which is how teams end up with high coverage and recurring regressions in the first place.
What interviewers look for: a structured incident-response approach to a testing gap rather than an immediate jump to "add a test," and the judgment to distinguish a one-off missing test from a systemic gap that needs a different test type or tool.
Common mistakes:
- Adding only the one missing test case and declaring the process fixed, without asking why the existing 90% coverage didn't catch a bug of this shape.
- Reacting by mandating a higher coverage percentage target, which pushes toward more low-value tests rather than better ones.
Q10 How do you decide what NOT to test, and how do you keep a fast-growing integration test suite maintainable over years?#
Short answer: Skip tests that duplicate confidence you already have cheaper elsewhere — do not unit test a trivial property getter, and do not integration-test a scenario a lower, faster layer already covers just as well — and keep a growing suite maintainable by treating test code with the same engineering discipline as production code: shared fixtures and builders instead of copy-pasted setup, clear ownership per module, and periodic pruning of tests that no longer earn their runtime cost.
The "what not to test" judgment comes down to asking what a test would actually catch that nothing else would: framework code, trivial mapping with no logic, and generated code rarely justify a dedicated test, while anything with a branch, a boundary condition, or cross-service behavior almost always does. As a suite grows past a few thousand tests, the biggest maintainability risks are duplicated setup scattered across files (fix with shared object-mother/builder helpers and fixtures), tests that assert implementation details instead of observable behavior (fix by asserting on outputs and side effects, not on which private method was called), and simply not knowing which tests are worth their running time (fix by tracking suite runtime and flakiness per test over time and periodically retiring or consolidating low-value tests, the same way you would pay down other technical debt). Ownership matters as much as technique at scale: a test suite with no clear per-module owner accumulates tests nobody feels safe deleting, which is how suites balloon to hours of CI time without a corresponding rise in real confidence.
What interviewers look for: a principled basis for skipping tests (redundant confidence, not laziness) and concrete suite-hygiene practices for the years-long maintenance problem, not just launch-day advice.
Common mistakes:
- Treating "more tests" as an unqualified good and never revisiting or pruning a suite as the codebase evolves.
- Testing implementation details so tightly that routine refactors break dozens of tests with no actual behavior change, training the team to see failing tests as noise.
Quick-Fire Round#
| Question | Answer |
|---|---|
| In the testing trophy, which layer is the largest? | Integration tests. |
| Who writes the contract in consumer-driven contract testing? | The consumer. |
| What .NET type lets a test control time deterministically? | TimeProvider / FakeTimeProvider. |
| Which test type finds the system's actual breaking point? | Stress testing. |
| Which test type runs a sustained load for hours to catch slow leaks? | Soak (endurance) testing. |
| What command installs Stryker.NET as a global tool? | dotnet tool install -g dotnet-stryker. |
| What does a "survived" mutant mean in mutation testing? | No test failed when that code was altered — the behavior is unverified. |
| Since which Polly version is Simmy's chaos engineering built in? | Polly 8.3. |
How to Prepare#
- Convert one real EF Core repository test from an in-memory provider to Testcontainers, and be ready to explain the specific bugs the switch would have caught.
- Write a consumer-side Pact test and a provider verification step against a small real API, so you can describe the workflow from firsthand experience.
- Run Stryker.NET against a module you already believe is well tested, and be ready to discuss what survived and why.
- Practice narrating a real flaky-test investigation you have done, including the specific root cause and fix, not just "we added a retry."
- Prepare one story where a green pipeline and high coverage still let a regression through, and what you changed about the testing strategy afterward, not just the code.