A test is a small program that runs your code and complains if the answer is wrong. This post covers
pytest, which is what most projects use, and unittest, which ships with Python and is what
you will meet in code that had no dependencies to spare.
The shape of a test
def apply_fee(balance, fee=2.50):
if balance < fee:
return balance
return round(balance - fee, 2)
def test_fee_is_deducted():
assert apply_fee(100.00) == 97.50
def test_fee_is_skipped_when_it_cannot_be_covered():
assert apply_fee(1.00) == 1.00
test_fee_is_deducted()
test_fee_is_skipped_when_it_cannot_be_covered()
print("both passed") # Output: both passed
That is a complete pytest test file. Functions named test_*, a plain
assert, no base class and no imports. pytest discovers and runs them; the two calls at the
bottom exist only so this block runs on its own.
Name the test after the behaviour, not the function. test_fee_is_skipped_when_it_cannot_be_covered
tells you what broke from the failure output alone, which is the entire point of the name.
Arrange, act, assert
class Account:
def __init__(self, balance):
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
self.balance += amount
def test_deposit_increases_the_balance():
account = Account(100) # arrange
account.deposit(50) # act
assert account.balance == 150 # assert
test_deposit_increases_the_balance()
print("passed") # Output: passed
Set up the world, do one thing, check one outcome. A test that acts twice is testing two behaviours and will not tell you which one broke.
Running it
pip install pytest
pytest # everything under the current directory
pytest -v # one line per test
pytest tests/test_money.py::test_fee_is_deducted # exactly one
pytest -x # stop at the first failure
pytest -k "fee" # only tests whose name matches
pytest finds files named test_*.py and functions named test_* inside them.
Put them in a tests/ directory mirroring your package.
Testing that something raises
import pytest
from bank.services import deposit
def test_rejects_a_negative_deposit():
with pytest.raises(ValueError, match="must be positive"):
deposit(100, -5)
pytest.raises as a context manager reads as "this block must raise". If it does not, the
test fails — which is the check people forget, leaving a validation rule that silently stopped
validating.
match= is a regex against the message. Use it, or a test for
ValueError will pass on a different ValueError raised for an entirely
unrelated reason.
Parametrising instead of copy-pasting
import pytest
from bank.models import normalise
@pytest.mark.parametrize("raw,expected", [
(" Alice@Bank.TEST ", "alice@bank.test"),
("BOB@bank.test", "bob@bank.test"),
("carol@bank.test", "carol@bank.test"),
])
def test_email_is_normalised(raw, expected):
assert normalise(raw) == expected
Three test cases, one function. pytest runs it once per row and names each run after its arguments, so a failure tells you exactly which input broke — much better than one test with three asserts, where the first failure hides the rest.
Fixtures
import pytest
@pytest.fixture
def account():
"""A fresh account for each test that asks for one."""
return {"balance": 100.0}
def test_deposit(account):
account["balance"] += 50
assert account["balance"] == 150
def test_starts_clean(account):
assert account["balance"] == 100
A fixture is set-up code you request by naming it as a parameter. It runs fresh for every test that
asks, which is what keeps tests independent — test_starts_clean passes even though
test_deposit modified its own copy, and it would pass in any order.
That independence is the property that decides whether a suite stays trustworthy. Tests that share state pass in the order you wrote them and fail when someone adds a sixth one in the middle, and the resulting hunt is miserable. If a test only passes after another test has run, it is not a test.
The built-in tmp_path fixture gives you a temporary directory, which is how you test
file handling without touching real data.
unittest, from the standard library
The bank app this track draws on uses unittest deliberately — it ships with Python, so
the suite runs anywhere with no install step:
def test_rounds_half_up_not_half_even(self):
self.assertEqual("2.35", str(round_money(Decimal("2.345"))))
def test_rejects_text_that_is_not_a_number(self):
with self.assertRaises(ValueError):
parse_money("abc")
Subclass unittest.TestCase, name methods test_*, and use
self.assertEqual rather than a bare assert. setUp runs before
every test. Run it with python3 -m unittest discover -s tests.
Its assertion methods are wordier than pytest's plain assert, and its failure output is
less helpful. Choose pytest for new work; recognise unittest when you meet it.
Temporary files, not real ones
Tests that touch real data are tests you cannot run twice. pytest's tmp_path fixture
hands you an empty directory that is deleted afterwards:
import csv
def load_accounts(path):
with open(path, newline="", encoding="utf-8") as handle:
return list(csv.DictReader(handle))
def test_reads_a_name_containing_a_comma(tmp_path):
file = tmp_path / "accounts.csv"
file.write_text(
'id,name\n1,"Cooper, Alice"\n',
encoding="utf-8",
)
rows = load_accounts(file)
assert rows[0]["name"] == "Cooper, Alice"
tmp_path is a Path, fresh per test, so two tests writing
accounts.csv cannot collide. The same idea without pytest is
tempfile.TemporaryDirectory(), which is what the bank app's suite uses — it builds an
entire CSV "database" in a temp directory in setUp, so every test starts from identical
data and none of them can reach the real files.
What is worth testing
Not everything, and chasing a coverage percentage produces tests that assert the code does what the code does. Test in this order:
- The rules. Anything with a
raisein it, anything with a threshold, a limit or a "must be". These are what your program is for. - The edges. Zero, empty, negative, one item, the boundary itself. Bugs live here.
- Every bug you fix. Write the test that fails first, then fix it. That test is what stops the bug coming back.
Skip getters, skip framework code you did not write, and be suspicious of a test that needs ten lines of setup — that is usually the design telling you the function has too many dependencies.
Next: Debugging.