Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 28m2026-06-11

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.

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 the feedback loop on every PR is under five minutes".

Most engineers learn one testing framework and apply it everywhere. The result is either a CI run that takes 40 minutes (everything is an end-to-end test) or a green build that ships broken (everything is a mock-heavy unit test that proved the implementation, not the behaviour). 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.

Concepts and depth

Unit testing in .NET

The three frameworks you will meet are xUnit, NUnit, and MSTest. They are sufficiently equivalent for most purposes that the choice is usually "what the codebase already uses". New code in the .NET ecosystem leans xUnit because it has the cleanest async support, the most predictable lifecycle model, and is what most Microsoft samples use.

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 is closer to NUnit. The xUnit model removes a class of "leftover state from previous test" bugs by construction.

[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 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)]  // overflow check
    public void Add_returns_sum(int a, int b, int expected)
    {
        _sut.Add(a, b).Should().Be(expected);
    }
}

FluentAssertions is the assertion library you should default to. The vanilla Assert.Equal(expected, actual) reads backward; actual.Should().Be(expected) reads naturally, has chainable matchers for collections (Should().BeEquivalentTo, Should().ContainSingle(x => ...)), and produces dramatically better failure messages. The cost is a dependency; the benefit is years of clearer test code.

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. NSubstitute's API is the cleaner 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 the most common testing argument. 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
  • • xUnit + FluentAssertions + NSubstitute as the standard kit
  • [Theory] for parameterised tests; never a foreach in a [Fact]
  • • 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 it exercise the real HTTP request pipeline minus the network, which is the highest-value testing surface for backend services.

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 EF Core's in-memory provider or with a containerised real DB (via Testcontainers), replace external HTTP clients with stubs, replace the auth scheme with one that auto-issues a test JWT.

For database-backed integration tests, in-memory EF Core is fast but misses provider-specific behaviour. Testcontainers spins up a real database in a Docker container per test class, giving you provider-faithful semantics at the cost of container startup time (~5 seconds per class). For most non-trivial services, Testcontainers is worth the cost.

The integration test you should always have: happy path through every endpoint. If the test exercises every route at least once, you catch wiring failures, JSON serialisation regressions, and missing DI registrations on every CI run. They are cheap to write and high-signal.

Good enough to ship
  • • WebApplicationFactory tests for every endpoint's happy path
  • • Service overrides for external dependencies
  • • In-memory EF Core for DB-light tests
  • • Authentication scheme override that issues test tokens
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. Exceptions in async void cannot be observed by the runner; the test passes silently while throwing.

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

Time-based tests are where most flake originates. Anything that calls DateTime.UtcNow or Task.Delay is non-deterministic in a test runner: 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(10));
    sut.IsExpired(token).Should().BeTrue();
}

The same principle applies to Task.Delay — abstract it via the TimeProvider.CreateTimer API or a custom IDelayer interface. Tests that call await Task.Delay(1000) then assert on state are a future flake; you might get away with it on a fast machine, you will fail on a busy CI runner.

Parallelism: xUnit runs test classes in parallel by default but methods within a class sequentially. Tests that share mutable state across methods will flake under parallelism; either don't share state (preferred) or disable parallelism for that class with [Collection].

Good enough to ship
  • • All async tests return Task; no async void anywhere
  • • TimeProvider injected; FakeTimeProvider in tests
  • • No Task.Delay in test bodies
Expert tier
  • • Flaky-test dashboard tracks each test's pass rate
  • • Quarantine policy: a test that flakes thrice is quarantined and ticketed
  • • Deterministic random via seeded Random injected

Frontend testing: Vitest, Jest, React Testing Library

For React, Vitest is the modern default (Vite-native, ESM-first, fast) and Jest is the long-incumbent (Babel-based, slower startup, more plugins). Both have nearly identical APIs. React Testing Library (RTL) sits on top of either and provides the rendering + query primitives.

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 } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { LoginForm } from './LoginForm';

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. Each of those couples the test to implementation and pays off only if the implementation never changes. Behaviour-based tests pay off forever.

MSW (Mock Service Worker) is the right tool for stubbing HTTP in frontend tests. Instead of mocking fetch, MSW intercepts at the network layer with a handler that returns fixture responses. Your component code runs unmodified; only the network is faked.

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 is the modern e2e tool of choice: cross-browser (Chromium, Firefox, WebKit), excellent auto-wait semantics, first-class TypeScript, parallel execution, video + trace capture on failure. Its predecessor Selenium still works but the developer experience gap is large.

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 catch what unit and integration tests cannot: routing changes that break navigation, API contract drift, CSS regressions that hide a button. They are also the slowest and flakiest tier.

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. A suite of 50 e2e tests will be flaky and ignored; a suite of 8 will be stable and trusted.

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 covering the 5-10 critical user journeys
  • • Runs against staging post-deploy, gates promotion to prod
  • • Failures attach video + trace for debug
Expert tier
  • • Multi-browser matrix (Chromium + Firefox + WebKit)
  • • Parallel sharding for sub-5-minute suite runtime
  • • Flaky-test triage rota; failures investigated within 24h

The test pyramid mental model

The pyramid is a heuristic for distribution:

  • Base (most tests): unit tests. Fast (ms), deterministic, many. Test a single function or class.
  • Middle (fewer): integration tests. Slower (10ms-1s), still deterministic. Test wiring across modules — DB, HTTP pipeline.
  • Apex (fewest): end-to-end tests. Slowest (seconds), often flaky. Test the whole system through a user-facing surface.

The shape matters because cost grows up the pyramid: a 5-second e2e test is 1000× a 5ms unit test. A suite that inverts the pyramid (many e2e, few unit) is slow, flaky, and provides poor failure attribution — a red e2e tells you "something is broken" but rarely "what function has the bug". Many unit tests fail at the function level and tell you exactly where.

The mental model is "shift left". The earlier in the pipeline a test catches a bug, the cheaper the fix. Catch it in a unit test on the developer's laptop: 5 minutes. Catch it in integration on PR: 30 minutes. Catch it in e2e in staging: hours. Catch it in production: hours to days, plus customer trust hit. Push tests down the pyramid where you can.

Counter-balance: not every test belongs at the unit level. A bug that lives in the wiring between modules will pass every unit test and fail integration. A bug that lives in the actual deployed system will pass every integration test and fail e2e. The right level is the lowest one at which the bug is observable.

Worked examples

A unit test with the Arrange-Act-Assert structure

public class PricingServiceTests
{
    [Theory]
    [InlineData(100, 0.10, 90)]
    [InlineData(50, 0.0, 50)]
    [InlineData(200, 0.25, 150)]
    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 [InlineData] parameterises the happy paths.
  • Exception assertion uses Action act = () => ... to capture and assert on the throw.
  • WithParameterName validates the specific parameter name in the exception (catches misnamed params).

A WebApplicationFactory integration test with auth + DB override

public class OrdersApiTests : IClassFixture<TestWebAppFactory>
{
    private readonly HttpClient _client;

    public OrdersApiTests(TestWebAppFactory factory) => _client = factory.CreateClient();

    [Fact]
    public async Task Authenticated_user_can_create_order()
    {
        // Test JWT issued by the factory's auth override
        _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TestTokens.For("alice"));

        var response = await _client.PostAsJsonAsync("/orders", new { sku = "WIDGET-1", qty = 2 });

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var body = await response.Content.ReadFromJsonAsync<OrderResponse>();
        body!.OwnerId.Should().Be("alice");
    }
}

public class TestWebAppFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(opts =>
                opts.UseInMemoryDatabase("test-" + Guid.NewGuid()));

            services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, opts =>
            {
                opts.SecurityTokenValidators.Clear();
                opts.SecurityTokenValidators.Add(new TestTokenValidator());
            });
        });
    }
}

What to notice:

  • One factory per test class via IClassFixture — shared HTTP client, one host startup.
  • DB swapped to in-memory with a unique name per factory instance (no cross-test pollution).
  • Auth scheme overridden with a test validator that accepts pre-baked tokens.
  • The test exercises the real pipeline including auth + routing + controller + DB.

A React Testing Library behaviour test

import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } 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());
afterEach(() => 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();
    });
  });
});

What to notice:

  • MSW intercepts the HTTP call at the network layer; the component code does not need any test seam.
  • 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; tests within share the "sign in" context.
  • Credentials from env vars; no hardcoded secrets.
  • 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 an auth override that issues a test JWT. You're done when the test exercises the real pipeline including auth.

  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) Add or strengthen tests until killed mutants exceed 80%. 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.

High-signal resources

Official docs

Books or courses

  • Unit Testing Principles, Practices, and Patterns — Vladimir Khorikov. The book on testing in .NET in 2026.
  • Working Effectively with Legacy Code — Michael Feathers. The seminal "how to add tests to untestable code" text.

Practitioner posts

Weekly milestones

  1. Day 1: Read xUnit basics. Write 10 unit tests for an existing class using AAA + FluentAssertions. 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

The capstone 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 in-memory EF Core for fast tests and Testcontainers for the few that need real Postgres semantics. The pipeline runs both on every PR; the integration suite is under 60 seconds.

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.

The test scripts are wired into the pre-commit hook (run unit tests only) and the pipeline (run everything). 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