AI EngineeringZero to ProductionHome·About·What’s new·Contact
Software Testing · Chapter TQ1

Why test, and your first pytest

Why testing lets you change code fearlessly — from a first assertion to pytest to a risk-based test strategy a team follows. (Software testing, distinct from LLM evals.)

⏱️ ~2.5 hours🧪 6 labs🎯 Beginner→Tech-lead
🌱 Start here — from zero Testing, from scratch — a test is just code that checks your code — the habit that lets you change things fearlessly.

A test runs your code and asserts the result. This chapter climbs from your first assertion to running pytest, and up to designing a test strategy a team follows. So every block runs anywhere, the runnable examples use plain assert (pytest uses the same assertions).

The words you'll hear (in plain terms):

TermWhat it actually means
testcode that runs your code and asserts it's correct.
assertiona check that must hold (assert x==5).
pytestthe standard runner that finds and runs tests.
regressiona bug that returns; a test stops it recurring.
test pyramidmany unit, some integration, few end-to-end tests.

What you need before starting:

  • Python functions (py3).
  • pip install pytest for the pytest labs.
  • Runnable blocks use plain assert so they work with zero setup.

New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.

Learning objectives

  • Explain what tests buy you and the test pyramid.
  • Write assertions and your first pytest test.
  • Read a failure and interpret expected-vs-got.
  • Shape a test strategy for a codebase.
▶ Runnable companionThe code here is also saved under code/tq1-why-test/. Python blocks run offline; config files are ready to drop into a project.

1 · Why test — and a first assertion essential

Without tests every change is a gamble. With them, one command tells you if you broke anything. At heart a test is an assert.

Here's the moment every developer has lived. You fix a small bug on a Friday afternoon — one line, obviously safe. You ship it. On Monday a colleague messages: the checkout page is throwing errors for anyone with a discount code. Your one-line fix quietly changed a shared helper, and nothing warned you, because the only thing checking that helper was you clicking through the app — and you didn't click the discount path. That gap between "I changed something small" and "I have no idea what else I touched" is exactly the fear that tests exist to kill.

Think of tests as a smoke alarm for your code. A smoke alarm doesn't stop you cooking; it just screams the instant something's wrong, so a small fire never becomes a house fire. A test suite does the same for behaviour: it doesn't stop you changing code, it screams the instant a change breaks something you'd already gotten right. Without it you're cooking with the alarm unplugged — everything's fine until the day it very much isn't, and by then the damage is in production.

So what is a test, stripped of all ceremony? It is code that runs your code and states, in code, what the answer must be. That statement is an assertion: assert add(2, 3) == 5 means "I claim this equals 5, and if it ever doesn't, stop everything and tell me." When the claim holds, the assert is silent and your program flows on. When it fails, Python raises an AssertionError — and that failure is not a nuisance, it's the whole point. A failing assertion is a precise, automated bug report you wrote before the bug existed.

Why does this change how you work, not just how safe you are? Because it flips your relationship with change. Untested code is brittle — you tiptoe around it, afraid to refactor, because you can't tell what a change will break. Tested code is malleable — you can rip out an ugly implementation, run one command, and know in seconds whether the behaviour still holds. That confidence to change fearlessly is the real product of testing; catching bugs is just the visible part. The common beginner mistake is treating tests as extra homework you do after the "real" work is done. In practice, tests are part of the work — they're how you find out your code does what you think it does, which is the one thing you can never verify just by reading it.

the same one-line change — two very different fates no tests you change code ship nothing checks 🔥 incident users hit the bug with tests you change code run suite assert … 🔴 red — caught 🟢 green — safe A test suite is a gate between your change and your users. Without tests a bad change sails straight to production and becomes an incident; with tests it hits an assert that turns the bar red and stops it — or green, and you ship with confidence.
🗺️ How to read this diagram

Read each row left to right; the two rows are the same code change, once without a test suite and once with one.

  • The top row (no tests) goes change → ship → 🔥 incident: nothing sits between your edit and your users, so a bug is only discovered in production when someone hits it.
  • The bottom row (with tests) inserts one box — run suite — between the change and the world. That box runs your asserts automatically.
  • The suite has two exits: a red box (a bug was caught, before users saw it) or a green box (every check held, ship safely). That red exit is the whole payoff — the fire is stopped while it's still small.

In short: tests add one gate between "I changed something" and "my users found out" — turning silent production fires into a loud, early red bar.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · the essence of a test (runs)
essence.pydef add(a, b):
    return a + b

# a "test" is just: run the code, assert the result
assert add(2, 3) == 5
assert add(-1, -1) == -2
print("all assertions passed")
all assertions passed
▶ How this works

This is the whole idea of testing in six lines. A test is nothing magic: you run a bit of your code, then assert that the result is what you expected. If it is, nothing happens and the program keeps going. If it isn't, Python stops with an error — that's the test failing, and it's how a test tells you something broke.

  1. The first two lines define a tiny function add that returns a + b. This is the code under test — the thing we want to be sure works.
  2. assert add(2, 3) == 5 means "I claim this equals 5." Python computes add(2, 3), gets 5, checks 5 == 5, and since that's true it stays silent and moves on.
  3. The second assert checks a different case (adding two negatives). Good tests check more than one example so you catch mistakes that only show up sometimes.
  4. The final print only runs if every assertion above passed — so seeing the message is your proof that all the checks held.

What the output means: You see all assertions passed. That line printing at all means both asserts were true; if one had been false, Python would have raised an AssertionError and the print would never run.

Try this: Change the first assertion to == 6 and run it again. Now the program stops with an AssertionError and never prints the message — that is exactly what a failing test looks like.

2 · Your first pytest test essential

pytest finds functions named test_* and runs them. Plain assert — pytest rewrites it to show values on failure.

Bare assertions work — until you have more than five of them. Picture a script full of assert lines. The first one that fails stops the whole file, so you never learn whether the other forty would have passed. There's no summary, no count, no "3 failed out of 50" — just one crash and a lot of unknowns. And you have to remember to run the script at all. That doesn't scale to a real codebase, and the fix is a test runner: a program whose entire job is to find your tests, run every one of them even when some fail, and hand you a tidy report.

In Python that runner is pytest, and its cleverest trick is how little it asks of you. You don't inherit from a base class or register anything; you just write ordinary functions whose names start with test_, and put plain assert statements inside. pytest scans your files, notices those test_ names, and runs each one. That automatic finding has a name — test discovery — and the test_ prefix is the convention that drives it. Name a function check_add and pytest walks right past it; name it test_add and it gets run. This is the single rule most beginners trip on: the prefix isn't decoration, it's the signal.

The second thing pytest gives you is assertion rewriting. If you write a plain assert x == 5 in a normal script and it fails, Python just says AssertionError — useless, because it won't tell you what x actually was. pytest rewrites your assertions behind the scenes so that a failure prints both sides: the value it got and the value you expected. That's why in pytest you use plain assert rather than a pile of special assertEqual-style methods — you get readable failures for free, in the language you already know. As you run your suite you'll hear two words constantly: green when everything passes and red when something fails. The daily rhythm of testing is simply keeping the bar green and reacting fast when it turns red.

type pytest -q — it finds and runs your tests, then reports; you never call them yourself pytest -q scan files discover test_add ✓ check_add skipped run each assert inside 🟢 all passed 🔴 N failed name matches the test_ prefix is the signal — no prefix, no run pytest finds, runs, and reports — you just write test_* functions. Discovery keys entirely off the test_ name prefix; every matching function runs even if others fail, and the suite ends green (all passed) or red (a count of failures).
🗺️ How to read this diagram

Follow the blue arrows left to right — that's the order pytest works through one run, from the command you type to the summary it prints.

  • The blue box on the left is you typing pytest -q; it scans your files. The next box is discovery: pytest keeps test_add because its name starts with test_, and walks past check_add because it doesn't — that's why the prefix is a signal, not decoration.
  • The run each box executes every discovered test and its inner assert. Crucially it runs all of them, collecting results, rather than stopping at the first failure like a bare script would.
  • The two boxes on the right are the only two outcomes: the green box (every assertion held) via the solid arrow, or the red box (a count of failures) via the dashed arrow — the "keep it green" bar you watch daily.

In short: name a function test_* and pytest discovers it, runs it, and folds the result into one green-or-red report.

pytest · calc.py + test_calc.py
test_calc.py# calc.py
def add(a, b): return a + b

# test_calc.py
from calc import add
def test_add():            assert add(2, 3) == 5
def test_add_negatives():  assert add(-1, -1) == -2

# run:  pytest -q   ->  2 passed
▶ How this works

Here the same assertions move into a real pytest test file. pytest is the standard tool that finds your tests, runs them, and reports pass/fail — so you don't call each check by hand. The one rule you must follow: names have to start with test_, because that's how pytest discovers which functions are tests.

  1. The top two lines are the code being tested (add), which normally lives in its own file calc.py. The from calc import add line pulls that function into the test file so the tests can use it.
  2. Each function whose name starts with test_ is one test. Inside, a single assert states what should be true. You can have as many test_ functions as you like — here there are two.
  3. You do not call these functions yourself. pytest scans the file, sees the test_ names, and runs each one for you — that automatic finding is called test discovery.
  4. The last comment shows how you run it: type pytest -q in your terminal (-q just means "quiet", less noise). pytest replies with a summary line.

What the output means: 2 passed means both test_ functions ran and every assertion inside them was true. This all-passing state is what people call green; a failing run is red.

Try this: Run pip install pytest, save these two functions in a file named test_calc.py, and run pytest -q. Then break one assertion and watch the summary flip to 1 failed, 1 passed.

3 · Reading a failure intermediate

A failing test shows expected vs got — learning to read it is half of testing.

Beginners fear red; experienced testers read it. The first time a test fails, the instinct is to feel like you've done something wrong. But a failing test is the system working exactly as designed — it just caught something before your users did. The skill that separates people who get value from tests from people who just have them is the ability to look at a failure and calmly extract what it's telling you. And a good failure tells you a lot.

Every useful failure boils down to two values: what your code actually produced (call it got) and what you claimed it should produce (want). A failure line like got=4 want=5 is a doctor's diagnosis in miniature — it doesn't just say "you're sick," it points at the exact symptom. The moment you see both numbers, you can ask the one question that resolves every test failure: is the code wrong, or is my expectation wrong? Those are the only two possibilities, and they lead to opposite fixes.

The lab below is a tiny hand-rolled imitation of what pytest prints, so you can practise reading that got-vs-want line in isolation before you meet pytest's fuller output. Real pytest surrounds these two values with more context — the file, the line, the exact expression — but the heart of every failure you'll ever debug is this same expected-versus-actual comparison. Learn to read it fast and half of testing is already yours.

every failure reduces to two values and one question 🔴 got=4 · want=5 a failing assertion code wrong, orexpectation wrong? the only two possibilities code is wrong 5 is right, 4 is the bug→ fix the function expectation is wrong 4 is right → fix the test A failure is a fork with exactly two paths. Read the got and want, then decide which is correct: if want is right, the code has a bug; if got is right, the test encoded a wrong expectation.
🗺️ How to read this diagram

Read it top to bottom: the red box at the top is the raw failure, the amber box is the question it forces, and the two bottom boxes are the only two answers.

  • The red box is what a failed test hands you — two values, got=4 and want=5. That pair is the entire diagnosis; everything below flows from comparing them.
  • The amber box is the single question every failure reduces to: is the code wrong, or is my expectation wrong? There is no third option, which is what makes debugging a test tractable.
  • The two bottom boxes are the opposite fixes. Left: 5 is genuinely correct, so 4 is a real bug — you fix the function. Right: 4 was actually right — you fix the test (never by blindly pasting in whatever the code returned).

In short: a red result isn't a verdict on you — it's a fork, and reading got vs want tells you which branch to take.

Python · simulate what pytest reports
failure.pydef add(a, b): return a + b

def check(expr_desc, got, want):
    status = "PASS" if got == want else "FAIL"
    print(f"{status}: {expr_desc}  got={got!r} want={want!r}")

check("add(2, 2)", add(2, 2), 5)     # FAIL — shows 4 vs 5, like pytest does
check("add(2, 3)", add(2, 3), 5)
FAIL: add(2, 2)  got=4 want=5
PASS: add(2, 3)  got=5 want=5
▶ How this works

Passing tests are boring; the skill that matters is reading a failure. This little program imitates what pytest prints when a test fails, so you learn to read the two numbers that matter: what your code actually produced (got) versus what you expected (want).

  1. check(...) is a helper we wrote to mimic pytest: it takes a description, the value your code got, and the value you want, then compares them.
  2. The line that decides pass or fail is the comparison got == want: if they're equal it labels the line PASS, otherwise FAIL.
  3. It then prints a line showing both values. The !r inside the f-string just prints them in a precise, quote-showing form — handy so 4 and "4" don't look identical.
  4. The first call, add(2, 2), is meant to fail: 2+2 is 4, but we told it we want 5. The second call is a genuine pass. Running both lets you compare the two output lines side by side.

What the output means: FAIL: add(2, 2) got=4 want=5 is the failure line — it says your code returned 4 but you expected 5. Real pytest shows this same got-vs-want information; reading it tells you whether the bug is in your code or in your expectation.

Try this: Fix the expectation: change the 5 in the first check(...) call to 4 and re-run. Both lines now say PASS — you just "fixed" a failing test by correcting a wrong expectation.

4 · The test pyramid intermediate

Imagine a suite that's all end-to-end tests. Every test spins up the whole system — database, web server, the works — clicks through a real user flow, and takes thirty seconds. You have two hundred of them. A full run takes over an hour, they fail intermittently because a network hiccup counts as a failure, and when one does fail it says "something in the checkout flow broke" without telling you where. Developers stop running the suite because it's too slow and too noisy, and a suite nobody runs protects nothing. That miserable outcome has a name — the inverted pyramid — and it's the most common way test suites go bad.

The test pyramid is the shape that avoids it. Picture a triangle: wide at the bottom, narrow at the top, with three layers. The bottom, widest layer is unit tests — each one exercises a single function in isolation, runs in milliseconds, and when it fails it points at the exact function that broke. Because they're so cheap and precise, you can afford hundreds of them, so they form the broad base. The middle layer is integration tests — these check that a few parts work together (your code plus a real database, say), which is slower and touches more, so you write fewer. The narrow tip is end-to-end tests — these drive the whole system like a user would, are the slowest and flakiest, and pinpoint the least, so you keep only a precious handful covering your most critical journeys.

Why this specific shape rather than "just test everything at every level"? Because each layer trades speed and precision for realism, and you want most of your feedback to come from the fast, precise end. A unit test that fails tells you the answer in one second; an e2e test that fails sends you hunting through the whole stack. The pyramid says: catch as many bugs as you can with cheap unit tests, use integration tests to catch the bugs that only appear when parts meet, and reserve slow e2e tests for proving the critical paths actually work front-to-back. The mistake to avoid is chasing realism by writing mostly e2e tests because "they test what the user sees" — you end up with the slow, flaky, un-diagnosable inverted pyramid, and the whole team quietly loses the fast feedback that made testing worth doing.

wide fast base of unit tests, narrow slow tip of end-to-end end-to-end whole system · slow · few integration parts together · seconds · some unit one function · milliseconds · many slower, more real faster, more precise Many cheap unit tests, a few costly end-to-end ones. Width equals count: the base is broad because unit tests are fast and precise, the tip is narrow because e2e tests are slow and flaky. Flip the shape and you get the dreaded inverted pyramid.
🗺️ How to read this diagram

Read the triangle bottom to top, and treat the width of each band as how many tests of that kind you should have.

  • The wide green base is unit tests: each checks one function, runs in milliseconds, and pinpoints exactly what broke — so you can afford hundreds, which is why the band is widest.
  • The purple middle is integration tests: they check a few parts working together (code plus a real database, say), are slower, and touch more — so you write fewer, and the band narrows.
  • The narrow amber tip is end-to-end tests: they drive the whole system like a user, are slowest and flakiest, and localize the least — so you keep only a handful for critical journeys. The dashed arrow on the right shows the trade: moving up buys realism but loses speed and precision.

In short: most feedback should come from the fast, precise base; a top-heavy suite is the slow, flaky inverted pyramid nobody runs.

LevelTestsSpeedHow many
Unitone functionmsmany
Integrationparts togethersecondssome
End-to-endwhole systemslowfew

5 · Professional — organize the suite professional

Convention: a tests/ folder mirroring src/, files test_*.py, config in pyproject.toml. pytest discovers it all.

A test suite that isn't organized rots the same way a junk drawer does. At three tests, layout doesn't matter — you can keep them anywhere. At three hundred, spread across a project with no convention, nobody can answer the simplest question: "where's the test for this module, and does it even have one?" People stop adding tests because they can't find where the existing ones live, duplicates creep in, and the suite slowly stops reflecting the code. Structure is what keeps a growing suite discoverable, and discoverability is what keeps people actually using it.

The convention the Python world settled on is deliberately boring, and boring is the point — predictability is the feature. Tests live in a tests/ directory that mirrors your source layout, so src/orders.py is tested by tests/test_orders.py. Given any source file you can name its test file without looking, and given any test you can name the code it covers. That one-to-one mapping is what lets tooling automatically flag a module that has no test at all — you can't enforce "every module is tested" if there's no rule for where a module's test would be.

The other half of organizing is telling pytest how to behave once, in a config file, instead of remembering flags every run. The standard home is pyproject.toml — the same file that already describes your project — under a [tool.pytest.ini_options] section that pytest reads automatically. Here you pin down where tests live (testpaths), what flags always apply (addopts), and which custom labels, or markers, are allowed. Markers let you tag tests — slow, say — so you can run just the fast ones during development and the whole set in CI. A small but valuable guard is --strict-markers, which turns a mistyped marker into an error instead of a silently-ignored typo. The point of all this isn't bureaucracy: it's that a new teammate can clone the repo, type pytest with no arguments, and everything just runs the way the team intended — because the layout and the config carry the knowledge, not tribal memory.

the tests/ tree mirrors src/ — every module has a predictable test file src/ orders.py auth.py report.py tests/ test_orders.py test_auth.py — missing — no matching file → this module is untested, and the layout makes that obvious One source file, one predictable test file. Mirroring tests/ onto src/ means src/orders.py is always tested by tests/test_orders.py — and a module with no matching test file (here report.py) stands out immediately.
🗺️ How to read this diagram

Read it as two columns — your source files on the left, their test files on the right — and follow each arrow across to see the one-to-one mapping.

  • The left column is src/: the ordinary modules you write (orders.py, auth.py, report.py). The right column is tests/, holding one test_*.py file per module.
  • The solid blue arrows are the healthy pairings: orders.py → test_orders.py, auth.py → test_auth.py. Because the name is mechanical, you can name any module's test file without looking.
  • The dashed arrow to the red box is the payoff: report.py has no matching test file. That gap is only detectable because the layout is predictable — the mirror is what lets you (or tooling) flag an untested module.

In short: a mirrored tests/ tree turns "is this tested?" into a lookup, and a missing counterpart is a visible red flag.

config · pyproject test config
pyproject.toml[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
markers = ["slow: long-running tests (deselect with -m 'not slow')"]
▶ How this works

Once you have more than a handful of tests, you tell pytest where they live and how to behave, once, in a config file. pyproject.toml is the standard place; pytest reads this section automatically every time you run it, so nobody has to remember flags.

  1. The header [tool.pytest.ini_options] is the section name pytest looks for. Everything under it configures pytest.
  2. testpaths = ["tests"] tells pytest to only look inside a folder called tests — the usual convention is a tests/ folder that mirrors your source code.
  3. addopts lists flags applied to every run, so pytest alone behaves like pytest -q --strict-markers. --strict-markers makes pytest error on any label you didn't declare — a guard against typos.
  4. markers declares custom labels (here slow) you can attach to tests so you can, for example, skip slow ones during quick runs.

Try this: Drop this into a real project's pyproject.toml, put your test files in a tests/ folder, and just run pytest — no flags needed, because the config supplies them.

6 · Tech-lead — a test strategy tech-lead

A lead defines what to test and to what depth so effort goes where risk is. A simple, codified policy beats vague "write more tests." Here's a risk model you can actually compute.

"Aim for 100% coverage" sounds responsible and is usually a trap. Testing effort is not free — every test costs time to write and time to maintain, and time is the one thing a team never has enough of. Spend it evenly across the codebase and you pour hours into exhaustively testing a logging helper that would barely matter if it broke, while the payments module that could cost real money gets the same attention as everything else. A lead's job isn't to demand more tests; it's to point the finite testing budget at the code where a bug would hurt most. That means having an opinion, written down, about what deserves rigor.

The cleanest way to form that opinion is to think about risk, and risk has two ingredients that multiply. The first is blast radius: if this code fails, how much breaks with it? A shared payments path can take down every checkout in the product; a one-off reporting script fails alone. The second is change rate: how often does this code get edited? Code that never changes rarely grows new bugs, but code touched every week is a moving target where regressions hide. Multiply the two and you get a risk score, because the truly dangerous code is what's both load-bearing and constantly changing — high blast radius times high churn. A stable, isolated module scores low even if it's important, because nobody's poking it.

Turning that score into a coverage target makes the policy concrete and, crucially, reviewable. Instead of "test more," you get "payments requires 95% coverage, reporting 70%, throwaway scripts 50%" — numbers anyone on the team can check a pull request against, and numbers that respond when a module's risk changes. That's the difference between a strategy and a slogan: a strategy is something you can compute and defend. The lab below encodes exactly this — a small function that reads blast radius and change rate and returns a required coverage percent. The trap it steers you around is treating coverage as a virtue in itself; coverage is a proxy for confidence, and confidence should be highest exactly where a failure would cost the most, not spread thin as a vanity metric.

two 1–5 ratings multiply into a risk score → a coverage tier blast radiusrating 1–5 change raterating 1–5 risk = blast × rate risk ≥ 16 → 95%payments risk ≥ 9 → 85%auth risk ≥ 4 → 70%reporting else → 50%scripts high blast × high churn = the dangerous code → the most coverage Coverage is computed from risk, not decreed. Blast radius times change rate is a single risk score; thresholds turn it into a required coverage percent, so payments (high × high) demands ~95% while a throwaway script lands at 50%.
🗺️ How to read this diagram

Read it left to right: two inputs on the left combine in the middle, and the score is sorted into a tier on the right.

  • The two left boxes are the inputs a lead judges — blast radius (how much breaks if this fails) and change rate (how often it's edited), each rated 1–5.
  • The amber middle box multiplies them into one risk number. Multiplying (not adding) is deliberate: something dangerous has to be both load-bearing and frequently changed.
  • The right column is the thresholds that map risk to a required coverage percent — the highest band (red, ~95%) is where payments lands, the lowest (green, 50%) is where a throwaway script lands. Effort follows risk, not a flat "100% everywhere."

In short: blast radius × change rate → a risk score → a coverage tier, so the most dangerous code gets the most testing and glue code stays light.

Python · a risk-based coverage policy (runs)
strategy.py# Decide required coverage from a module's risk (blast radius x change rate).
def required_coverage(blast_radius, change_rate):   # each 1-5
    risk = blast_radius * change_rate
    if risk >= 16: return 95      # core, changes often -> near-total
    if risk >= 9:  return 85
    if risk >= 4:  return 70
    return 50                     # low-risk glue -> light

modules = {
    "payments":  (5, 4),   # huge blast radius, changes often
    "auth":      (5, 2),
    "reporting": (2, 3),
    "scripts":   (1, 1),
}
for name, (b, c) in modules.items():
    print(f"{name:10} -> require {required_coverage(b, c)}% coverage")
payments   -> require 95% coverage
auth       -> require 85% coverage
reporting  -> require 70% coverage
scripts    -> require 50% coverage
▶ How this works

This is a tech-lead's answer to "how much should we test each part?" Instead of testing everything equally, it scores each module by risk and turns that score into a required coverage target — so effort lands where a bug would hurt most. It's ordinary Python, no testing library needed.

  1. required_coverage(blast_radius, change_rate) takes two 1–5 ratings: how much breaks if this module fails (blast radius), and how often it changes (change rate). It multiplies them into a single risk number.
  2. The chain of if checks maps that risk to a coverage percent: very high risk demands 95, and it steps down to 50 for low-risk "glue" code. Higher risk, more testing required.
  3. modules is a dictionary pairing each module name with its (blast_radius, change_rate) pair — the real judgement calls a lead makes.
  4. The for loop walks every module, calls the function, and prints the required coverage. The {name:10} just pads the name to 10 characters so the output lines up in a neat column.

What the output means: Each line pairs a module with its required coverage — payments needs 95% (high blast radius, changes often) while scripts needs only 50%. The point: the policy is computed and consistent, not a vague "write more tests."

Try this: Change reporting to (4, 4) and re-run. Its risk jumps and the required coverage rises — you've just watched a testing policy respond to a change in risk.

Strategy > volume"100% coverage everywhere" wastes effort on low-risk code and still misses edge cases. A lead targets coverage by risk, requires tests on high-blast-radius modules, and lets glue code be light. That's how you get reliability without drowning in tests.

Exercise TQ1.1 — Test + triage

Context: Validation logic is the natural first thing to test, and the risk model tells you where to spend effort next — pairing a small test suite with a triage of what to test more.

Your task: Write is_valid_email(s) covered by four or more assertions, then score three modules of a project you know with the risk model and state the coverage tier you would require for each.

Requirements:

  • is_valid_email handles valid addresses, a missing @, a missing domain, and the empty string
  • At least four assertions, one per case above
  • Reuse the blast-radius / change-frequency risk model on three real modules
  • For each module, state the required coverage tier and one sentence of justification
  • Both the test and the triage run/read offline — no live project needed

💡 Hint: Pick modules with genuinely different risk profiles (a payments path vs. a logging helper) so the tiers you assign actually differ.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Your first assertionBeginner

Context: A test is nothing more than code that checks other code. Before any framework, the bare habit is to call a function and state, in code, what its answer must be.

Your task: Write add(a, b) and back it with a couple of plain assert statements that pin down its expected results — no pytest, no test runner, just Python.

Requirements:

  • Use the built-in assert statement, not a framework
  • Assert at least one positive case (e.g. add(2, 3) == 5)
  • Assert a case that crosses zero (e.g. add(-1, 1) == 0)
  • Print a confirmation line only after every assertion has passed
  • The whole thing runs offline as an ordinary script

💡 Hint: An assert that does not raise is a claim about your code that just held — that silent success is the entire point.

Show solution

The whole idea, in two lines:

def add(a, b):
    return a + b

assert add(2, 3) == 5
assert add(-1, 1) == 0
print("all assertions passed")

An assert that doesn't raise means the code did what you claimed. This is the habit that lets you change code fearlessly.

Exercise 2 · A unittest test case (runs without pytest)Intermediate

Context: Bare asserts do not scale — you want named cases, a runner, and a pass/fail report. The stdlib unittest ships with Python and runs anywhere, so it is the zero-dependency way to write a real test.

Your task: Wrap the add check in a unittest.TestCase with two separate test methods, and run it with the built-in runner.

Requirements:

  • Subclass unittest.TestCase
  • Give it two distinct test methods (e.g. positives and mixed signs)
  • Use self.assertEqual rather than a bare assert
  • Invoke unittest.main(verbosity=2) under a __main__ guard so each test name prints
  • The suite runs offline with no third-party packages

💡 Hint: The pytest version of this is just plain functions with assert; the assertions themselves are identical, so what you learn here transfers.

Show solution

unittest ships with Python — a first test that runs offline:

import unittest

def add(a, b): return a + b

class TestAdd(unittest.TestCase):
    def test_positives(self):
        self.assertEqual(add(2, 3), 5)
    def test_mixed_signs(self):
        self.assertEqual(add(-1, 1), 0)

if __name__ == "__main__":
    unittest.main(verbosity=2)
# test_mixed_signs (...) ok
# test_positives (...) ok
# Ran 2 tests ... OK

The pytest equivalent is just plain functions with assert; the assertions are identical.

Exercise 3 · Read a failure and fix itAdvanced

Context: A failing test is not a nuisance — it is a precise bug report you wrote in advance. Watching a test go red and then flip to green is the core loop of all testing.

Your task: Write a deliberately buggy median, let an assertion catch it, then fix the function so the same assertion passes — demonstrating the red→green flip.

Requirements:

  • The first median mishandles the even-length case (returns the wrong element)
  • Catch the AssertionError and print the actual value so the failure localizes the bug
  • The failing assertion targets an even-length list (e.g. median([1,2,3,4]) == 2.5)
  • The fixed version averages the two middle elements for even-length input
  • Show the odd-length case still returns the single middle element
  • Print a RED line before the fix and a GREEN line after

💡 Hint: The even-length branch is exactly the edge the test pins down — average the two central values only when the length is even.

Show solution

The failure message localizes the defect:

def median(xs):
    xs = sorted(xs)
    n = len(xs)
    # BUG: integer division picks the wrong element for even-length lists
    return xs[n // 2]

try:
    assert median([1, 2, 3, 4]) == 2.5, f"got {median([1,2,3,4])}"
except AssertionError as e:
    print("RED:", e)     # RED: got 3

def median_fixed(xs):
    xs = sorted(xs); n = len(xs); m = n // 2
    return xs[m] if n % 2 else (xs[m-1] + xs[m]) / 2

assert median_fixed([1, 2, 3, 4]) == 2.5
assert median_fixed([1, 2, 3]) == 2
print("GREEN: fixed")

The even-length case is exactly the edge the test pinned down — a failing test is a precise bug report you wrote in advance.

Exercise 4 · Classify tests into the pyramidExpert

Context: The test pyramid says a healthy suite has many fast unit tests, fewer integration tests, and fewest end-to-end tests. An inverted pyramid — mostly slow e2e — is the classic anti-pattern.

Your task: Given a suite of tests each tagged by type, write a check that asserts the suite is pyramid-shaped: unit ≥ integration ≥ e2e.

Requirements:

  • Count tests per type (a Counter over the tags is enough)
  • Define is_pyramid() returning whether unit ≥ integration ≥ e2e
  • Assert the healthy suite passes and print the per-type counts
  • Give the assertion a message naming the failure ("top-heavy: too many slow tests")
  • The check turns "are we pyramid-shaped?" into a runnable test

💡 Hint: You are comparing three tallies, not the tests themselves — reduce the tagged list to counts first, then compare them in order.

Show solution

Shape the suite so most tests are fast and cheap:

from collections import Counter

suite = ["unit", "unit", "unit", "unit", "integration", "integration", "e2e"]
c = Counter(suite)

def is_pyramid(c):
    return c["unit"] >= c["integration"] >= c["e2e"]

print(dict(c))            # {'unit': 4, 'integration': 2, 'e2e': 1}
assert is_pyramid(c), "suite is top-heavy: too many slow tests"
print("pyramid OK")

An inverted pyramid (mostly slow e2e tests) is the classic anti-pattern — the assertion turns "are we pyramid-shaped?" into a check.

Exercise 5 · Organize the suite by responsibilityProfessional

Context: As a suite grows, discoverability keeps it maintainable: anyone should be able to find (or create) the test file for a given module without hunting. A predictable source→test mapping is what lets tooling enforce that every module has a test.

Your task: Document a project layout and naming convention that groups tests by the module they cover, then write a helper that maps a source path to its test path.

Requirements:

  • State the convention (e.g. src/orders.py → tests/test_orders.py)
  • Write test_path_for(src_path) that derives the test path from a source path
  • Strip the directory and the .py extension, then prefix the module with test_
  • Place the result under a tests/ directory
  • Assert the mapping on at least two example paths
  • Use os.path so the split works portably

💡 Hint: Split the path into directory and filename, drop the extension to get the module name, and reassemble as tests/test_<module>.py.

Show solution

Discoverable structure keeps a growing suite maintainable:

# Layout:
#   src/orders.py      -> tests/test_orders.py
#   src/pricing.py     -> tests/test_pricing.py
# Convention: test_<module>.py, one test class per public class.

import os

def test_path_for(src_path):
    d, fname = os.path.split(src_path)
    module = fname[:-3] if fname.endswith(".py") else fname
    return os.path.join("tests", f"test_{module}.py")

assert test_path_for("src/orders.py") == "tests/test_orders.py"
assert test_path_for("src/pricing.py") == "tests/test_pricing.py"
print("mapping OK")

A predictable source→test mapping means anyone can find (or create) the right test file, and tooling can enforce that every module has one.

Exercise 6 · A risk-based test strategyIndustry scenario

Context: Tech-lead reality: not everything deserves equal testing effort. A component's blast radius (how much breaks if it fails) and its change frequency together say how much rigor it warrants — and where you are dangerously under-tested.

Your task: Encode a risk-based strategy that assigns a required coverage tier from a component's blast radius and change frequency, then audit a component list to flag high-risk items lacking the tests they need.

Requirements:

  • required_tier(blast_radius, change_freq) maps the two low/med/high inputs to critical/standard/light
  • Higher combined blast-plus-churn yields a higher required tier
  • audit() returns the names of components whose required tier is critical but which lack critical-tier tests
  • A high-blast, high-churn component (e.g. payments) is flagged; a low/low one (e.g. logging) is not
  • The strategy is explicit and reviewable, not a gut call

💡 Hint: Rank each dimension 0/1/2 and add them, then threshold the sum into tiers — the audit is just a filter for critical components missing their tests.

Show solution

Spend testing effort where failure hurts most:

def required_tier(blast_radius, change_freq):
    # blast_radius, change_freq in {"low","med","high"}
    rank = {"low": 0, "med": 1, "high": 2}
    score = rank[blast_radius] + rank[change_freq]
    return "critical" if score >= 3 else "standard" if score >= 1 else "light"

COMPONENTS = [
    {"name": "payments", "blast": "high", "freq": "high", "has_critical_tests": False},
    {"name": "logging",  "blast": "low",  "freq": "low",  "has_critical_tests": False},
]
def audit(components):
    gaps = []
    for c in components:
        tier = required_tier(c["blast"], c["freq"])
        if tier == "critical" and not c["has_critical_tests"]:
            gaps.append(c["name"])
    return gaps

print(required_tier("high", "high"))   # critical
print("under-tested:", audit(COMPONENTS))   # ['payments']

Payments (high blast radius, high churn) is flagged as needing critical-tier tests; logging is fine with light coverage. The strategy is explicit and reviewable, not vibes.

✓ Checkpoint — you can move on when you can…

  • Explain what tests buy you and the pyramid.
  • Write assertions and a pytest test.
  • Read a failure's expected-vs-got.
  • Set risk-based testing depth for a codebase.

Knowledge check check yourself

✓ Knowledge check

This lesson's risk model computes required coverage from blast_radius × change_rate. Why does a lead target coverage by risk instead of mandating 100% everywhere?

Show answer
Because "100% coverage everywhere" wastes effort on low-risk glue code and still misses edge cases. Scoring each module by risk (how much breaks if it fails, times how often it changes) sends test effort where a bug would hurt most — e.g. payments needs ~95% while scripts needs only ~50%.
✓ Knowledge check

In the test pyramid the lesson describes, why should a suite have many unit tests but few end-to-end tests?

Show answer
Unit tests exercise one function, run in milliseconds, and pinpoint failures, so you can afford many of them. End-to-end tests exercise the whole system, are slow, and are harder to diagnose, so you keep only a few — the pyramid is wide at the fast/cheap unit base and narrow at the slow e2e top.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in