📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 66 · Modern signals

Testing — what to write, what to skip

📖 Walk me through it — plain English

This lesson is not about an algorithm — it is about how to talk about testing when an interviewer asks "how would you test this?" A test is just a small piece of code that runs your real code with some input and checks the result is what you expected. The whole point of testing is confidence: proof that your code does what you claim, and a tripwire that goes off if someone later breaks it. The trap the interviewer is watching for is the rote answer "I'd aim for 100% coverage." The grown-up answer is: write tests where the risk is, skip tests where there is nothing to get wrong.

Think of it like proofreading a printed book before it ships. You do not read every single letter with a magnifying glass — that would take forever and add little. Instead you spread your attention by value: unit tests are like checking individual sentences (fast, you do tons of them); integration tests are like reading a whole chapter to make sure the paragraphs connect (slower, fewer, but they catch the most real mistakes); end-to-end (E2E) tests are like reading the book cover to cover as a customer would (slowest and most fragile, so you only do it for the most important storyline — the "golden path"). That shape — a wide base of cheap unit tests, a thinner middle of integration tests, a tiny top of E2E — is what people mean by the test pyramid.

A few terms the lesson leans on, defined plainly. AAA = Arrange (set up the inputs), Act (call the thing once), Assert (check the result). One test should check one behavior — if it "acts" twice, split it into two tests so a failure points at exactly one cause. Mocking means swapping out a real dependency for a fake stand-in so the test stays fast and predictable; you mock at the IO boundary (input/output: the network, the filesystem, the clock, randomness) and never mock the actual code you are testing. Test behavior, not implementation means: check what the function returns or changes, not which internal helper it happened to call — otherwise every harmless refactor breaks your tests for no reason.

How to approach the interview question when it comes up:

1 · Name the one happy-path integration test — the normal end-to-end success case that proves the pieces fit together.
2 · Add unit tests only for the tricky bits: parsing, rate-limit logic, anything with branching rules.
3 · Rattle off edge cases: empty, single item, two items, max size, off-by-one, negative, zero, unicode, duplicates, already-sorted, reverse-sorted.
4 · Say what you would skip: trivial getters and anything with no logic to break.
5 · Flag flaky tests — a test that passes and fails randomly is worse than none, because it teaches the team to ignore red builds. Fix it or delete it; never just "rerun until green."

Why this answer wins: it shows you reason about where bugs actually hide and what each test costs to write and maintain, rather than chasing a coverage number. The memorable line from the lesson sums it up — "Specific > exhaustive." You also signal judgment about TDD (test-driven development, writing the test before the code): great for pure logic with a clear contract, awkward when you are still exploring an unfamiliar UI or API design, where it is fine to write the code first and harden it with tests afterward.

Interviewers ask "how would you test this?" to separate engineers who write tests-by-rote from engineers who reason about confidence. The answer is rarely "100% coverage."

The vocabulary, defined once

Before the pyramid, lock down the words. These get thrown around loosely, and an interviewer notices when you use them precisely.

  • Test — code that runs your real code with a chosen input and checks the result. If the check passes the test is "green"; if it fails it is "red."
  • Assertion — the single line in a test that states what must be true, e.g. assert result == 42. A test with no assertion proves nothing; it only proves the code did not crash.
  • Unit test — exercises one small piece (a function or class) in isolation. Fast (milliseconds), so you write many.
  • Integration test — exercises several pieces wired together, often with a real database or a real HTTP call. Slower, fewer, but catches the bugs that only appear where parts meet.
  • End-to-end (E2E) test — drives the whole system the way a user would (a browser clicking buttons, or a full API request). Slowest and most brittle.
  • Test pyramid — the recommended proportion: many unit, fewer integration, a tiny number of E2E. Explained below.
  • Mock / stub / fake — three flavors of stand-in for a real dependency. A stub returns canned answers ("when asked for user 7, return this object"). A mock is a stub that also records how it was called so the test can verify the interaction. A fake is a real-but-lightweight working implementation (an in-memory database instead of Postgres). You reach for these to keep tests fast and deterministic.
  • Fixture — reusable setup data or state shared across tests (a sample user, a temp folder, a seeded database) so each test starts from a known, repeatable baseline.
  • Coverage — the percentage of your lines (or branches) that ran while the tests executed. It measures what got executed, not what got verified — a crucial gap, covered in the pitfalls.
  • Regression — a bug in something that used to work. The main job of a test suite is to catch regressions before users do. A test written for a fixed bug is a regression test.
  • Flaky test — a test that passes and fails on the same code without changes (usually timing, ordering, or randomness). Engineers say "that's a flake" or "rerun until green" (the bad habit). Toxic, because it erodes trust in the whole suite.
  • Happy path — the normal success case only. "We only tested the happy path" means edge cases are missing.
  • Green / red — passing / failing. "CI is green" = all checks passed.
  • Smoke test — a tiny E2E on the critical flow ("can users still log in?") — run before/after deploy.
  • TDD (test-driven development) — write the failing test first, then write just enough code to make it pass, then refactor. Engineers shorthand it red-green-refactor. Forces you to define the contract before the implementation.
  • Property-based test — instead of one example, you state a property that must always hold (e.g. "sorting then reversing equals reverse-then-sort-descending"), and the framework throws hundreds of random inputs at it to try to break the property. Great for finding edge cases you would never think to type by hand.

A concrete test, top to bottom (AAA)

Vocabulary sticks once you see it run. Say we wrote a tiny function and want to prove it works. The Arrange / Act / Assert shape gives every test the same readable skeleton: set up the inputs, do the one thing, check the one result.

# the code under test
def apply_discount(price, percent):
    if percent < 0 or percent > 100:
        raise ValueError("percent must be 0..100")
    return round(price * (1 - percent / 100), 2)

# a unit test, written Arrange / Act / Assert
def test_applies_a_normal_discount():
    price, percent = 200, 10      # Arrange: choose the inputs
    result = apply_discount(price, percent)  # Act: call it exactly once
    assert result == 180.0            # Assert: one behavior, one check

# a second test for the error path — a separate behavior, so a separate test
def test_rejects_out_of_range_percent():
    import pytest
    with pytest.raises(ValueError):     # Assert: it must raise
        apply_discount(200, 150)        # Act: an invalid percent

Notice the two tests are split by behavior: one proves the happy path, one proves the guard. If both lived in one test and it went red, you would not instantly know which behavior broke. That is the practical reason behind "one test = one behavior."

Sanity check your own test. A test you have never seen fail is suspect. Briefly break the code (return the wrong number) and confirm the test goes red, then revert. A test that stays green no matter what is just decoration — it gives false confidence and inflates coverage without verifying anything.

What to test vs what to skip

The whole skill is allocating effort to where bugs hide. A quick rule: test the logic, skip the plumbing.

Worth testing
  • Branching rules: discounts, rate limits, permissions, state machines.
  • Parsing and formatting — anywhere input can be malformed.
  • Boundaries and error paths — empty input, the max size, the failure case.
  • Anything that broke before (lock in the fix with a regression test).
Safe to skip
  • Trivial getters/setters with no logic to get wrong.
  • Code that only forwards to a well-tested library.
  • Pure configuration and constants.
  • The framework or language itself — that is already tested.
The pyramid
  • Unit — single function / class. Fast (ms), many. Mock external boundaries only.
  • Integration — module + real DB / real HTTP. Fewer, slower (~100ms). Highest bug-find / time ratio.
  • E2E / acceptance — full app, browser or API. Slow, brittle. Reserve for golden-path flows.

Why a pyramid and not, say, a square? It is an economics argument. As you climb, each test runs slower, costs more to write, and breaks more often for reasons unrelated to real bugs (a moved button, a slow network). So you want most of your safety net made of cheap, fast, stable unit tests, a moderate layer of integration tests that prove the wiring is right, and only a few precious E2E tests guarding the flows that actually make money. The opposite shape — lots of slow E2E tests, few unit tests, sometimes drawn as an "ice-cream cone" — gives a suite that is slow, flaky, and expensive, so people stop trusting and running it. The pyramid is the proportion that keeps the suite fast enough to run on every push (see CI/CD) yet broad enough to catch regressions.

AAA

Arrange, Act, Assert. One test = one behavior. If your test has two acts, split it.

Test behavior, not implementation

Asserting that "internal helper foo was called once" couples you to the implementation. Assert outputs / side effects instead.

Where to mock

At the IO boundary: network, filesystem, clock, randomness. Don't mock the thing you're testing.

Edge cases to remember

Empty, single, two, max size, off-by-one, negative, zero, unicode, duplicates, already-sorted, reverse-sorted.

Flaky tests

A flaky test is worse than no test — it trains the team to ignore failures. Fix it or delete it; never "rerun until green."

TDD when it helps

Pure logic with clear contracts: TDD shines. Exploratory UI / API design: write code first, harden with tests after.

Two pitfalls that fail interviews

Pitfall 1 — testing implementation, not behavior

If your test asserts "the private helper _format() was called once," it is glued to how the code works today. Rename or inline that helper — behavior unchanged — and the test goes red anyway. Now tests punish refactoring, the opposite of their job. Fix: assert the observable result — the return value, the row written to the database, the event emitted — and mock only true external boundaries (network, clock, filesystem, randomness), never your own internals.

Pitfall 2 — chasing 100% coverage

Coverage counts lines that ran, not behaviors that were verified. A test that calls a function and asserts nothing meaningful can push coverage to 100% while catching zero bugs. Worse, the last few percent are usually defensive error branches that cost the most to fake and matter the least. Use coverage as a flashlight to find untested areas, not as a target. The real question is never "what is my coverage?" but "would this test catch the bug I am actually worried about?"

Interview answer: "I'd write one integration test for the happy path, plus unit tests for the tricky bits — boundary inputs, the parser, the rate-limit logic. I'd skip testing trivial getters." Specific > exhaustive.
→ Going deeper: tests earn their keep when they run automatically. See CI/CD for how the test suite becomes a merge gate that runs on every push.
Go deeper (optional): Martin Fowler's essays on the "Test Pyramid" and "Practical Test Pyramid" are the canonical write-ups; Kent Beck's Test-Driven Development by Example is the original TDD source.
→ Going deeper: Testing discipline carries straight into Eval design. See Eval design.