Arrange, act, assert and good test names
You can write tests. This lesson is about writing ones that are still helping you in a year — because a bad test suite is worse than none, and the difference is mostly structure and naming.
Arrange, act, assert
Every test does three things, and separating them makes tests readable at a glance:
def test_discount_reduces_the_amount():
# Arrange — set up the inputs
amount = 1000
percent = 10
# Act — do the one thing under test
result = apply_discount(amount, percent)
# Assert — check what happened
assert result == 900.0
You will not write the comments in practice. The shape stays anyway, and blank lines between the three sections are usually enough.
Why it matters: a test mixing setup and assertions throughout is hard to read, and usually a sign it is checking several things at once.
Act should be one line. If it is five, the test is checking a workflow rather than a behaviour, and when it fails you will not know which step broke.
One behaviour per test
def test_expense():
e = Expense(250, "food")
assert e.amount == 250
assert e.category == "food"
assert e.with_gst() == 295.0
with pytest.raises(ValueError):
Expense(-50, "food")
One test, four unrelated checks. Problems:
- The first failure stops the rest, so you fix one thing, re-run, and find another
test_expensefailing tells you nothing about what is wrong- Its name cannot describe what it does, because it does four things
Split them:
def test_stores_amount_and_category(): ...
def test_adds_gst_to_the_amount(): ...
def test_rejects_a_negative_amount(): ...
Now a failure names the broken behaviour before you read a line of output.
This does not mean one assert per test. Several assertions about one
behaviour are fine:
def test_from_dict_builds_a_complete_expense():
e = Expense.from_dict({"amount": "250", "category": "food"})
assert e.amount == 250.0
assert e.category == "food"
One behaviour — building from a dictionary — checked properly.
Naming
The name is read far more often than the body, usually in a failure report at an inconvenient moment.
Poor:
def test_1(): ...
def test_discount(): ...
def test_works(): ...
Better:
def test_discount_of_ten_percent_reduces_amount_by_a_tenth(): ...
def test_discount_over_100_percent_raises_value_error(): ...
def test_discount_of_zero_leaves_the_amount_unchanged(): ...
Long names are fine here. You never call these, so the only cost is reading them, and they are earning that by describing the behaviour.
A useful pattern: what, under what conditions, with what result.
test_withdraw_more_than_balance_raises_insufficient_funds needs no body to be
understood.
When a test fails at 3pm on a Friday, that name is the first thing you see.
Test the edges
The middle of the range is rarely where bugs live. Cover:
Boundaries. Zero, one, the maximum, one either side.
@pytest.mark.parametrize("percent", [0, 100])
def test_accepts_the_extremes(percent): ...
@pytest.mark.parametrize("percent", [-0.01, 100.01])
def test_rejects_just_outside(percent): ...
Empty. Empty list, empty string, empty file. sum([]) / len([]) is module
6's ZeroDivisionError, and an empty collection is the input people forget.
def test_total_of_no_expenses_is_zero():
assert total([]) == 0
One. Plenty of code works for many and fails for one — off-by-one errors hide there.
Wrong types and missing values. None where a number was expected.
The unhappy path. Most bugs reported by users are in error handling, because that is the code least exercised during development.
A quick way to find cases: for each parameter, ask what the smallest, largest, emptiest and most obviously wrong value would be.
Independence
Tests must not depend on each other or on order.
expenses = [] # shared between tests — do not
def test_add():
expenses.append(Expense(250, "food"))
assert len(expenses) == 1
def test_total():
assert total(expenses) == 250 # only passes if the other ran first
Run test_total alone and it fails. Reorder them and they fail. Add a third and
everything shifts.
Use a fixture, which gives each test a fresh copy:
@pytest.fixture
def expenses():
return [Expense(250, "food")]
The rule: any test must pass when run on its own. pytest tests/test_x.py::test_name
runs exactly one, and is the quick way to check.
Do not repeat the implementation
def test_gst():
e = Expense(250, "food")
assert e.with_gst() == round(250 * 1.18, 2)
That re-implements the function in the test. If the formula is wrong, both are wrong in the same way and the test passes.
Write the expected value out:
def test_gst_on_250_is_295():
assert Expense(250, "food").with_gst() == 295.0
Work the number out yourself, by hand, once. A test containing the same expression as the code is testing that Python can do arithmetic.
Test behaviour, not structure
def test_internals():
account = BankAccount("Priya", 100)
account.deposit(50)
assert account._transactions[0] == "Deposited 50"
That asserts on a private attribute and a message format. Rename the list or reword the string — changing nothing a user sees — and the test fails.
def test_deposit_increases_the_balance():
account = BankAccount("Priya", 100)
account.deposit(50)
assert account.balance == 150
Tests on the public surface survive refactoring, which is what tests are for. A suite that breaks every time you tidy something discourages tidying — exactly backwards.
How much to test
Coverage tools report which lines ran:
python -m pip install pytest-cov
pytest --cov=src
Useful for finding code no test touches at all. Unreliable as a target: 100% coverage proves every line executed, not that anything was checked correctly.
A reasonable standard:
- Business logic and calculations — thoroughly, including edges
- Error handling — the unhappy paths
- Glue code — lightly
- Trivial code — not at all
Chasing a number produces tests written to touch lines rather than to check behaviour.
Check your work
Splitting the four-assertion test. With them combined, the first failure
stops the rest, so you fix one thing, re-run, and find another. And
test_expense failing tells you nothing. Split:
def test_stores_amount_and_category(): ...
def test_adds_gst_to_the_amount(): ...
def test_rejects_a_negative_amount(): ...
Now a failure names the broken behaviour before you read any output.
Renaming. test_1 → test_discount_of_ten_percent_reduces_amount_by_a_tenth.
Long names are fine here: you never call these, so the only cost is reading
them, and they earn it by describing the behaviour. The pattern: what, under
what conditions, with what result.
The boundaries. 0 and 100 must be accepted; −0.01 and 100.01 must be rejected. Boundaries are where bugs live — the middle of a range rarely breaks.
The empty case.
def test_total_of_no_expenses_is_zero():
assert total([]) == 0
sum([]) is 0, so this passes without special handling — but the test is
what tells you that, and it is the input people forget.
Order-dependent tests. Run test_total alone and it fails, because the
shared list is empty. Any test must pass when run on its own —
pytest path::test_name is the quick check.
Re-implementing the formula.
assert e.with_gst() == round(250 * 1.18, 2) # tests nothing
assert e.with_gst() == 295.0 # tests the behaviour
If the formula is wrong, the first version is wrong in the same way and passes. Work the number out by hand, once.
Asserting on _transactions breaks when you rename the list or reword the
message — changing nothing a user sees. Tests on the public surface survive
refactoring, which is what tests are for. A suite that breaks every time you
tidy something discourages tidying, which is exactly backwards.
Coverage finds code no test touches. It is unreliable as a target: 100% proves every line ran, not that anything was checked correctly.
Practice
- Rewrite a test with explicit arrange/act/assert sections.
- Take a test with four unrelated assertions and split it. Break one thing and compare the reports.
- Rename three vague tests to describe behaviour fully.
- For
apply_discount, write boundary tests at 0, 100, −0.01 and 100.01. - Write a test for an empty list input. Make it pass.
- Write two order-dependent tests using a shared list. Confirm they fail when run alone, then fix them with a fixture.
- Write a test that re-implements the formula, then rewrite it with a hand-calculated value.
- Write a test asserting on a private attribute. Rename the attribute and watch it break for no real reason. Rewrite it against the public surface.
- Run
pytest --covon something you have written. Find one untested branch and test it.
Next: tools that catch problems before your tests even run.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship