Testing in Practice · Foundations: Strategy, pytest and Test Doubles
A Testing Strategy That Pays Off
Decide what to test at which level (unit, integration, end-to-end), write clear tests with arrange-act-assert, and test behaviour rather than implementation.
What you'll build
Tests for a shipping-cost function, written the way that makes test suites valuable: named after the behaviour they check, structured as arrange-act-assert, covering the boundaries where bugs live, and independent of implementation details. Every example in this course is real code run with pytest 9 (and Hypothesis, FastAPI's TestClient and Playwright where noted); the output shown is what it printed.
The idea
- Unit tests check one piece of logic fast and in isolation; integration tests check pieces working together (your code plus a real database, queue or framework); end-to-end tests drive the whole system like a user.
- Many teams aim for lots of fast unit and integration tests and a few end-to-end tests for critical journeys (the "testing pyramid" or, for web apps, the "testing trophy" with more integration tests).
- Test behaviour through public interfaces, not private helpers; refactors shouldn't break good tests.
- Focus on boundaries and edge cases: zero, limits, empty input, invalid input.
The code
# file: shipping.py
def shipping_cost(weight_kg: float, country: str, express: bool = False) -> float:
if weight_kg <= 0:
raise ValueError("weight must be positive")
base = 4.99 if country == "DE" else 9.99
cost = base + max(0, weight_kg - 1) * 1.5
if weight_kg > 20:
cost += 15 # bulky surcharge
return round(cost * (2 if express else 1), 2)
# file: test_shipping.py
import pytest
from shipping import shipping_cost
def test_first_kilo_is_included_in_the_base_price():
assert shipping_cost(1, "DE") == 4.99
def test_each_extra_kilo_costs_1_50():
assert shipping_cost(3, "DE") == 7.99
def test_international_parcels_cost_more():
assert shipping_cost(1, "FR") > shipping_cost(1, "DE")
def test_bulky_surcharge_starts_above_20_kg():
assert shipping_cost(21, "DE") - shipping_cost(20, "DE") == pytest.approx(16.5)
def test_express_doubles_the_price():
assert shipping_cost(2, "DE", express=True) == 2 * shipping_cost(2, "DE")
def test_rejects_non_positive_weight():
with pytest.raises(ValueError, match="positive"):
shipping_cost(0, "DE")
# file: main.py
import pytest
pytest.main(["-q", "--tb=short", "-p", "no:cacheprovider"])
Output
...... [100%] 6 passed in <time>
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Test names like
test_shipping_1that don't say what broke. - Asserting on internal calls and private state, so every refactor breaks tests that found no bug.
- Only happy-path tests; most production bugs are at boundaries and in error handling.
- An inverted pyramid: hundreds of slow, flaky end-to-end tests and few fast ones.
Key points
- Choose the cheapest level that can catch each kind of bug.
- Name tests after behaviour and structure them as arrange-act-assert.
- Test boundaries and error cases, through public interfaces.
Further reading: pytest: getting started · Martin Fowler: the practical test pyramid
Practice questions
Why test behaviour through public interfaces rather than implementation details?
Answer
Tests coupled to internals break on every refactor without finding bugs; behaviour-based tests stay valid while the code changes.
What's the difference between unit, integration and end-to-end tests?
Answer
Unit tests check isolated logic; integration tests check your code with real collaborators (database, framework); end-to-end tests drive the whole system like a user.
Where do bugs tend to hide that tests should target?
Answer
At boundaries (zero, limits, empty and maximum values) and in error handling paths.
Open in the app to mark lessons complete, take notes and track your progress.