Concepts
What you will learn: A useful test finds a problem before users do and points toward its cause. Choose between a fast unit test, an integration test that checks connected parts, and an end-to-end test of a full journey. Then handle databases, time, and changing external systems without relying on luck.
Before you start: A test runs behavior and compares the observed result with an expected one. Know basic methods and assertions, and that good tests isolate a case and produce repeatable results. C# examples assume basic xUnit familiarity; new terms are explained before use.
Select a term with a dotted underline for its meaning, then continue reading from the same place.
#Choose tests by risk: pyramid and trophy
Tests should catch failures before users do and make their cause easy to locate. A unit test checks a small behavior without infrastructure; an integration test checks connected components; an test exercises a complete user journey.
Testing a brake alone resembles a unit test; installing it in a car resembles integration; driving the journey resembles E2E. A working part does not prove correct wiring.
| Shape | Emphasis | Useful for |
|---|---|---|
| Test pyramid | Many unit tests, fewer integration tests, few E2E tests | Domains rich in calculations and rules |
| Testing trophy | Static checks and substantial integration coverage | Applications whose main risks lie in component wiring |
These are guides rather than mandatory ratios. Test invoice discounts with fast units, persistence and payment boundaries with integration tests, and checkout with selected E2E journeys. Avoid repeating every calculation case at every level.
#Readable unit tests: AAA, FIRST and xUnit v3
A useful test explains one rule and fails clearly. means Arrange inputs, Act on the behavior, and Assert the result. FIRST means Fast, Independent, Repeatable, Self-validating and Timely. This example belongs in an xUnit v3 test project with using Xunit;.
public static class ShippingPolicy
{
public static int Fee(int total) => total >= 1000 ? 0 : 50;
}
public sealed class ShippingTests
{
[Fact] // (1)
public void Fee_AtFreeShippingThreshold_IsZero()
{
var total = 1000; // Arrange
var fee = ShippingPolicy.Fee(total); // Act
Assert.Equal(0, fee); // Assert (2)
}
[Theory] // (3)
[InlineData(999, 50)]
[InlineData(1000, 0)]
[InlineData(1001, 0)]
public void Fee_UsesThreshold(int total, int expected) =>
Assert.Equal(expected, ShippingPolicy.Fee(total));
}
- (1) A Fact describes a fixed case; its name states the scenario and outcome.
- (2) The assertion targets a public result, not a private method or implementation detail.
- (3) A Theory repeats one behavior with different data, covering below, at and above the boundary.
Several assertions can describe one logical outcome. Avoid testing trivial getters just to raise coverage or combining unrelated behaviors into one long test. Use the xUnit v3 guide to configure the appropriate runner.
#Assertions and test doubles: test the contract
- Order creation checks stock, saves an order and requests notification. A test should prove the business rule, rather than every internal implementation step.
- A stub supplies a predetermined answer, such as available stock; a fake implements simplified behavior, such as an in-memory repository; a mock verifies expected interactions under the chosen tool.
- Check observable outcomes: insufficient stock is rejected without saving an order. Coupling assertions to irrelevant internal call order can break on valid refactoring.
An assertion should detect the intended defect: a wrong total, a specific error, or a duplicate charge. A dummy fills an unused parameter; a stub returns predefined responses; a fake is a simplified working implementation; a mock verifies expected interactions; a spy records calls for later inspection. These are roles, and one library can support several.
This standalone xUnit example requires the NSubstitute and Moq packages. It shows equivalent stubs in both libraries; use one in an actual test.
using Moq;
using NSubstitute;
using Xunit;
public interface IPaymentGateway
{
bool Charge(decimal amount);
}
public sealed class Checkout(IPaymentGateway gateway)
{
public bool TryPay(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
return gateway.Charge(amount);
}
}
public sealed class GatewayTests
{
[Fact]
public void TryPay_WhenGatewayDeclines_ReturnsFalse()
{
var sub = Substitute.For<IPaymentGateway>();
sub.Charge(100m).Returns(false); // (1)
Assert.False(new Checkout(sub).TryPay(100m));
sub.Received(1).Charge(100m); // (2)
var mock = new Mock<IPaymentGateway>();
mock.Setup(x => x.Charge(100m)).Returns(false); // (3)
Assert.False(new Checkout(mock.Object).TryPay(100m));
mock.Verify(x => x.Charge(100m), Times.Once());
}
}
- (1) NSubstitute supplies a known boundary response.
- (2) Call-count verification is useful when a contract prevents duplicate payment; this checks that a gateway decline reaches the caller instead of becoming a false success.
- (3) Moq provides equivalent setup and verification.
Use real domain objects and avoid verifying internal calls that change during refactoring. Built-in assertions are sufficient; Shouldly and AwesomeAssertions offer readable alternatives. FluentAssertions v8 requires a commercial license for commercial use; review its license before upgrading, as with any dependency.
#Test time without Thread.Sleep
A booking-expiry test should not wait ten real minutes. Injecting TimeProvider separates the rule from the machine clock. This example requires xUnit and Microsoft.Extensions.TimeProvider.Testing.
using Microsoft.Extensions.Time.Testing;
using Xunit;
public sealed class Booking(TimeProvider clock, DateTimeOffset expiresAt)
{
public bool IsExpired => clock.GetUtcNow() >= expiresAt; // (1)
}
public sealed class BookingTests
{
[Fact]
public void Booking_AtExpiry_IsExpired()
{
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(start); // (2)
var booking = new Booking(clock, start.AddMinutes(10));
Assert.False(booking.IsExpired);
clock.Advance(TimeSpan.FromMinutes(10)); // (3)
Assert.True(booking.IsExpired);
}
}
- (1) Behavior reads a dependency rather than DateTime.UtcNow directly.
- (2) The test clock starts at a fixed instant.
- (3) Advancing time immediately tests the exact boundary.
| Older approach | Current approach |
|---|---|
| DateTime.UtcNow and sleeps | TimeProvider since 8 and FakeTimeProvider in tests |
Advancing the clock does not mean every background continuation has completed. Await an explicit completion signal. Measure elapsed duration with timestamp rather than an adjustable wall clock.
#Integration: an API with a real database
Testing a controller alone misses missing registrations and failed query translation. WebApplicationFactory uses TestServer without a network socket by default; Testcontainers manages a disposable database in Docker. This fragment requires xUnit v3, Microsoft.AspNetCore.Mvc.Testing 10, Testcontainers.PostgreSql, and an project reference. The API must expose public partial class Program { } and read its connection string when registering . Assume a successful /health endpoint; apply migrations in a separate setup step before data tests.
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.PostgreSql;
using Xunit;
public sealed class ApiFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer db = new PostgreSqlBuilder()
.WithImage("postgres:17").Build();
public WebApplicationFactory<Program> App { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
await db.StartAsync(); // (1)
App = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("ConnectionStrings:Default", db.GetConnectionString())); // (2)
}
public async ValueTask DisposeAsync()
{
if (App is not null) await App.DisposeAsync(); // (3)
await db.DisposeAsync();
}
}
public sealed class ApiTests(ApiFixture fixture) : IClassFixture<ApiFixture>
{
[Fact]
public async Task Health_ReturnsSuccess()
{
using var client = fixture.App.CreateClient(); // (4)
using var response = await client.GetAsync("/health");
response.EnsureSuccessStatusCode();
}
}
- (1) Start the database before the host; Docker must be available.
- (2) Configuration directs the app to the disposable database. If registration captures configuration too early, replace that registration in ConfigureTestServices.
- (3) Dispose the application before its database so background work cannot access a stopped resource.
- (4) Requests traverse and routing. A passing health test does not prove order persistence; add a POST and inspect stored data in a fresh scope.
Replace the payment boundary with a fake, but retain real authentication in security tests. TestServer does not test or the ; those need deployment tests. See the official Testcontainers example.
#Database isolation and why EF InMemory is insufficient
If a test passes alone but fails in the suite, look for shared data. A database per group with reset between cases is practical. Respawn inspects relationships and deletes data in a suitable order; initialize it after migrations and preserve migration history. This fragment assumes an open Npgsql connection to a test-only database and the Respawn package.
var reset = await Respawn.Respawner.CreateAsync(connection, new()
{
DbAdapter = Respawn.DbAdapter.Postgres,
SchemasToInclude = ["public"],
TablesToIgnore = [new Respawn.Graph.Table("__EFMigrationsHistory")]
}); // (1)
await reset.ResetAsync(connection); // (2)
// Seed only the data required by the next test.
- (1) Build the reset plan after the exists, excluding migration history.
- (2) Reset before each case while preventing parallel access to the same data.
rollback works only when all work actually participates in that transaction; an request often opens another connection. InMemory is not relational: it cannot validate translation, foreign keys or real transactions. SQLite helps in selected cases but differs from SQL Server and PostgreSQL in dialect and behavior. Test important queries on the production engine. See EF's comparison and Respawn.
#Contract and architecture tests
A service can pass every unit test and break a consumer by renaming a field. Consumer-driven contracts record the requests and responses a consumer needs; provider verification checks the actual provider against them. Pact is one option. Contracts do not replace authentication tests or complete business journeys.
Architecture tests prevent dependencies that violate architectural decisions. This xUnit example assumes a project reference to a domain assembly named Store.Domain.
using System.Reflection;
using Xunit;
public sealed class DependencyTests
{
[Fact]
public void Domain_DoesNotReferenceEfCore()
{
var domain = Assembly.Load("Store.Domain"); // (1)
var references = domain.GetReferencedAssemblies();
Assert.DoesNotContain(references,
a => a.Name?.StartsWith("Microsoft.EntityFrameworkCore") == true); // (2)
}
}
- (1) Inspect a specific assembly, not the test project's entire dependency graph.
- (2) Reject direct references. This does not detect every transitive dependency; tools such as ArchUnitNET support broader rules.
Snapshot tests help protect stable shapes, but changes need review and volatile IDs and timestamps need normalization. Automatically accepting new snapshots removes their value.
#TDD, BDD, coverage and mutation testing
When a discount rule is unclear, write an expected example first. is a short cycle: Red, confirm failure for the intended reason; Green, implement the minimum correct behavior; Refactor, improve design while tests remain green. starts with collaborative examples and business discussion, sometimes expressed as Given, When and Then; it is more than a tool or syntax.
- Write a case at the free-shipping threshold.
- Run it before implementation to prove it detects missing behavior.
- Implement it, add a below-threshold case, then improve the design.
Line coverage records executed code; branch coverage records decision paths. Neither measures assertion strength. Stryker.NET deliberately changes code, such as replacing >= with >; the boundary test should fail and kill that mutant.
#Flaky tests, load and concurrency
A flaky test changes outcome without an intentional code change. Common causes include real time, unrecorded random seeds, shared state, networks and assumed execution order. Use controlled clocks, isolated data and bounded polling for explicit conditions rather than arbitrary sleeps. Temporary quarantine needs an owner and repair deadline; retries alone hide the problem.
k6 defines load scenarios in JavaScript; NBomber supports scenarios. Specify a realistic workload, arrival rate, duration and warm-up, then monitor , errors, and p99 alongside and database waits. Use a dedicated environment; a load test is not a focused race-condition test.
- To test the last item, seed stock = 1 in a real database.
- Use an independent per buyer and coordinate starts with a signal or suitable barrier.
- Run both attempts and assert exactly one success, nonnegative stock and an expected loser outcome.
- Repeat with varied scheduling and test retries and . Repetition increases confidence but cannot prove every interleaving.
Interview questions
No results
Check your reasoning
Why does this choice fit? When would it fail? What changes under higher load or a dependency failure?
21.1How do unit, integration and E2E tests differ?Junior
I distinguish them by the boundary they exercise. A unit test checks a small behavior without infrastructure, an integration test checks components working together, and an test exercises a complete user journey. I use units for calculation rules, integration tests for persistence and wiring, and selected E2E tests for checkout. I choose the cheapest level that can actually detect the failure rather than copying every scenario into all three layers.
21.2What makes a good unit test?Junior
I want a test to explain one behavior, run quickly and produce a useful failure. I arrange explicit inputs, perform one meaningful action and assert its observable result. I keep it independent of other tests and control time and randomness. Multiple assertions are fine when they describe one outcome. I prefer behavior-based names and avoid private-method tests because refactoring should not break a test when the public behavior remains correct.
21.3When do you use Fact and Theory in xUnit?Junior
I use a Fact for a fixed scenario and a Theory when the same rule needs several input and expected-output pairs. For a shipping threshold, a Theory can cover values below, at and above the boundary. I keep the data small enough to understand and separate cases with different setup or meaning. Parameterization reduces duplication, but I do not turn unrelated behaviors into one large conditional test just to reuse a method.
21.4How do xUnit fixtures work?Mid
I use a class fixture to share expensive setup across one test class and a collection fixture when several classes need the same resource. xUnit still creates a new test-class instance for each case. In v3, async lifetime methods use ValueTask. I keep fixture lifetime separate from data : sharing a database container does not mean tests can share rows safely. I reset or partition data and avoid relying on test execution order.
21.5What are the different kinds of test doubles?Mid
I use dummy values only to fill unused parameters, stubs to return predefined answers, and fakes as simplified working implementations. A mock verifies an expected interaction, while a spy records calls for later inspection. NSubstitute and Moq can support several of these roles. I choose a double based on the behavior I need to control or observe, and I avoid verifying incidental internal calls that would make harmless refactoring expensive.
21.6What should you mock?Mid
I usually replace nondeterministic or external boundaries, such as payment providers, email, time and remote . I keep domain objects real because their rules are what I want to exercise. For database queries, I prefer integration tests against the actual engine rather than mocking DbSet query behavior. If an application abstraction returns query results, I may stub that abstraction for a unit test, while separately testing its real database implementation.
21.7How do you test time-dependent behavior?Junior
I inject TimeProvider instead of reading the machine clock directly. Tests use FakeTimeProvider with a fixed starting instant and advance it to the boundary being checked. That makes expiry tests fast and repeatable. I test before, at and after expiration, and distinguish elapsed duration from calendar time. For background timers, I also await an explicit completion signal because moving the fake clock does not guarantee every continuation has already finished.
21.8How would you integration-test an ASP.NET Core API?Mid
I host the with WebApplicationFactory, start a disposable database through Testcontainers, and configure the application to use it before the host starts. I apply migrations and seed only the scenario's data. Requests then exercise , routing, validation and persistence together. I replace external payment calls with a fake and inspect stored state through a fresh scope. I also run deployment checks separately because the default TestServer does not validate real or proxy configuration.
21.9Why avoid EF Core InMemory for database tests?Mid
I do not use InMemory to prove database correctness because it is not a relational engine. Passing tests cannot establish correct translation, foreign-key behavior or . SQLite is relational but still differs from SQL Server or PostgreSQL in dialect, types and comparison behavior. I test important queries and concurrency rules against the production database engine in disposable infrastructure, and use stubs above data access when a focused unit test needs predefined results.
21.10How do you isolate integration-test data?Senior
I choose explicitly: a database per test group, unique data partitions, or a reset tool such as Respawn with serialized access to that database. I initialize reset metadata after migrations and preserve migration history. A rollback is useful only if all application work participates in that transaction; an request often uses another connection. I also account for background workers, because resetting rows while a worker still processes them can create intermittent failures.
21.11What is TDD, and where is it useful?Mid
I use as a short feedback cycle: write a failing test, confirm the intended failure, add the minimum correct implementation, then refactor while tests remain green. It is especially useful for business rules with clear examples and boundary conditions. It encourages small interfaces and immediate feedback. I still need integration tests for infrastructure behavior, and I may first explore an unfamiliar before applying the cycle to the final implementation.
21.12Is 100 percent code coverage enough?Junior
I treat coverage as a map of code that tests execute, not proof that the behavior is correct. A test can execute a line without asserting anything useful. Branch coverage adds information about decision paths, but it still misses weak expectations and missing requirements. I prioritize critical business rules, failure cases and boundaries, review uncovered areas, and use mutation testing selectively to see whether realistic changes would actually make tests fail.
21.13What does mutation testing tell you?Senior
I use mutation testing to challenge the assertions in a suite. A tool such as Stryker.NET makes small code changes and reruns tests; a failing test kills the mutant. Survivors can reveal missing boundary cases or weak assertions, but some mutations are behaviorally equivalent. I inspect survivors rather than enforcing a perfect score. Because the process is expensive, I start with critical calculations and run a broader campaign on an appropriate schedule.
21.14How do contract tests differ from E2E tests?Senior
I use contract tests to verify an agreement at a service boundary, including the requests a consumer sends and the responses it requires. Provider verification checks the actual provider against that agreement. This gives faster, more focused compatibility feedback than a full environment. It does not prove the entire business workflow, authorization configuration or network routing. I retain selected tests for those interactions and avoid treating a matching as complete semantic compatibility.
21.15How would you test two buyers competing for the last item?Senior
I seed one item in the real database engine and run two purchase attempts with independent contexts and . I coordinate their execution so they can reach the contested operation together, then assert one successful reservation, an expected failure for the other and nonnegative persisted stock. I also test retry and behavior. Repeated runs help expose scheduling problems, but the underlying atomic update or concurrency constraint must enforce the in production.
21.16How do you investigate flaky tests?Mid
I preserve the failing logs, random seed and environment details, then compare isolated runs with full-suite and parallel runs. I look for shared data, uncontrolled clocks, incomplete async work, port conflicts and assumptions about ordering. I replace arbitrary sleeps with bounded waits for explicit conditions and give every resource a clear owner. If I quarantine a test temporarily, I assign an owner and deadline rather than allowing retries to turn an unreliable signal permanently green.
21.17How do you design a useful load test?Senior
I start with a capacity question and a representative workload, then choose an arrival model, request mix, duration and warm-up period. I can implement that with k6 or NBomber. I measure , errors and tail alongside application, database and infrastructure metrics. I also verify that the generator is not the bottleneck. A useful result identifies the saturation point and failure behavior; it does not merely report how many requests a synthetic endpoint completed.
21.18How do you choose an assertion library today?Mid
I start with the framework's built-in assertions and add a library when its diagnostics and readability justify the dependency. I review maintenance, compatibility and licensing before upgrading. FluentAssertions v8 requires a commercial license for commercial use, while Shouldly and AwesomeAssertions are alternatives worth evaluating. Whatever the syntax, I care more about precise expectations and helpful failure messages than fluent chaining, and I avoid asserting entire objects when only a small contractual outcome matters.
Test yourself
Quiz answers and completed-topic marks are saved in this browser; they do not sync across devices.
Which test best validates PostgreSQL query translation?
Which xUnit abstraction shares setup across several test classes?
A line is covered but changing its result does not fail tests. What is likely missing?
How should an expiry test move time forward?
Which double returns predefined responses without a working implementation?
Why can transaction rollback fail to isolate an HTTP integration test?
What is the first step in a TDD cycle?
Two concurrent purchases are tested against one shared DbContext. What should change?
What does a passing consumer-driven contract primarily establish?
A test fails only in parallel runs. Best first investigation?