Dev Essentials — 15 · Testing Strategy — Unit, Integration, End-to-End, Mocks and Testcontainers
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
pytestfixtures,parametrize,tmp_path,monkeypatchand 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 |
- 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:
- Fast. Milliseconds for unit tests. Slow suites stop being run.
- Deterministic. Same code, same result, every time. No real clock, random seed, network or shared state.
- One reason to fail. When it goes red, the name tells you what broke.
- Independent. It passes alone, in any order, and in parallel.
- 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, nottest_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, somailer.sned(...)(a typo) passes silently. Usecreate_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:
- Wrap the third-party code in a small class you own, such as
PaymentGatewaywithcharge(amount). - Mock or fake your wrapper in unit tests of the business logic.
- 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 oldfrom testcontainers.postgres import PostgresContainerstill works but prints aDeprecationWarning. get_connection_url()includes a SQLAlchemy driver name (postgresql+psycopg2://…) by default;driver=Nonegives a plain URL thatpsycopgaccepts.- Pin the image tag (
postgres:18), neverlatest, 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 3frompytest-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
pagefixture 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 addtime.sleep. --base-url http://localhost:3000lets tests usepage.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:
- Red: write one small failing test for the next bit of behaviour.
- Green: write the simplest code that makes it pass.
- 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
- Martin Fowler — Test Pyramid (2012)
- Kent C. Dodds — The Testing Trophy and Testing Classifications
- Bill Wake — 3A: Arrange, Act, Assert
- Martin Fowler — Mocks Aren't Stubs
- Freeman, Mackinnon, Pryce, Walnes — Mock Roles, not Objects (OOPSLA 2004, PDF)
- pytest documentation and configuration reference
- Python —
unittest.mock - JUnit User Guide
- Testcontainers for Python and for Java
- Google Testing Blog — Flaky Tests at Google and How We Mitigate Them (2016)
- Google Testing Blog — Code Coverage Best Practices (2020)
- pytest-randomly, pytest-rerunfailures, pytest-cov
- Hypothesis — Quickstart
- Playwright for Python — Getting started and pytest plugin reference
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.










