Dinesh/ Blog
application engineering

MS Stack Ch 21 — Testing strategy

Unit, integration, end-to-end, contract, mutation, load — the testing pyramid for ASP.NET Core + React on Azure. xUnit, WebApplicationFactory, Vitest, Playwright, k6, Stryker, Pact.

30 min readChecking device speech…
Lesson preparation & details

Level: intermediate

By the end, you should be able to

  • Choose unit, integration, UI, contract, mutation and load tests for the actual claim
  • Write expiry-boundary and negative authorization tests without overclaiming mocks
  • Explain fixture isolation, test-library versions and real-provider coverage

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 28 sections

Review and execution boundary

ASP.NET Core 8 WebApplicationFactory and xUnit v2-style examples; frontend examples use the Vitest/RTL and MSW 2 API families. Pin compatible package versions, a DOM environment and the application-specific types before running them. These framework examples are reference fragments, not installed or executed applications in this review.

The 9 October 2026 body review freshly read all current teaching and reused the hash-verified a72 original audit/recovery; it was not a fresh whole-original read. The exact JavaScript expiry function below was locally executed with boundary and invalid-input tests. Other bounded fixtures are explicitly teaching models, not execution of C#, React, Playwright journeys, Pact, Stryker or k6. Scoped MDX checks and saved primary-source quotations are recorded separately. No cloud writes, credentials, .NET compilation, deployment, service reproduction or site approval occurred. Learner commands require their own authorized environment.

Chapter 21 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

Tests are how you ship without fear. Not because every line of code is covered, but because the right test, at the right level, written while the design is fresh, catches the regressions you would otherwise discover at 2am from a customer ticket. The skill is not "write more tests"; it is "write the test that matters, at the level it matters, with the brittleness budget the team can carry".

The shipping-grade version of this skill is "I can write a unit test, an integration test, a frontend component test, and an end-to-end test, and I know which to reach for in each scenario". The expert-tier version is "I shape the test pyramid for a service so that the slow-and-flaky tests are few, the fast-and-stable ones are many, and I set and measure a feedback-time budget for PRs (five minutes is an illustrative target, not a promised result)".

Applying one test style everywhere can leave expensive broad tests or mock-heavy blind spots. A 40-minute CI run and a green build that misses a wiring defect are illustrative failure scenarios, not measured outcomes for most engineers. The fix is to internalise the pyramid and choose deliberately.

You finish this chapter when you can sit down with a new feature and immediately split it: this fact is a unit test, this interaction is an integration test, this regression is an end-to-end test, and you know why for each.

.NET unit testing — xUnit / NUnit / MSTest; Fact vs Theory; FluentAssertions; NSubstitute / Moq.

ASP.NET integration — WebApplicationFactory, in-memory test server, overriding services.

Async + time — Async test patterns; the time-based-test trap and how to escape it.

Frontend tests — Vitest / Jest, React Testing Library; behaviour, not implementation.

End-to-end — Playwright awareness; when and why to invest the brittleness budget.

The pyramid — Many unit, fewer integration, fewer still e2e — and why the inversion fails.

Concepts and depth

Unit testing in .NET

The three frameworks you will meet are xUnit, NUnit, and MSTest. All can express ordinary unit tests, but lifecycle, discovery, parallelism and extension contracts differ. Prefer the framework already supported by the project unless a concrete requirement justifies switching; this chapter uses xUnit, not a market-share or quality ranking.

The lifecycle differences matter for setup/teardown semantics. xUnit creates a new test class instance per test method (use the constructor for setup, IDisposable or IAsyncLifetime for teardown). NUnit uses class instances per fixture by default with explicit [SetUp]/[TearDown] attributes. MSTest normally creates a test-class instance for each test; its TestInitialize/TestCleanup hooks are not evidence of a shared NUnit-style fixture lifetime. Pin the framework version and deliberately scope shared fixtures. Per-test instances isolate instance fields, but static state and class/collection fixtures can still leak state between tests. These C# examples target .NET 8 and xUnit v2-style Fact/Theory/fixture APIs; xUnit v3 has lifecycle and async differences.

[Fact] vs [Theory] is the key xUnit distinction. A [Fact] is a test with no parameters — one invocation. A [Theory] is a parameterised test: you supply data via [InlineData(...)], [MemberData(nameof(Foo))], or [ClassData(typeof(Bar))], and xUnit invokes the method once per data row. Theories are the right tool for "the same logic across many inputs"; collapsing them into a foreach inside a [Fact] loses the per-input failure attribution.

public class Calculator
{
    public int Add(int a, int b) => unchecked(a + b);
}
 
public class CalculatorTests
{
    private readonly Calculator _sut = new();
 
    [Fact]
    public void Add_returns_zero_for_zeros()
    {
        _sut.Add(0, 0).Should().Be(0);
    }
 
    [Theory]
    [InlineData(1, 2, 3)]
    [InlineData(-1, 1, 0)]
    [InlineData(int.MaxValue, 1, int.MinValue)]  // only for an explicitly unchecked Add contract
    public void Add_returns_sum(int a, int b, int expected)
    {
        _sut.Add(a, b).Should().Be(expected);
    }
}

FluentAssertions is one expressive assertion library, not a license-free default for every project. Version 8 and later require a paid license for commercial use (the maintainers provide free use for eligible open-source/non-commercial cases); review the exact version/license or use framework assertions/an approved alternative. Some teams prefer actual.Should().Be(expected) to Assert.Equal(expected, actual) and its chainable matchers (Should().BeEquivalentTo, Should().ContainSingle(x => ...)). Evaluate diagnostics, maintainability and licensing rather than assuming a universal readability benefit.

Mocking — when your unit under test depends on an interface you do not want to instantiate (DB context, HTTP client, time provider), use NSubstitute or Moq. This example chooses NSubstitute's interface-substitution syntax, not a ranking of the two:

[Fact]
public async Task Sends_welcome_email_to_new_user()
{
    var email = Substitute.For<IEmailSender>();
    var sut = new UserService(email);
 
    await sut.RegisterAsync(new User { Email = "a@b.com" });
 
    await email.Received(1).SendAsync(
        Arg.Is<EmailMessage>(m => m.To == "a@b.com" && m.Subject.Contains("Welcome")));
}

The mocking-vs-real tradeoff is a recurring testing decision. Rule of thumb: mock at process boundaries (network, disk, time), use real instances of in-process collaborators (a real List<T>, a real validator). Over-mocking creates tests that pass on changes that break production; under-mocking creates tests that are integration tests in unit-test clothing.

Good enough to ship

• Choose xUnit/framework assertions or an approved assertion-library version; review its license • [Theory] for independent input cases; loops remain useful for a single invariant/property test • Mock at process boundaries; real instances for in-process collaborators • Arrange/Act/Assert structure visible in every test

Expert tier

• Test naming convention enforced (Method_state_expected) • Snapshot tests via Verify for object-shape regressions • Mutation testing (Stryker) on critical modules • Property-based testing via FsCheck for invariant-heavy code

Integration testing for ASP.NET Core

ASP.NET Core ships with WebApplicationFactory<TEntryPoint>, an in-memory host that runs your actual application — middleware, DI, routing, controllers — without binding to a network port. Tests against the default TestServer exercise the application HTTP pipeline minus a real network listener. That is valuable for wiring contracts, not a universal ranking of test value.

public class WeatherApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
 
    public WeatherApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(b =>
        {
            b.ConfigureServices(services =>
            {
                // Replace the real weather provider with a stub
                services.RemoveAll<IWeatherProvider>();
                services.AddSingleton<IWeatherProvider, StubWeatherProvider>();
            });
        }).CreateClient();
    }
 
    [Fact]
    public async Task Returns_forecast_for_known_city()
    {
        var response = await _client.GetAsync("/weather/london");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var body = await response.Content.ReadFromJsonAsync<Forecast>();
        body!.City.Should().Be("london");
    }
}

The pattern's power is service overrides. The factory's WithWebHostBuilder lets you replace any DI registration before the test runs. Typical overrides: replace the real database with a suitable database test double or with a containerised real DB (via Testcontainers), replace external HTTP clients with stubs, replace the auth scheme with a test handler that supplies chosen claims. That need not mint or validate any JWT.

For database-backed integration tests, EF Core InMemory omits relational/provider behavior and is discouraged by EF documentation as a general database fake. Testcontainers can manage a real database container; per-test, class or collection lifetime is your fixture decision, not an automatic per-class guarantee. Match the production engine/version and relevant settings, define reset/isolation, and measure startup cost. Even a real engine in a container does not reproduce the production topology or load.

A useful baseline is a happy path through each endpoint. It can catch wiring, serialization and DI regressions on the exercised branch; one 200 response does not test every route constraint, policy, error response or dependency failure. Add cases according to risk and inspect both status and response content.

Good enough to ship

• WebApplicationFactory tests for every endpoint's happy path • Service overrides for external dependencies • EF InMemory only when non-relational behavior is sufficient; prefer a real engine for relational contracts • Test auth handler with chosen principals plus unauthenticated/forbidden/cross-tenant cases

Expert tier

• Testcontainers for provider-faithful DB tests • Contract tests (Pact) for service-to-service interactions • Snapshot tests for response shapes • Concurrent-request tests for race-condition surfaces

Async test patterns and time-based test pitfalls

Async tests in xUnit are first-class: return Task from the test method and the runner awaits it. The gotcha is async void — never declare a test (or any application code outside event handlers) as async void. xUnit v2 had special async-void support, while v3 removes it. Other runners behave differently; Task-returning tests provide a portable awaitable completion/error contract. Do not infer that all historical xUnit async-void tests silently passed.

[Fact]
public async Task Fetches_user()  // ← Task, not void
{
    var user = await _sut.GetAsync(42);
    user.Should().NotBeNull();
}

Time-based tests are one source of flakiness. Tests that depend on wall-clock timing or scheduling can race: the clock can move between two lines, the scheduler can introduce a delay, parallel tests can compete for CPU. The fix is the abstract time pattern: inject TimeProvider (built into .NET 8+) and use FakeTimeProvider in tests.

public class TokenService
{
    private readonly TimeProvider _time;
    public TokenService(TimeProvider time) { _time = time; }
 
    public bool IsExpired(Token t) => t.Expires <= _time.GetUtcNow();
}
 
// Test
[Fact]
public void Token_is_expired_after_expiry()
{
    var fake = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
    var sut = new TokenService(fake);
    var token = new Token { Expires = fake.GetUtcNow().AddMinutes(5) };
 
    sut.IsExpired(token).Should().BeFalse();
    fake.Advance(TimeSpan.FromMinutes(5)); // expiry boundary is exclusive: now == exp is expired
    sut.IsExpired(token).Should().BeTrue();
}

The same principle applies to Task.Delay — use the .NET 8 Task.Delay(delay, timeProvider, cancellationToken) overload or a deliberately injected timer/delay abstraction. FakeTimeProvider is supplied by Microsoft.Extensions.TimeProvider.Testing, not by instantiating TimeProvider itself. A fixed real sleep does not establish that an operation finished; it may be unnecessarily slow or still race under contention. Await completion or a bounded observable condition. A delay using a fake provider is legitimate when the timer has been registered before advancing time.

Parallelism: xUnit runs test classes in parallel by default but methods within a class sequentially. Classes in the same named collection do not run concurrently with one another by default; methods in a class already share a collection. For broader isolation use a CollectionDefinition with DisableParallelization or assembly/runner settings. [Collection] by itself is not a universal “disable parallelism” switch.

Good enough to ship

• Use Task-returning tests for this xUnit v2 contract; avoid async void tests (event handlers are a separate language use case) • TimeProvider injected; FakeTimeProvider in tests • No real-time sleep used as a completion assertion; provider-backed timers may be tested deliberately

Expert tier

• Flaky-test dashboard tracks each test's pass rate • A quarantine policy names an owner, repair deadline and substitute coverage; choose a trigger based on risk, not an unexplained three-failure rule • Deterministic random via seeded Random injected

Frontend testing: Vitest, Jest, React Testing Library

For React, Vitest integrates with Vite; Jest is another established runner. Familiar describe/it/expect syntax does not imply identical mocking, module or configuration behavior, nor a universal speed advantage. React Testing Library (RTL) provides rendering and query primitives. In Vitest, configure jsdom (install its compatible package) or a supported browser setup: the default Node environment does not provide a DOM.

The animating principle of RTL is "test behaviour, not implementation". Queries find elements by what a user sees (getByRole('button', { name: /submit/i })) not by CSS class or test-id (use test-id only as a last resort). The test exercises the component the way a user would; it survives refactors that change internal structure but not user-visible behaviour.

import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { LoginForm } from './LoginForm';
 
afterEach(() => cleanup());
 
describe('LoginForm', () => {
  it('disables submit until both fields are filled', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={() => {}} />);
 
    const submit = screen.getByRole('button', { name: /sign in/i });
    expect(submit).toBeDisabled();
 
    await user.type(screen.getByLabelText(/email/i), 'a@b.com');
    expect(submit).toBeDisabled();
 
    await user.type(screen.getByLabelText(/password/i), 'hunter2');
    expect(submit).toBeEnabled();
  });
 
  it('calls onSubmit with the entered credentials', async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();
    render(<LoginForm onSubmit={onSubmit} />);
 
    await user.type(screen.getByLabelText(/email/i), 'a@b.com');
    await user.type(screen.getByLabelText(/password/i), 'hunter2');
    await user.click(screen.getByRole('button', { name: /sign in/i }));
 
    expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.com', password: 'hunter2' });
  });
});

What the test does not do: peek at component state, snapshot the rendered HTML, assert on internal function calls. Those checks can couple a test to implementation; use them only when that internal shape is an intentional contract. Behavior-based assertions are usually less coupled to implementation; they still require maintenance when the intended behavior changes. The LoginForm example assumes initially empty fields, a disabled button until both are filled, associated labels, and an onSubmit prop called with the displayed object. That prop is a public contract, not an internal function spy.

MSW is one option for API mocking. In browsers it uses a Service Worker; the msw/node setup below intercepts supported request APIs in the Node process, not a real network server or browser Service Worker. It lets application request code run against handlers instead of replacing the component's fetch call directly. Neither mode proves the remote API or wire-level transport.

Snapshot tests are the controversial third leg. They are cheap to write (expect(container).toMatchSnapshot()) and catch unintended UI changes, but they decay into noise if not maintained — devs --update-snapshots without reading the diff. Use them sparingly, for stable shapes like API response envelopes or component prop defaults.

Good enough to ship

• Vitest + RTL + userEvent + MSW as the standard kit • Queries by role / label; test-id only as last resort • Behaviour-based assertions; no snapshot spam

Expert tier

• Accessibility audits via axe-core in tests • Visual regression via Playwright + Percy • Component-isolation tests in Storybook

End-to-end (Playwright)

Playwright supports Chromium, Firefox and WebKit, awaited locator assertions, TypeScript, parallel execution and configured video/trace capture. Selenium is an alternative with a different architecture; tool preference is not evidence of test quality.

End-to-end tests run the whole stack — real browser, real frontend, real backend (against a staging env or a docker-compose) — and assert on user-visible behaviour. They can expose browser navigation, deployed integration and visibility failures missed by narrower suites. Contract or integration tests can also detect API drift; a full-stack journey is complementary, not the only possible detector. They often cost more to set up and diagnose, but relative speed and flakiness depend on the suite, environment and isolation.

import { test, expect } from '@playwright/test';
 
test('user can sign in and see dashboard', async ({ page }) => {
  await page.goto('https://staging.example.com');
  await page.getByRole('link', { name: /sign in/i }).click();
  await page.getByLabel(/email/i).fill(process.env.TEST_USER!);
  await page.getByLabel(/password/i).fill(process.env.TEST_PASS!);
  await page.getByRole('button', { name: /sign in/i }).click();
 
  await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});

The brittleness budget for e2e is finite. Keep the suite to the critical user journeys: sign-in, the core action of the product, sign-out, the few paths that have caused incidents in the past. Suite size alone predicts neither flakiness nor trust. Deterministic test data, isolated contexts, explicit contracts and failure triage matter more than a magic count; measure duration and failure rate before allocating the e2e budget.

Run e2e in a dedicated stage post-deploy to staging (chapter 18). Failure halts the rollout to prod. This is the right place — exercising the real deployed system, not a unit test in CI.

Good enough to ship

• Playwright suite selected by risk; 5–10 journeys is only an illustrative starting scope • Runs against staging post-deploy, gates promotion to prod • Configure failure video/trace retention with restricted access and secret/PII review

Expert tier

• Multi-browser matrix (Chromium + Firefox + WebKit) • Measure whether parallel sharding meets the chosen runtime budget • Flaky-test triage rota; failures investigated within 24h

The test pyramid mental model

The pyramid is a heuristic for distribution:

  • Base (often most tests): unit tests. Aim for fast, isolated checks of a function/class contract.
  • Middle: integration tests. Check real wiring, provider behavior or HTTP-pipeline contracts with controlled state.
  • Apex: end-to-end tests. Check selected whole-system user journeys.

Milliseconds, 100ms and seconds in the comparison cards below are illustrative scale labels, not measured ranges or guaranteed determinism.

The shape matters because cost grows up the pyramid: a 5-second e2e test is 1000× a 5ms unit test. A suite dominated by expensive broad tests can have slow feedback and ambiguous failures. Focused tests often narrow the failing contract, but neither shape nor level alone proves speed, determinism or the root cause.

The mental model is "shift left". Earlier detection can reduce downstream disruption, but fix cost also depends on the defect and system. As an illustrative planning scenario, a local failure might take minutes while a production incident can consume hours and affect customers. These are not empirical cost estimates; measure your team's detection and recovery times. Push tests down the pyramid where you can.

Counter-balance: not every test belongs at the unit level. A wiring defect can escape isolated unit tests; an integration test catches it only if the affected interaction is exercised. A deployment-specific defect can escape an in-process suite; an e2e case still needs the relevant path, data and assertion. The right level is the lowest one at which the bug is observable.

name: Unit; tagline: many · ms; bullets: One function or class — Mock at boundaries — Sub-second per test

name: Integration; tagline: fewer · 100ms; bullets: Wiring across modules — Real provider for provider claims; explicit fake limits — WebApplicationFactory

name: End-to-end; tagline: few · seconds; bullets: Real browser, real backend — Critical journeys only — Run against staging

title: A common testing trap

"100% code coverage" is not a goal. A test that exercises a line without asserting anything meaningful contributes coverage without necessarily checking the required result. Track coverage as a trend — sudden drops warrant explanation — not as a threshold to game. Mutation testing (Stryker) asks a complementary question: which selected code mutations cause tests to fail? Surviving, uncovered, timed-out and equivalent mutants need interpretation; neither coverage nor mutation score is a certificate.

Worked examples

A unit test with the Arrange-Act-Assert structure

public class PricingServiceTests
{
    public static IEnumerable<object[]> DiscountCases => new[]
    {
        new object[] { 100m, 0.10m, 90m },
        new object[] { 50m, 0m, 50m },
        new object[] { 200m, 0.25m, 150m }
    };
 
    [Theory]
    [MemberData(nameof(DiscountCases))]
    public void Apply_discount_returns_reduced_price(decimal price, decimal discount, decimal expected)
    {
        // Arrange
        var sut = new PricingService();
 
        // Act
        var result = sut.ApplyDiscount(price, discount);
 
        // Assert
        result.Should().Be(expected);
    }
 
    [Fact]
    public void Apply_discount_throws_on_negative_discount()
    {
        var sut = new PricingService();
 
        Action act = () => sut.ApplyDiscount(100m, -0.1m);
 
        act.Should().Throw<ArgumentOutOfRangeException>()
           .WithParameterName("discount");
    }
}

What to notice:

  • AAA structure is visible at a glance.
  • [Theory] with decimal-valued [MemberData] parameterises the happy paths without relying on attribute numeric conversion.
  • Exception assertion uses Action act = () => ... to capture and assert on the throw.
  • WithParameterName validates the specific parameter name in the exception (catches misnamed params).
  • The omitted PricingService must implement the chosen contract: nonnegative price, discount in [0, 1], decimal price * (1 - discount), and an explicit currency rounding rule where needed. Add greater-than-one discount, negative price and rounding cases; these three examples do not prove that full contract.

A WebApplicationFactory integration test with auth + DB override

// .NET 8; app-specific AppDbContext/OrderResponse types remain integration seams.
// NuGet: Microsoft.AspNetCore.Mvc.Testing, EF InMemory, xUnit v2.
public class OrdersApiTests : IClassFixture<TestWebAppFactory>
{
    private readonly TestWebAppFactory _factory;
    public OrdersApiTests(TestWebAppFactory factory) => _factory = factory;
 
    [Fact]
    public async Task Authenticated_user_can_create_order()
    {
        using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
        { AllowAutoRedirect = false });
        client.DefaultRequestHeaders.Add("X-Test-User", "alice");
        var response = await client.PostAsJsonAsync("/orders", new { sku = "WIDGET-1", qty = 2 });
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        var body = await response.Content.ReadFromJsonAsync<OrderResponse>();
        Assert.Equal("alice", body!.OwnerId);
    }
}
 
public class TestWebAppFactory : WebApplicationFactory<Program>
{
    private readonly string databaseName = "test-" + Guid.NewGuid();
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            // Also remove provider-specific registrations if the app creates them.
            services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase(databaseName));
            services.AddAuthentication(o =>
            {
                o.DefaultAuthenticateScheme = "Test";
                o.DefaultChallengeScheme = "Test";
            }).AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
        });
    }
}
 
// Test project only. Never register this header-based handler in production.
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { }
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-Test-User", out var user) ||
            user.Count != 1 || string.IsNullOrWhiteSpace(user[0]))
            return Task.FromResult(AuthenticateResult.NoResult());
        var identity = new ClaimsIdentity(new[]
        { new Claim(ClaimTypes.NameIdentifier, user[0]!) }, Scheme.Name);
        return Task.FromResult(AuthenticateResult.Success(
            new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
    }
}

Imports include System, System.Net, System.Net.Http.Json, System.Security.Claims, System.Text.Encodings.Web, Microsoft.AspNetCore.Authentication, Microsoft.AspNetCore.Hosting, Microsoft.AspNetCore.Mvc.Testing, Microsoft.AspNetCore.TestHost, Microsoft.EntityFrameworkCore, Microsoft.Extensions.DependencyInjection, Microsoft.Extensions.DependencyInjection.Extensions, Microsoft.Extensions.Logging, Microsoft.Extensions.Options, System.Threading.Tasks and Xunit. Make the application's Program entry point accessible to the test assembly. If a production policy explicitly names Bearer, override that named scheme/policy intentionally; changing the default scheme alone does not bypass it.

What to notice:

  • IClassFixture shares the factory across tests; each test above creates/disposes its own client. Instance sharing is not database isolation.
  • DB name is stable per factory; tests sharing that factory share its database unless reset. This provider does not validate relational constraints or production transaction behavior.
  • TestAuthHandler supplies claims from a test-only header; it neither issues nor validates a bearer token. It supplies only NameIdentifier: add a validated test-only tenant claim matching the application policy before attempting the cross-tenant exercises; the shown handler alone cannot represent tenant isolation.
  • The test exercises routing/controller/persistence and a test authentication path, not real JWT cryptography or the production database engine.

A React Testing Library behaviour test

import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { UserProfile } from './UserProfile';
 
const server = setupServer(
  http.get('/api/me', () => HttpResponse.json({ name: 'Alice', email: 'a@b.com' })),
);
 
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { cleanup(); server.resetHandlers(); });
afterAll(() => server.close());
 
describe('UserProfile', () => {
  it('shows the user name after load', async () => {
    render(<UserProfile />);
 
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
 
    await waitFor(() => {
      expect(screen.getByRole('heading', { name: /alice/i })).toBeInTheDocument();
    });
  });
 
  it('shows an error if the API fails', async () => {
    server.use(http.get('/api/me', () => new HttpResponse(null, { status: 500 })));
 
    render(<UserProfile />);
 
    await waitFor(() => {
      expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
    });
  });
});

Setup contract: use compatible MSW 2/RTL/user-event packages and a Vitest DOM environment with a deliberate base URL. The application must request the same resolved /api/me URL the handler matches; native Node fetch may require an absolute URL. UserProfile is an omitted component whose contract here is immediate loading text, then an accessible Alice heading on 200 or error text on 500. If loading is intentionally not observable, change that assertion to match the actual UI contract rather than adding a sleep.

What to notice:

  • MSW Node request interception preserves the component request path, but it is not a remote service or Service Worker test.
  • waitFor handles the async resolution; no manual sleep.
  • Per-test handler override (server.use(...)) tests the error path without affecting other tests.
  • Queries by role and name; no peek at the implementation.

A Playwright critical-journey test

import { test, expect } from '@playwright/test';
 
test.describe('Sign-in journey', () => {
  test('user signs in and lands on dashboard', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: /sign in/i }).click();
 
    await page.getByLabel(/email/i).fill(process.env.E2E_USER!);
    await page.getByLabel(/password/i).fill(process.env.E2E_PASS!);
    await page.getByRole('button', { name: /sign in/i }).click();
 
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByRole('heading', { name: /your activity/i })).toBeVisible();
  });
 
  test('invalid password shows error', async ({ page }) => {
    await page.goto('/signin');
    await page.getByLabel(/email/i).fill(process.env.E2E_USER!);
    await page.getByLabel(/password/i).fill('wrong-password');
    await page.getByRole('button', { name: /sign in/i }).click();
 
    await expect(page.getByText(/invalid credentials/i)).toBeVisible();
  });
});

What to notice:

  • One describe per user journey; default per-test browser contexts isolate cookies/storage, not server-side accounts or database records. Give concurrently running tests isolated server data or an explicit reset policy.
  • The environment names are placeholders for a runner's protected credential injection; ! is only a TypeScript assertion and does not validate missing values. Use dedicated test accounts, never real user secrets, and do not expose them in traces. The example has not logged into staging.
  • Auto-waiting via expect(...).toBeVisible() — no manual sleeps.
  • Failure modes: invalid password is the negative test; both are critical for the journey.

Hands-on exercises

  1. Goal: Convert a [Fact] foreach into a [Theory]. Steps: (1) Find a test that loops over inputs inside one [Fact]. (2) Rewrite as [Theory] with [InlineData] per input. (3) Run; verify failure attribution per row. You're done when a deliberate failure shows the exact row that failed.

  2. Goal: Write a WebApplicationFactory integration test. Steps: (1) Pick an endpoint. (2) Create a factory, override the database to in-memory. (3) Write a happy-path test that posts data and asserts on the response. (4) Add a test-only auth handler and negative cases. You're done when routing and authorization for the chosen claims are exercised; real token verification remains a separate suite.

  3. Goal: Replace DateTime.UtcNow with TimeProvider in one class. Steps: (1) Inject TimeProvider via constructor; use it everywhere previously calling DateTime.UtcNow. (2) In production wire TimeProvider.System. (3) In tests use FakeTimeProvider. (4) Write a test that advances time and asserts on time-dependent behaviour. You're done when the time-based test is fully deterministic.

  4. Goal: Write a React Testing Library test with MSW. Steps: (1) Pick a component that calls an API. (2) Set up MSW with a handler returning a fixture. (3) Render the component, assert on the rendered output. (4) Add a test for the error path using server.use(...). You're done when both paths are tested without mocking fetch directly.

  5. Goal: Write a Playwright test for one critical user journey. Steps: (1) Pick the most important journey in your app. (2) Configure Playwright to point at staging (or a local docker-compose). (3) Write the test: navigation, input, assertion. (4) Run; iterate until stable. (5) Wire it into the pipeline post-deploy. You're done when the journey is gated by the test.

  6. Goal: Add mutation testing to one critical module. Steps: (1) Install Stryker (dotnet tool install -g dotnet-stryker). (2) Run against one project; observe the mutation score. (3) Inspect surviving, equivalent and timed-out mutants; strengthen assertions for meaningful faults rather than gaming a universal score. You're done when Stryker reports the module's mutation score and you understand which mutants survive.

Self-check questions

  1. Explain the difference between [Fact] and [Theory] in xUnit. When do you reach for each?
  2. Why is async void dangerous in a test?
  3. What is the test pyramid, and what failure mode does inverting it produce?
  4. When do you mock a dependency vs use the real instance?
  5. What does WebApplicationFactory give you that a unit test does not?
  6. Why is DateTime.UtcNow in a test class a code smell?
  7. What's the principle behind React Testing Library's "query by role, not by test-id"?
  8. Why use MSW instead of mocking fetch?
  9. When do you write a Playwright test vs a WebApplicationFactory test?
  10. Name three things mutation testing catches that line coverage misses.
  11. What's the cost of a flaky test, and what's the response policy?
  12. Walk through Arrange-Act-Assert with a concrete example.

Contract, mutation, property and load tests

These address different questions; none is a replacement for the others.

  • Pact contract: a dashboard consumer records the request it sends and fields it actually needs, such as GET /orders/42 yielding id and total. Provider verification replays those interactions against the provider in named provider states. A changed field type can fail compatibility without running a full browser. It does not prove the provider's payment algorithm or production authorization is correct; maintain separate tests for those.
  • Mutation: replacing expires <= now with < survives tests that check only “five minutes before” and “five minutes after”. An assertion at equality kills this mutant. Removing a thrown exception needs an invalid-input assertion; changing a total needs an asserted value, not only “not null”. Equivalent mutants need classification, not artificial tests.
  • Properties: for valid prices and discounts in [0, 1], discounted price must stay between zero and the original price; discount 0 preserves price and discount 1 yields zero. Generate boundary values and retain the seed/counterexample. Such loops are legitimate; independently named examples remain useful for diagnosis.
  • Load (k6): choose an arrival/concurrency model, dataset and authorized isolated target. For an illustrative SLO, configure http_req_failed: ['rate<0.01'] and http_req_duration: ['p(95)<300']. Threshold failure gives a failing process exit, unlike merely collecting metrics. Record offered/achieved load, duration, warmup, saturation and per-operation metrics. A low-load pass is not a capacity certificate, and no load traffic was generated for this review.

Worked assessment answers

  1. Fact expresses a single case; Theory names one result per input row. For independent discount examples use Theory; for a generated invariant retain a property loop plus reproducible seed.
  2. Return Task so completion and failure are observed. xUnit v2's special async-void support is historical; v3 rejects it.
  3. The pyramid manages feedback cost, not a mandatory ratio. Isolated logic belongs in unit tests, real SQL semantics in provider tests, and browser routing/login in e2e. Pushing a wiring bug into mocks can make every unit green while production is broken.
  4. Stub volatile boundaries to isolate a behavior; use the real engine where transactions, collation or query translation are the claim. A fake email sender verifies intent to send, not delivery.
  5. WebApplicationFactory exercises the application's middleware, DI, routing and serialization through TestServer. It does not prove Kestrel/TLS or production bearer validation when auth is replaced.
  6. Capture/inject time rather than race a wall clock. At expiry E, assert valid at E−1 tick, expired at E and E+1 tick. Advance a fake provider only after timers have been registered if testing asynchronous timer behavior.
  7. Role/label queries assert accessible names and user-facing semantics rather than CSS structure. Use test IDs when semantic queries do not fit, but do not call a passing RTL test a complete accessibility audit.
  8. MSW preserves application request code while providing controlled responses through supported request APIs. In Node it does not run a Service Worker. Fail unexpected requests, reset handlers and clean up the DOM per test; test the actual provider separately.
  9. Use WebApplicationFactory for endpoint policy/serialization cases and Playwright for navigation, form interactions and browser-specific behavior. Browser tests with mocked HTTP are UI tests, not full-stack e2e.
  10. Mutation reveals missing boundary assertions, missing exception checks and wrong-value assertions; line coverage alone detects none of those. A surviving equivalent mutant need not indicate a defect.
  11. Flakes consume investigation time and teach people to ignore red builds. Capture first-failure evidence, assign an owner and quarantine only with a repair deadline and alternative coverage. A retry pass must remain visible as flaky, not be reported as an ordinary first-pass success.
  12. Arrange price 100 and discount 0.10, act by applying discount, assert decimal result 90. Add negative and greater-than-one discount rejection plus rounding policy tests.

Worked exercise outcomes

Exercise 1 solution: Use a Theory row (1, 2, 4) as an intentional failure against Add, observe the reported input/expected/actual, then restore expected 3. Retain the other valid rows; a generated invariant loop remains appropriate when it is one property, not unrelated examples.

Exercise 2 solution: Create an isolated database state, send Alice's authenticated create request and assert 201 plus OwnerId. Without X-Test-User assert the endpoint's unauthenticated rejection (normally 401 for this test scheme). Then request Alice's newly created order as Bob and as a caller in a different tenant, adding a validated test-only tenant claim as required, and assert denial (403 or an intentional existence-hiding 404). This does not imply Bob is forbidden to create his own order. Reset the shared factory database or allocate a separate fixture. These cases test chosen claims, not issuer/audience/signature validation; a named Bearer policy needs an intentional test override or a separate real-token suite.

Exercise 3 solution: Set expiry E, start the fake clock at E minus one tick, and assert false; advance one tick and assert true; advance another and remain true. For an asynchronous delay, register the delay first and advance the injected provider, then await it. Keep cancellation separate. The JavaScript millisecond model below is not the .NET tick/timer runtime.

Exercise 4 solution: With the success handler, render and await the Alice heading. Override /api/me to 500, render a fresh component and await the error message. Fail unhandled requests and run cleanup plus resetHandlers after each test; success after the failure case is a negative control for handler leakage.

Exercise 5 solution: Configure the base URL and isolated test account/data, then await sign-in navigation and a dashboard heading. Use an invalid password to assert the error path without accidentally reusing a signed-in context. A stage must consume the failing process result to gate promotion; archive only access-controlled, reviewed diagnostic artifacts. Running a browser against mocked HTTP is a narrower UI test, not full-stack proof.

Exercise 6 solution: Run the module baseline tests before mutation testing. The now >= expiry reference passes before/equal/after cases; replacing it with now > expiry fails only equality. A removed validation throw must fail an invalid-input assertion. Inspect uncovered and timed-out mutants and document genuinely equivalent survivors rather than claiming an arbitrary 80% score proves correctness. No Stryker installation or run occurred here.

Local reference fixture: this safe pure function exercises the equality decision used by the expiry example. It is an executed JavaScript model, not execution of C# FakeTimeProvider.

function isExpired(expiresMs, nowMs) {
  if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) {
    throw new TypeError('finite timestamps required');
  }
  return nowMs >= expiresMs;
}

High-signal resources

These are optional discovery links and reading suggestions, not evidence that every page, edition or tutorial was reviewed. Exact governing passages for the contracts above are listed in the review notes; broad homepages and endorsements do not validate framework versions.

Official docs

Books or courses

  • Unit Testing Principles, Practices, and Patterns — Vladimir Khorikov. Optional reading on unit-test design; no edition-specific coverage or ranking is asserted.
  • Working Effectively with Legacy Code — Michael Feathers. Optional reading on changing legacy systems; verify the edition and contents for your needs.

Practitioner posts

Weekly milestones

Illustrative pacing, not a completion-time guarantee; operational exercises require an authorized disposable target and compatible dependencies.

  1. Day 1: Read xUnit basics. Write 10 unit tests for an existing class using AAA + framework assertions or an approved FluentAssertions version. Answer self-check 1, 4, 12.
  2. Day 2: Set up WebApplicationFactory; write happy-path integration tests for every endpoint. Answer self-check 5.
  3. Day 3: Refactor one class to use TimeProvider; write a time-based test that is deterministic. Answer self-check 2, 6.
  4. Day 4-5: Set up Vitest + RTL + MSW; test one React component's behaviour and error path. Answer self-check 7, 8.
  5. Day 6-7: Write one Playwright e2e for the critical journey; wire it into the pipeline. Add Stryker to one module. Answer self-check 3, 9, 10, 11.

How it shows up in the capstone

A proposed capstone test design has all four tiers. Unit tests live in tests/Unit/ and cover business logic — pricing rules, validation, domain invariants. Integration tests in tests/Integration/ use WebApplicationFactory with explicit dependency fakes for narrow wiring tests and a matched real Postgres engine for every claimed provider-sensitive behavior. The pipeline runs both on every PR; set and measure an integration-suite duration budget rather than asserting an unmeasured 60-second result.

Frontend tests use Vitest + RTL + MSW, covering each non-trivial component's behaviour and error paths. The Playwright suite covers five critical journeys: sign-in, view dashboard, create dashboard, edit settings, sign-out. It runs against staging post-deploy and gates promotion to prod.

In this proposal, selected fast tests would run before commit and the chosen suites would be wired into CI/staging gates. No such hook or pipeline was implemented by this chapter review. When you can explain why each tier exists and what bug each catches that the next tier down would miss, the chapter is in place.


Previous chapter → Ch 20 — Security baseline
Next chapter → Ch 22 — Cross-cutting professional skills

Corrected contracts and failure analysis

WebApplicationFactory uses a test host and in-memory TestServer; it does not reproduce every Kestrel, TLS, reverse-proxy or cloud identity behavior. An overridden test auth handler proves policy behavior with chosen claims, not JWT cryptography. Keep a separate suite for actual token validation, including invalid signature/issuer/audience and missing permissions.

An EF in-memory provider is not a relational database. SQLite catches some relational behavior but not every production dialect, isolation level or collation. Test transaction-sensitive paths against the real engine in an authorized disposable environment. Await asynchronous assertions and use a controllable clock rather than sleeps for expiry. Playwright locator assertions auto-wait for observable conditions; fixed delays merely hide races. A passing snapshot or coverage percentage cannot establish accessibility or business correctness.

Boundary exercise with solution

An integration test injects Alice and returns 200. Which two important security claims remain untested?

Solution and reasoning

Token authentication validity and denial of access to someone else’s resource. Add Bob/cross-tenant negative cases to policy tests and separate real bearer-token validation tests. Also verify default unauthenticated access is denied rather than relying on the test handler to always supply a user.

Source-backed review notes

Official/maintainer raw and canonical snapshots were refreshed on 9 October 2026. The batch-18 packet retains exact governing quotations and hashes, with extraction rules and explicit moniker boundaries. Mutable documentation is not an installed-runtime pin. Optional resources above are not blanket-verified.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.