Tests are how you change code without fear. But more tests is not the goal; the right tests are. This post gives you a strategy: which kinds of tests to write, what makes a test good, and the tools that make it practical. It covers pytest in depth (fixtures, parametrize, tmp_path, monkeypatch), JUnit briefly, test doubles and when not to mock, real databases in tests with Testcontainers, flaky tests, coverage, property-based testing with Hypothesis, end-to-end tests with Playwright, and a short look at TDD. The next post runs all of this automatically in CI.

Series map: 01 Text Encoding → … → 14 API Reliability → 15 Testing (this post) → 16 CI/CD → … → 24 Desk Health. Part C · Building safely.


Learning Outcome

  • Tell unit, integration and end-to-end tests apart, and explain the test pyramid and the testing trophy as models.
  • Write fast, deterministic tests with one reason to fail, in Arrange-Act-Assert form.
  • Use pytest fixtures, parametrize, tmp_path, monkeypatch and markers; read a JUnit 5/6 test.
  • Choose between dummy, stub, fake, spy and mock, and wrap code you do not own.
  • Test against a real PostgreSQL or MongoDB with Testcontainers.
  • Find and fix flaky tests, read coverage as a signal, and add property-based and end-to-end tests.

Chapter 1 — Three kinds of tests

Kind What it checks Speed Typical tool
Unit One function or class, with no network, disk or database milliseconds pytest, JUnit
Integration Your code together with a real neighbour: a database, a queue, the file system, another module seconds pytest + Testcontainers
End-to-end (E2E) The whole system through its real interface, usually a browser or public API seconds to minutes Playwright

The names are fuzzy in practice. Agree on meanings inside your team, and focus on the real question: how much confidence does this test give, for how much time and maintenance?

Terminology

Term Meaning
SUT System under test: the code the test is about.
Test double Any object that stands in for a real one in a test (Chapter 5).
Fixture Setup that a test needs: data, objects, a temp folder, a database.
Flaky test A test that passes and fails on the same code.
Coverage Which lines or branches ran during the tests.
Regression test A test written for a bug you fixed, so it cannot come back.

Chapter 2 — Pyramid or trophy?

Both are models, not laws. They are opinions about where to spend effort.

Layer (top to bottom) Test pyramid (Cohn) Testing trophy (Dodds)
End-to-end / UI very few few
Service / integration some most effort
Unit most tests some
Static (types, lint) not part of the model the base layer

The test pyramid and the testing trophy side by side, layer by layer, both labelled as models rather than laws

  • Test pyramid. Mike Cohn described it in Succeeding with Agile (2009); Martin Fowler's 2012 "TestPyramid" article is a common reference. Many fast unit tests at the bottom, fewer service tests, very few slow UI tests on top.
  • Testing trophy. Kent C. Dodds sketched it in 2018, building on Guillermo Rauch's line: "Write tests. Not too many. Mostly integration." Its layers are static analysis, unit, integration and E2E, with the biggest share on integration, because those tests are closest to how the software is used while still being fairly fast.

Both agree on the important parts: E2E tests are few and precious, static checks (types, lint) are cheap wins, and every test must earn its cost. The trophy fits web apps where most bugs live between components. The pyramid fits libraries and logic-heavy code. Testcontainers (Chapter 6) makes integration tests cheaper than they used to be, which pushes many teams toward the trophy shape.


Chapter 3 — What makes a good test

A good test is:

  1. Fast. Milliseconds for unit tests. Slow suites stop being run.
  2. Deterministic. Same code, same result, every time. No real clock, random seed, network or shared state.
  3. One reason to fail. When it goes red, the name tells you what broke.
  4. Independent. It passes alone, in any order, and in parallel.
  5. Readable. A reader sees the setup, the action and the expected result without jumping around.

The shape that gives you readability is Arrange-Act-Assert (named by Bill Wake in 2001). A bad test mixes everything; a good one has three clear parts:

1
2
3
4
5
6
7
8
9
10
11
def test_everything():                       # BAD: which part broke? you cannot tell
assert with_tax(100) == Decimal("108.00")
assert load_prices(Path("prices.csv")) # depends on a real file in the repo
assert send_invoice(customer_id=42) # sends a real e-mail


def test_with_tax_adds_eight_percent(): # GOOD
amount = 100 # Arrange
result = with_tax(amount) # Act
assert result == Decimal("108.00") # Assert

Trick: name tests as sentences about behaviour: test_negative_amount_is_rejected, not test_with_tax_2. The test report then reads like a specification.


Chapter 4 — pytest essentials

The code under test (shop/pricing.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import os
from decimal import Decimal, ROUND_HALF_UP


def with_tax(amount):
"""Return amount plus tax (TAX_RATE env var, default 8%), rounded to cents."""
if amount < 0:
raise ValueError("amount must be >= 0")
rate = Decimal(os.environ.get("TAX_RATE", "0.08"))
total = Decimal(str(amount)) * (1 + rate)
return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def load_prices(path):
"""Read 'sku,price' lines into a dict of Decimals."""
lines = path.read_text(encoding="utf-8").splitlines()
pairs = (line.split(",") for line in lines if line.strip())
return {sku: Decimal(price) for sku, price in pairs}

(Money uses Decimal, never float; see Dev Essentials 08 · Floating Point.) The tests (tests/test_pricing.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from decimal import Decimal

import pytest

from shop.pricing import load_prices, with_tax


@pytest.mark.parametrize(
("amount", "expected"),
[
(0, "0.00"),
(1, "1.08"),
(19.99, "21.59"), # 21.5892 rounds half up to 21.59
(0.05, "0.05"), # 0.054 rounds down to 0.05
],
ids=["zero", "one", "typical", "tiny"],
)

def test_with_tax_table(amount, expected):
assert with_tax(amount) == Decimal(expected)


def test_negative_amount_is_rejected():
with pytest.raises(ValueError, match="must be >= 0"):
with_tax(-1)


def test_tax_rate_from_env(monkeypatch):
monkeypatch.setenv("TAX_RATE", "0.10") # undone automatically after the test
assert with_tax(100) == Decimal("110.00")


def test_load_prices(tmp_path):
price_file = tmp_path / "prices.csv" # a fresh, empty folder for every test
price_file.write_text("A1,2.50\nB2,10\n", encoding="utf-8")
assert load_prices(price_file) == {"A1": Decimal("2.50"), "B2": Decimal("10")}
1
2
3
4
python -m pytest -q                 # run everything
python -m pytest -k tax -v # only tests whose name contains "tax", verbose
python -m pytest -x --lf # stop at the first failure; run only last run's failures

What each tool does:

Tool Use
plain assert pytest rewrites it to show both sides when it fails.
pytest.raises(E, match=...) The block must raise E with a matching message.
@pytest.mark.parametrize One test function, many cases; each case is reported separately (test_with_tax_table[typical]).
tmp_path A unique pathlib.Path folder per test. No clean-up code, no clashes.
monkeypatch setenv, delenv, setattr, chdir, all undone after the test.
pytest.approx Compare floats with a tolerance: assert 0.1 + 0.2 == pytest.approx(0.3).

Fixtures

A fixture is a function that builds what tests need. Tests ask for it by parameter name. Code after yield is the tear-down:

1
2
3
4
5
6
7
8
9
10
@pytest.fixture
def price_file(tmp_path): # fixtures can use other fixtures
path = tmp_path / "prices.csv"
path.write_text("A1,2.50\n", encoding="utf-8")
yield path # the test runs here; tear-down code would follow


def test_price_of_a1(price_file):
assert load_prices(price_file)["A1"] == Decimal("2.50")
  • scope="function" (default) builds it fresh for every test. scope="session" builds it once for the whole run, which is useful for an expensive database container.
  • Put shared fixtures in tests/conftest.py; pytest finds them without imports.

Configuration and markers

pytest 9 reads native TOML from [tool.pytest] in pyproject.toml. Older versions use [tool.pytest.ini_options], where addopts is one string instead of a list.

1
2
3
4
5
6
[tool.pytest]
minversion = "9.0"
addopts = ["-ra", "--strict-markers"]
testpaths = ["tests"]
markers = ["integration: needs Docker (Testcontainers)"]

Mark slow tests with @pytest.mark.integration, then choose: python -m pytest -m "not integration" for the fast inner loop, everything in CI.


Chapter 5 — JUnit briefly, and test doubles

JUnit in one screen

On the Java side, the current release is JUnit 6 (6.1.3 in August 2026; it needs Java 17+). The Jupiter API you write tests with keeps the org.junit.jupiter.api package from JUnit 5, so JUnit 5 tutorials still apply. Setup is in Java Development p1 / p2.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class PricingTest {
@Test
void addsEightPercent() {
assertEquals(new BigDecimal("108.00"), Pricing.withTax(new BigDecimal("100")));
}

@ParameterizedTest
@CsvSource({"0, 0.00", "1, 1.08", "19.99, 21.59"})
void taxTable(BigDecimal amount, BigDecimal expected) {
assertEquals(expected, Pricing.withTax(amount));
}
}

assertThrows(IllegalArgumentException.class, () -> ...) is the Java version of pytest.raises.

Test doubles

A test double replaces a real collaborator. The names come from Gerard Meszaros (xUnit Test Patterns, 2007), and Martin Fowler's article "Mocks Aren't Stubs" explains them:

Double What it is Example
Dummy Passed around, never used None for a logger the code path never touches
Stub Returns canned answers an exchange-rate function that always returns 1.1
Fake A working but simple implementation an in-memory repository instead of PostgreSQL
Spy A stub that also records how it was called a mailer that stores sent messages in a list
Mock Pre-programmed with expected calls that the test verifies "send must be called once with these arguments"

The service under test takes its collaborators as parameters, including the clock:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from datetime import datetime, timedelta, timezone


class OrderService:
def __init__(self, repo, mailer, clock=lambda: datetime.now(timezone.utc)):
self.repo, self.mailer, self.clock = repo, mailer, clock

def place(self, order_id, email, total):
order = {"id": order_id, "email": email, "total": total, "created": self.clock()}
self.repo.add(order)
self.mailer.send(email, f"Order {order_id} confirmed")
return order

def can_cancel(self, order_id):
"""Orders can be cancelled for 30 minutes."""
return self.clock() - self.repo.get(order_id)["created"] < timedelta(minutes=30)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from unittest.mock import Mock

import pytest

from shop.orders import OrderService


class FakeRepo: # a fake: real behaviour, stored in a dict
def __init__(self):
self.rows = {}

def add(self, order):
self.rows[order["id"]] = order

def get(self, order_id):
return self.rows[order_id]


@pytest.fixture
def service():
return OrderService(repo=FakeRepo(), mailer=Mock())


def test_place_stores_order(service):
service.place("A-1", "ada@example.com", 108)
assert service.repo.get("A-1")["total"] == 108 # state verification


def test_place_sends_confirmation(service):
service.place("A-1", "ada@example.com", 108)
service.mailer.send.assert_called_once_with( # behaviour verification
"ada@example.com", "Order A-1 confirmed"
)

Fowler calls these two styles state verification (check the result) and behaviour verification (check the calls). Prefer state: behaviour checks break when you refactor how the work is done, even if the result is still right. Use a mock when the call is the result, as with sending an e-mail.

Common pitfall: Mock() accepts any attribute, so mailer.sned(...) (a typo) passes silently. Use create_autospec(RealMailer) so the mock has the same methods and signatures as the real class.

"Don't mock what you don't own"

This rule comes from the paper "Mock Roles, not Objects" (Freeman, Mackinnon, Pryce and Walnes, OOPSLA 2004) and the book Growing Object-Oriented Software, Guided by Tests. If you mock requests.get or a database driver directly, your test encodes your guess about how that library behaves. When the library changes, your mocks keep passing and production breaks.

Instead:

  1. Wrap the third-party code in a small class you own, such as PaymentGateway with charge(amount).
  2. Mock or fake your wrapper in unit tests of the business logic.
  3. Test the wrapper itself with an integration test against the real thing, or a real container of it (next chapter).

Chapter 6 — Real databases with Testcontainers

Fakes are fast, but they cannot catch a wrong SQL query, a missing index or a driver quirk. Testcontainers starts a real database in Docker for your tests and removes it afterwards. You need Docker running (Docker Desktop on Windows).

Python

1
2
pip install testcontainers "psycopg[binary]"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# tests/test_pg_repo.py
from decimal import Decimal

import psycopg
import pytest
from testcontainers.community.postgres import PostgresContainer


class PgOrderRepo:
def __init__(self, conn):
self.conn = conn

def add(self, order):
self.conn.execute("INSERT INTO orders (id, total) VALUES (%s, %s)",
(order["id"], order["total"]))

def get(self, order_id):
row = self.conn.execute("SELECT id, total FROM orders WHERE id = %s",
(order_id,)).fetchone()
return {"id": row[0], "total": row[1]}


@pytest.fixture(scope="session")
def pg_url():
with PostgresContainer("postgres:18") as pg: # one container for the whole run
yield pg.get_connection_url(driver=None) # postgresql://test:test@host:port/test


@pytest.fixture
def conn(pg_url):
with psycopg.connect(pg_url, autocommit=True) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS orders (id text PRIMARY KEY, total numeric)")
conn.execute("TRUNCATE orders") # every test starts empty
yield conn


@pytest.mark.integration
def test_save_and_load(conn):
repo = PgOrderRepo(conn)
repo.add({"id": "A-1", "total": Decimal("108.00")})
assert repo.get("A-1")["total"] == Decimal("108.00")
  • In the current release (testcontainers-python 4.15, July 2026), modules live under testcontainers.community.*. The old from testcontainers.postgres import PostgresContainer still works but prints a DeprecationWarning.
  • get_connection_url() includes a SQLAlchemy driver name (postgresql+psycopg2://…) by default; driver=None gives a plain URL that psycopg accepts.
  • Pin the image tag (postgres:18), never latest, so a new major version does not surprise you.

For MongoDB (see the MongoDB Course for the queries themselves):

1
2
3
4
5
6
7
8
9
# pip install "testcontainers[mongodb]"   (brings in pymongo)
from testcontainers.community.mongodb import MongoDbContainer


@pytest.fixture(scope="session")
def mongo_client():
with MongoDbContainer("mongo:8.0") as mongo:
yield mongo.get_connection_client() # a pymongo.MongoClient

Java

Testcontainers for Java 2.x renamed its artifacts (org.testcontainers:testcontainers-postgresql, testcontainers-junit-jupiter) and moved classes to per-module packages. JUnit 4 support was removed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.sql.DriverManager;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@Testcontainers
class OrderRepositoryIT {
@Container // static = one container for all tests in the class
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18");

@Test
void selectOne() throws Exception {
try (var conn = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
var rs = conn.createStatement().executeQuery("SELECT 1")) {
rs.next();
assertEquals(1, rs.getInt(1));
}
}
}

Watch out: the first run downloads the image, which can take a minute. In CI, keep these tests in their own job (Dev Essentials 16 · CI/CD) so the fast unit tests report first.


Chapter 7 — Flaky tests

A flaky test passes and fails on the same code. It is worse than no test, because people learn to ignore red. One large-scale data point (Google, 2016, one company's measurement): about 1.5% of all test runs gave a flaky result, almost 16% of tests showed some flakiness, and about 84% of observed pass-to-fail transitions involved a flaky test.

Cause Symptom Fix
Time Fails near midnight, month end, DST Inject a clock (below); store and compare in UTC (see Dev Essentials 02 · Dates & Time Zones)
Order Passes alone, fails in the full run Remove shared state; find it with pytest-randomly, which shuffles order and prints the seed
Network Fails when a remote API is slow or down Fake the client you own; use a local container instead of a shared server
Shared state Tests leak rows, files, env vars, globals tmp_path, monkeypatch, a fresh table per test (TRUNCATE), no module-level caches
Async / waits sleep(1) is sometimes not enough Wait for a condition, not a time (Playwright's auto-waiting does this)
Randomness Fails 1 run in 50 Fix the seed, or use Hypothesis, which reports the failing input

Inject the clock instead of reading the real one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from datetime import datetime, timedelta, timezone


class FakeClock:
def __init__(self, start):
self.now = start

def __call__(self):
return self.now

def advance(self, **kwargs):
self.now += timedelta(**kwargs)


def test_cancel_window():
clock = FakeClock(datetime(2026, 9, 24, 12, 0, tzinfo=timezone.utc))
service = OrderService(repo=FakeRepo(), mailer=Mock(), clock=clock)
service.place("A-1", "ada@example.com", 108)
clock.advance(minutes=29)
assert service.can_cancel("A-1")
clock.advance(minutes=2)
assert not service.can_cancel("A-1")

To reproduce an order bug that pytest-randomly found, run again with the seed it printed at the start: python -m pytest --randomly-seed=1234.

Common pitfall: automatic retries (pytest --reruns 3 from pytest-rerunfailures) hide flakiness instead of fixing it. Use them only as a short quarantine with a ticket and a deadline.


Chapter 8 — Coverage is a signal, not a goal

1
2
3
pip install pytest-cov
python -m pytest --cov=shop --cov-branch --cov-report=term-missing

The report lists, per file, the percentage of lines and branches that ran, plus the missing line numbers. Missing lines are the useful part: they show code no test even executes.

Coverage cannot tell you whether anything was checked:

1
2
3
def test_with_tax_runs():
with_tax(100) # runs every line on the happy path, asserts nothing

This test raises coverage and catches no bugs. Once a number becomes a target, people write tests like this (Goodhart's law).

Practical guidance:

  • Look at coverage of changed lines in each pull request more than the project total.
  • For a feel of scale, Google's testing blog (2020) gave its internal guideline as 60% "acceptable", 75% "commendable" and 90% "exemplary", and warned against top-down mandates. Treat it as one company's rule of thumb.
  • A red flag is high coverage and many production bugs: the tests run code without checking results.

Chapter 9 — Property-based testing with Hypothesis

Example tests check the cases you thought of. Property-based tests state a rule that must hold for all inputs, and a library generates the inputs. By default Hypothesis tries 100 generated examples per test, and when one fails it shrinks it to a minimal failing case.

A run-length encoder with a hidden bug:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from itertools import groupby


def encode(text):
"""'aaab' -> '3a1b'"""
return "".join(f"{len(list(run))}{ch}" for ch, run in groupby(text))


def decode(code):
out, count = [], ""
for ch in code:
if ch.isdigit():
count += ch
else:
out.append(ch * int(count))
count = ""
return "".join(out)

Example tests like decode(encode("aaab")) == "aaab" pass. The property test does not:

1
2
3
4
5
6
7
from hypothesis import given, strategies as st


@given(st.text())
def test_round_trip(text):
assert decode(encode(text)) == text

Hypothesis soon reports a tiny input such as text='0': encode("0") is "10", and decode reads both characters as one count. The fix is a format that does not mix counts and data, such as a list of (char, count) pairs.

Good properties to look for:

Property Example
Round trip decode(encode(x)) == x, json.loads(json.dumps(x)) == x
Invariant sorted(xs) is in order and has the same items as xs (Counter(result) == Counter(xs))
Oracle a fast function agrees with a slow, obviously correct one
Idempotence normalise(normalise(x)) == normalise(x)

Chapter 10 — End-to-end tests with Playwright

E2E tests drive a real browser through your real app. Keep them few: the main user journeys (sign in, buy, publish), not every edge case.

1
2
3
pip install pytest-playwright
playwright install # downloads the browsers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# tests/e2e/test_home.py
import re

from playwright.sync_api import Page, expect


def test_home_has_title(page: Page):
page.goto("https://playwright.dev/")
expect(page).to_have_title(re.compile("Playwright"))


def test_get_started_link(page: Page):
page.goto("https://playwright.dev/")
page.get_by_role("link", name="Get started").click()
expect(page.get_by_role("heading", name="Installation")).to_be_visible()
1
2
3
python -m pytest tests/e2e --headed --browser firefox          # watch it run
python -m pytest tests/e2e --tracing retain-on-failure # keep a trace of failed tests
  • The page fixture gives each test a fresh, isolated browser context.
  • Locate elements the way users see them (get_by_role, get_by_label, get_by_text), not by CSS classes that change with every redesign.
  • Playwright auto-waits: actions wait until the element is visible, stable and enabled, and expect(...) assertions retry until they pass or time out. Never add time.sleep.
  • --base-url http://localhost:3000 lets tests use page.goto("/") against your own dev server.

Chapter 11 — TDD in brief

Test-driven development (Kent Beck, Test-Driven Development: By Example) is a short loop:

  1. Red: write one small failing test for the next bit of behaviour.
  2. Green: write the simplest code that makes it pass.
  3. Refactor: clean up while all tests stay green.

It works best when the behaviour is clear and the code is logic-heavy (parsers, pricing rules, state machines). It is harder for exploratory UI work. Even if you do not use TDD all the time, one habit is always worth it: when you fix a bug, first write the test that fails because of it (see Dev Essentials 10 · Debugging).


Practice Exercises

Exercise 1 — Name the kind

Classify each test: (a) with_tax(100) returns 108.00; (b) PgOrderRepo saves and loads a row in a Testcontainers PostgreSQL; (c) a browser signs in and sees "Welcome"; (d) OrderService.place with a fake repo and a mock mailer.

Solution

(a) unit, (b) integration, (c) end-to-end, (d) unit: the fake and the mock replace all real neighbours, so it tests one class alone.

Exercise 2 — Parametrize a slug function

Write slugify(title) (lowercase, words joined by -, only a-z0-9) and one parametrized test with at least four cases, including an empty string.

Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import re

import pytest


def slugify(title):
words = re.findall(r"[a-z0-9]+", title.lower())
return "-".join(words)


@pytest.mark.parametrize(("title", "slug"), [
("Hello World", "hello-world"),
(" Many spaces ", "many-spaces"),
("C++ & Python!", "c-python"),
("", ""),
])

def test_slugify(title, slug):
assert slugify(title) == slug

Exercise 3 — Fix a flaky test

This test fails now and then, always right around midnight UTC. Why, and how do you fix it?

1
2
3
4
5
6
7
from datetime import datetime, timezone


def test_created_today(service):
order = service.place("A-1", "ada@example.com", 108)
assert order["created"].date() == datetime.now(timezone.utc).date()
Solution

The service reads the clock once, the test reads it again later. If the first read is 23:59:59.9 and the second is 00:00:00.1, the dates differ. Inject a FakeClock with a fixed time and assert against that same value:

1
2
3
4
5
6
def test_created_uses_clock():
clock = FakeClock(datetime(2026, 9, 24, 23, 59, 59, tzinfo=timezone.utc))
service = OrderService(repo=FakeRepo(), mailer=Mock(), clock=clock)
order = service.place("A-1", "ada@example.com", 108)
assert order["created"] == clock.now

Exercise 4 — Pick the double

Which double fits? (a) A function needs a logger argument, but the tested path never logs. (b) You need get_rate("EUR") to return 1.1. (c) You must prove exactly one SMS is sent. (d) You want repository behaviour without a database.

Solution

(a) dummy, (b) stub, (c) mock (or a spy you assert on), (d) fake.

Exercise 5 — Wrap what you do not own

Your code calls requests.get(url, timeout=5).json()["rate"] in three places, and the tests mock requests.get. Refactor for testability.

Solution
1
2
3
4
5
6
7
8
9
10
11
12
import requests


class RateClient: # the wrapper you own
def __init__(self, base_url):
self.base_url = base_url

def get_rate(self, currency):
resp = requests.get(f"{self.base_url}/rates/{currency}", timeout=5)
resp.raise_for_status()
return resp.json()["rate"]

Business code takes a RateClient (or anything with get_rate) as a parameter. Its unit tests pass a stub: a tiny class whose get_rate always returns 1.1. RateClient itself gets one integration test against a local test server or a container. When requests changes, only that one test needs attention.


Cheat-sheet

Need Code / command
Run tests python -m pytest -q; one file: python -m pytest tests/test_x.py
Filter / stop early -k name, -m "not integration", -x, --lf
Expect an error with pytest.raises(ValueError, match="..."):
Many cases @pytest.mark.parametrize(("a", "b"), [...], ids=[...])
Temp files def test_x(tmp_path):
Env vars / patch monkeypatch.setenv(...), monkeypatch.setattr(obj, "name", value)
Shared setup @pytest.fixture (+ yield for tear-down, scope="session" for expensive things)
Safe mock create_autospec(RealClass); m.method.assert_called_once_with(...)
Real PostgreSQL from testcontainers.community.postgres import PostgresContainer
Real MongoDB from testcontainers.community.mongodb import MongoDbContainer
Java container @Testcontainers + @Container static PostgreSQLContainer ...
Coverage python -m pytest --cov=pkg --cov-branch --cov-report=term-missing
Order bugs pip install pytest-randomly; rerun with --randomly-seed=<seed>
Property test @given(st.text()) from hypothesis
E2E pip install pytest-playwright; playwright install; page fixture + expect

What's next

Dev Essentials 16 · CI/CD runs these tests on every push: a lint job, a Python version matrix, and separate jobs for slow integration tests. After that, Part D begins with how LLMs work.


References

Note: this post is in progress. Planned additions: contract tests between services, mutation testing to check test quality, snapshot testing, and a full worked TDD session.