04 Β· Software Engineering

Testing & Quality β€” what tests are for, and how reviewers think

"How do you test your code?" is rarely about pytest syntax. It probes whether you understand risk β€” where bugs come from, what's worth defending, and whether you can be trusted with a shared codebase. Your Sentinel work gives you a rare angle: you built a system that reviews code and gates changes, so you've thought about quality from both sides.

01First principles: why do tests exist?

Not to prove correctness β€” that's impossible for nontrivial programs. Tests exist to:

1 Β· Catch regressions cheaply

A test is a tripwire you pay for once and it guards forever.

2 Β· Enable change

The real payoff isn't finding today's bug β€” it's letting you refactor next quarter without fear. A codebase without tests calcifies because nobody dares touch it.

3 Β· Document behavior executably

A good test name is a spec that can't drift stale: test_expired_token_returns_401 tells you more than a wiki page.

4 Β· Force better design

Hard-to-test code is nearly always badly-coupled code. (More in Β§7.)

The economic frame interviewers respect: a test is an investment with a maintenance cost. Bad tests β€” brittle, slow, testing implementation details β€” have negative ROI: they fail on every refactor, teach people to ignore red builds, and slow the team. "More tests" is not the goal; more confidence per unit of maintenance is.

02The testing pyramid

        /  E2E  \          few β€” slow, flaky, high fidelity
       / integr. \         some β€” real DB/queue, seams verified
      /   unit    \        many β€” fast, isolated, precise

The shape encodes a cost argument:

LayerWhat it verifiesCost profileWhat it catches
Unit (base)A function in isolation β€” pure input β†’ outputMilliseconds each; thousands per commit; pinpoint failures to a functionLogic bugs, edge cases in parsers, pricing, state machines
Integration (middle)Seams β€” your code against a real Postgres, a real Kafka topic, actual FastAPI routing + serializationSlower, but tolerable with containersBugs unit tests structurally cannot see: SQL vs. schema mismatch, serialization, transaction semantics, config wiring
E2E (tip)The deployed system, driven like a userSlow, expensive, flaky (network, timing, shared environments)Whole-system wiring; keep a handful of critical-path smoke tests, not hundreds
Why the pyramid inverts on teams that don't think: E2E tests feel most valuable ("it tests the real thing!"), so teams write too many β€” the suite takes 40 minutes, fails randomly, and everyone starts clicking re-run. The pyramid is a discipline against that gravity.
Modern nuance worth voicing β€” the "testing trophy": for service-heavy backends, some argue for a fat integration layer, because most real bugs live at the seams (DB, queue, HTTP) and modern tooling (testcontainers, in-process test clients) makes integration tests fast enough. Reasonable position: unit tests for logic-dense code (parsers, pricing, state machines); integration tests as the workhorse for CRUD-and-glue services; E2E as a thin smoke layer. Showing you tune the mix to the codebase beats reciting the triangle.

What each layer looks like concretely (FastAPI world)

Unit

The function that classifies a log line as incident/noise β€” pure input β†’ output, no I/O.

Integration

TestClient / httpx.AsyncClient against your app with a real (containerized) Postgres β€” assert the endpoint writes the row, returns 201, enforces the unique constraint.

E2E

Staging deploy, real webhook fired at it, assert a PR appears. Run nightly / pre-release, not per-commit.

Interactive β€” where does your suite sit on the pyramid?

Drag: % of your tests that are E2E β†’ 8%

0% shaded zone = sweet spot β‰ˆ 3–12% 80%

Feedback speed

Flake-risk & CI cost

Sweet spot.

03Mocking philosophy β€” the part people get wrong

A test double replaces a real dependency. Using the vocabulary precisely reads senior:

DoubleWhat it isYou assert on…Example
StubReturns canned data; you don't assert on itYour code's output, not the stub"The user service returns this user."
MockYou assert on how it was calledCall count + arguments"Assert we called send_email once with X."
FakeA real, working, lightweight implementationBehavior through the fakeIn-memory repository; SQLite standing in for Postgres.
SpyRecords calls for later inspection without changing behaviorThe recording, afterwards"Did the retry loop fire 3 times?"
The philosophy: mock at boundaries you don't own; use real things you do own.
Mock: third-party HTTP APIs (GitHub, LLM providers), the clock, randomness, email/SMS, payment providers β€” slow, non-deterministic, cost money, or have side effects.
Don't mock: your own domain logic, your own repository layer in a test that exists to verify persistence, pure functions.

If your test mocks five internal collaborators to test one class, the test is a change-detector: it verifies your implementation calls your implementation, passes even when behavior is wrong, and fails whenever you refactor. This is the London school vs Detroit school debate β€” mockist vs classicist β€” and the pragmatic position is classicist-leaning: assert on observable behavior (return values, state changes, external effects), not on internal call sequences.

Two failure smells to name in interviews

1 Β· Over-mocked test

30 lines of mock setup, 2 lines of assertion β€” and the assertion is mock.assert_called_once(). It tests the mocks.

2 Β· Mock drift

The mock's shape no longer matches the real dependency, so tests pass and prod fails. Mitigations: autospec=True in unittest.mock (mocks enforce the real signature), contract tests, or recorded-cassette tools (VCR.py / respx) that pin real response shapes.

LLM-specific angle you can own: you can't meaningfully mock an LLM's judgment, only its interface. In Sentinel-style systems: unit/integration tests mock the provider client (deterministic canned completions, error/timeout cases, malformed JSON responses β€” testing your handling), while actual output quality is tested by a separate eval suite with golden cases, run less often. Separating "does my plumbing handle the model" from "is the model's output good" is a genuinely strong answer.

Interactive β€” Test Double Picker

Six situations. Pick the right double each time.

04TDD, honestly assessed

The loop: red (write a failing test) β†’ green (minimal code to pass) β†’ refactor (clean up under a green bar). Repeat in small steps.

What it genuinely delivers

β€’ Forces you to define behavior before implementation β€” you catch fuzzy requirements at the cheapest moment.

β€’ Guarantees the test can fail (you watched it fail) β€” a surprising number of non-TDD tests pass vacuously.

β€’ Produces testable, decoupled design as a side effect, because you experience the API as a caller first.

β€’ Great for bug fixes: write the failing repro test first, so the bug can never silently return.

β€’ Great for logic-dense, well-specified problems: parsers, validators, pricing rules, state machines.

Where it honestly fits poorly

β€’ Exploratory work β€” when you don't know what the interface should be yet (prototyping an agent loop, trying a chunking strategy), test-first ossifies your first guess. Spike without tests; throw the spike away or backfill tests once the shape settles.

β€’ Glue-heavy code β€” thin controllers, config wiring: TDD ceremony exceeds value; an integration test covers it better.

β€’ Systems whose "correctness" is empirical β€” LLM output quality, ranking relevance, UI feel. You need evals/experiments, not red-green-refactor.

The honest interview answer: "I use test-first where the spec is crisp β€” especially bug fixes and pure logic β€” and test-soon-after elsewhere. What I'm rigid about isn't the ordering, it's the invariants: every behavior change lands with tests, and every test has been seen to fail." That's credible; "I always do strict TDD" usually isn't.

05Property-based testing β€” the idea

Example-based test: for this input, expect this output. Property-based test: for all inputs (sampled), this invariant holds. The framework (Hypothesis in Python) generates hundreds of randomized inputs and β€” crucially β€” shrinks any failure to a minimal counterexample.

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sort_is_idempotent(xs):
    assert sorted(sorted(xs)) == sorted(xs)

@given(st.text())
def test_roundtrip(s):
    assert decode(encode(s)) == s    # the killer pattern: encode/decode round-trips

Classic property families

Round-trip

serialize→deserialize = identity (Pydantic models, chunking→reassembly, event encode/decode).

Invariants

Output sorted, no items lost, total conserved.

Oracle

Fast clever implementation agrees with slow obvious one.

Idempotence

f(f(x)) == f(x) β€” normalizers, your webhook dedupe logic.

Why it matters: humans test the inputs they thought of; property tests find the inputs you didn't β€” empty lists, "\x00", surrogates, off-by-one boundaries. You don't need to claim daily use; knowing when you'd reach for it (parsers, codecs, anything with an invariant) is the signal.

06pytest patterns that read senior

Fixtures β€” dependency injection for tests

@pytest.fixture
def db(postgres_container):              # fixtures compose
    engine = create_engine(postgres_container.url)
    run_migrations(engine)
    yield engine                          # test runs here
    engine.dispose()                      # teardown after yield

@pytest.fixture
def client(db):
    app.dependency_overrides[get_db] = lambda: db   # FastAPI's DI hook
    return TestClient(app)

Key points: yield gives setup/teardown in one place; scopes (function default, module, session) trade isolation for speed β€” container per session, clean transaction per test (begin β†’ run test β†’ rollback) is the classic fast-and-isolated DB pattern; conftest.py shares fixtures without imports.

Parametrize β€” one test, a table of cases

@pytest.mark.parametrize("status,expected", [
    (200, "ok"), (429, "backoff"), (500, "retry"), (400, "fail_fast"),
])
def test_retry_policy(status, expected):
    assert classify(status) == expected

Each row is a separately-reported test. This is how you cover status-code matrices, permission matrices, and edge tables without copy-paste.

Others worth having in your pocket

pytest.raises(ValueError, match="negative")   # assert exceptions precisely

@pytest.mark.slow                              # markers β†’ tiered suites:
pytest -m "not slow"                           # fast per-commit, slow pre-merge
monkeypatch

Fixture for env vars / attributes; freezegun or an injected clock for time.

async & HTTP

pytest-asyncio / anyio for async handlers; respx for mocking httpx calls (your LLM-provider tests).

Flaky-test policy

Quarantine-and-fix, never normalize re-running. A suite people re-run is a suite people ignore.

Test structure: Arrange–Act–Assert, one behavior per test, name = behavior sentence (test_duplicate_webhook_delivery_is_ignored). If a test needs a paragraph of comments, the name is wrong.

07What "testable code" actually means

Testability isn't a testing property β€” it's a design property. Code is testable when:

1 Β· Dependencies injected, not reached for

def process(event, *, github: GitHubClient, clock: Clock) β€” a test passes fakes. Code that does requests.post(...) or datetime.now() inline welds itself to the world.

2 Β· Logic separated from I/O

"Functional core, imperative shell." This one pattern is 80% of testability β€” see below.

3 Β· Effects are explicit

Functions return results instead of mutating globals/singletons; hidden module-level state makes tests order-dependent (the worst flake source).

4 Β· Seams exist at boundaries

An interface/protocol for "notifier", "repo store", "LLM provider" means each has a fake.

5 Β· Non-determinism quarantined

Time, randomness, UUIDs, network β€” all injected.

Welded to the world

def handle(event):
    logs = requests.get(LOG_URL).json()   # hidden I/O
    if datetime.now().hour < 6:           # hidden clock
        return
    action = decide(logs, event["diff"])
    slack.post(action.summary)            # hidden effect

Functional core, imperative shell

# core: pure decision fn β€” fast, exhaustive unit tests
def decide_fix_action(logs, diff, now) -> Action: ...

# shell: thin fetching/posting layer β€”
# a few integration tests cover it
def handle(event, *, http, slack, clock):
    action = decide_fix_action(http.logs(), event.diff, clock.now())
    slack.post(action.summary)
The tell: if a test is painful to write, treat it as a design signal, not a testing problem. Needing to patch six module paths means the code has six hidden dependencies. You refactor the code, not heroically mock around it.

08Code review culture β€” what reviewers actually look for

You've built an automated adversarial reviewer, so speak about review with unusual authority: you had to encode "what makes a change trustworthy" into a system.

The reviewer's priority stack (roughly in order)

1 Β· Correctness of intent

Does this change do what the ticket needed? Is the approach right, or is it a local patch on a design problem?

2 Β· Edge cases & failure modes

Nulls/empties, concurrency, partial failure (what if the third of five writes fails?), idempotency of retried paths, timezone/encoding traps.

3 Β· Security

Injection (SQL/command/prompt), authz on new endpoints (who can't call this?), secrets in code/logs, unsafe deserialization, SSRF on URL-fetching features.

4 Β· Blast radius & operability

Migration safety (backwards compatible? locking a hot table?), rollout/rollback story, feature flags, what appears in logs/metrics when this fails at 3am.

5 Β· Tests

Do tests cover the behavior that changed, and would they fail if the change were reverted? 400 lines of code with tests that never exercise the new branch is the classic red flag.

6 Β· Readability & maintainability

Naming, function size, whether the next person can understand it. Consistency with the codebase beats personal taste.

7 Β· Style/nits β€” last, and automated

Formatting belongs to Black/Ruff, not humans. Teams that argue about commas in review waste their scarcest resource: reviewer attention.

Culture markers of good teams (and good candidates)

Small PRs

Review quality collapses with diff size β€” a 2,000-line PR gets "LGTM"; a 200-line PR gets found bugs. Authors who split work reviewably are prized.

Author reviews first

Self-review your own diff, annotate the non-obvious bits ("this looks redundant but handles the redelivery race") β€” respect for reviewer time.

Questions, not commands

"What happens if the queue is down here?" > "wrong." Prefix nits as nit: so severity is legible.

Code, not person

As author, treat pushback as free QA, not attack. Verify feedback technically rather than performatively agreeing β€” some review comments are wrong, and saying so with evidence is healthy.

Knowledge transfer

Reviews spread codebase knowledge as much as they find defects β€” it's how juniors absorb the codebase and how bus-factor stays >1.

Your Sentinel angle (use this): building the adversarial reviewer stage forced the question "what should a reviewer check, mechanically?" β€” and taught what automation is good at (consistency, tirelessness, checking every diff against known failure patterns, policy enforcement) versus bad at (intent, product context, "is this the right problem to solve"). That's why the human approval gate stayed in the loop: automated review raises the floor; human review owns the judgment call. A genuinely differentiated answer to "what makes code review effective?"

09Rapid-fire interview Q&A

Q: How do you decide what to test?

By risk and logic density. Pure business logic gets exhaustive unit tests; seams get integration tests; the critical user path gets a thin E2E smoke. I don't chase coverage numbers β€” coverage tells you what's executed, not what's asserted.

Q: Is 100% coverage a good goal?

No. It's easy to hit with assertion-free tests and it taxes every refactor. I'd rather have 75% coverage of behavior-asserting tests than 100% of change-detectors. Coverage is a flashlight for finding untested risk, not a KPI.

Q: How do you test code that calls an LLM?

Two layers. Plumbing: mock the provider (canned outputs, timeouts, 429s, malformed JSON) and test my parsing, retries, fallbacks deterministically. Quality: a separate eval set of golden inputs scored on real model output, run on schedule and on PRs to prompts β€” because model behavior is a dependency that changes under you.

Q: How do you handle a flaky test?

Quarantine it immediately (so the suite stays trusted), then root-cause: usually shared state, real time/network, or order dependence. Deleting a flaky test is better than retry-until-green β€” retries train the team to ignore red.

Q: Unit or integration for a CRUD endpoint?

Integration β€” with a real containerized DB through the HTTP layer. The risk in CRUD is at the seams (SQL, serialization, constraints), which unit tests with a mocked repo can't see.

10Self-test before you close the tab

Name the four test doubles and the one-line difference between mock and spy.

Stub (canned data, no assertions on it), mock (assert on how it was called), fake (real lightweight implementation), spy (records calls for later inspection without changing behavior). Mock = you set the expectation up front and assert the interaction; spy = it just records, and you inspect the recording afterwards.

Give the pyramid's cost argument in two sentences, then the trophy counter-argument.

Unit tests are cheap, fast, and pinpoint failures, so you want many; E2E tests are slow, expensive, and flaky, so you want few β€” the shape is an economic discipline. Trophy counter: on service-heavy backends most real bugs live at the seams, and testcontainers/in-process clients make integration tests fast enough to be the workhorse layer.

What's the classicist objection to heavy mocking, and what do you assert instead?

Over-mocked tests are change-detectors β€” they verify your implementation calls your implementation, pass when behavior is wrong, and fail on every refactor. Assert on observable behavior instead: return values, state changes, external effects β€” and mock only at boundaries you don't own.

Scope TDD honestly: two places it shines, two where it fits poorly.

Shines: bug fixes (failing repro test first) and logic-dense, well-specified code (parsers, validators, pricing, state machines). Fits poorly: exploratory work where the interface is unknown (spike, then backfill) and empirical-correctness systems (LLM output quality, ranking) that need evals, not red-green-refactor.

Recite the reviewer priority stack from memory.

1 correctness of intent β†’ 2 edge cases & failure modes β†’ 3 security β†’ 4 blast radius & operability β†’ 5 tests that cover the changed behavior β†’ 6 readability & maintainability β†’ 7 style/nits, last and automated.

Why does "functional core, imperative shell" buy you 80% of testability?

Pure decision functions (logic) get fast, exhaustive, deterministic unit tests with no mocks; the thin shell that fetches and posts gets a few integration tests. Non-determinism (time, network, randomness) is pushed to the edges and injected, so nothing needs heroic patching.