Writing unit tests is one of those practices everyone claims to value and many put off. The reason is understandable: at first, tests look like extra code to write with no immediate benefit. The benefit comes later, when you change a function and ten tests tell you within seconds whether you broke something. At that point tests stop being a cost and become the safety net that lets you change code without fear.
What a unit test is
A unit test checks a small piece of code, usually a function or a method, in isolation from the rest of the system. It should be:
- fast: milliseconds, not seconds, so you can run hundreds of them on every change;
- deterministic: the same test always gives the same result;
- independent: it does not depend on execution order or on other tests;
- readable: whoever reads it immediately understands what is being checked.
If a test touches the real database, the network or the disk, it is no longer a unit test: it is an integration test, useful but different.
The Arrange-Act-Assert structure
A good test almost always follows three phases:
- Arrange: you prepare the data and initial conditions;
- Act: you run the operation you want to check;
- Assert: you verify that the result is the expected one.
Here is an example with pytest, for a function that calculates a discounted price:
import pytest
from shop import discounted_price
def test_percentage_discount_applied_correctly():
price = 100.0 # Arrange
result = discounted_price(price, 20) # Act
assert result == 80.0 # Assert
def test_discount_above_one_hundred_percent_rejected():
with pytest.raises(ValueError):
discounted_price(100.0, 150)
Notice the names: they describe the expected behaviour. When a test fails, the name alone should tell you what is not working.
What to test
There is no need to test every line in the same way. It pays to focus on a few categories:
| Category | Example for discounted_price |
|---|---|
| Normal case | 100 euros with a 20% discount |
| Edge cases | 0% discount, 100% discount, price 0 |
| Invalid input | Negative discount, discount over 100% |
| Rounding | 9.99 euros with a 15% discount |
| Regressions | A bug already fixed that must not come back |
Edge cases are where most errors hide: empty lists, null values, the first and last element, dates spanning midnight or the end of the month. A good habit is to write a test every time you fix a bug: the test reproduces the problem, fails, then the fix makes it pass. That bug will not come back without you noticing.
What not to test
Some things do not deserve dedicated unit tests:
- code from external libraries, which already has its own tests;
- simple assignments or methods that return an attribute with no logic;
- internal implementation details that could change without changing behaviour.
The last point is the most important. Test behaviour, not implementation. If a test checks that a function internally calls a certain private method, it will break at the first refactoring even if the result stays correct. Tests of this kind discourage improving the code instead of protecting it.
Dependencies and test doubles
When a function depends on something slow or unpredictable, such as the current time, an external service or a random generator, you can isolate it with a test double: an object that replaces the real dependency during the test. There are various forms, from simple stubs that return fixed values to mocks that record how they were called.
An often cleaner alternative is to design the code so that the dependency is passed in as a parameter:
def is_expired(expiry_date, today):
return today > expiry_date
This function can be tested with no tricks at all, by passing fixed dates. Code that is easy to test also tends to be well-designed code.
Coverage: an indicator, not a target
Code coverage measures which lines are executed by the tests. It is useful for discovering parts that are never checked, but high coverage does not guarantee good tests: you can execute every line without checking anything meaningful. Use it to find gaps, not as a number to hit at all costs.
Common mistakes
- Tests that depend on each other: if the second only works after the first, sooner or later they will fail inexplicably.
- Too many assertions in one test: when it fails, you do not know which aspect broke.
- Uncontrolled random test data: it makes failures impossible to reproduce.
- Ignoring tests that fail intermittently: a flaky test teaches the team not to trust tests.
In short
- A unit test is fast, deterministic, independent and checks one precise behaviour.
- Organise each test into Arrange, Act and Assert, with a name describing the expectation.
- Focus on normal cases, edge cases, invalid input and bugs already fixed.
- Test behaviour, not implementation details.
- Use coverage to find untested areas, not as a goal in itself.