What is it? #
A test is code that runs your code and checks the result. When it fails, you find out in seconds instead of after a customer does.
The real payoff is not proving correctness today. It is changing code six months from now without holding your breath. Tests are what make refactoring safe.
pytest is the standard tool. Write a function starting with test_, use a plain assert, run pytest. There is almost no ceremony.
A good test names the situation, exercises one behaviour, and asserts something meaningful. Tests that assert "the function returned something" waste everyone's time.
Think of it like this #
Tests are the smoke alarm in your house. They do not stop fires, and installing them takes an afternoon. But the first time one goes off at 3am while you are asleep — that is when they pay for themselves many times over.
Simple example #
You have a discount function with rules: percentages must be valid, members get an extra cut, and the discount can never make the price negative. Each rule becomes a test, including the ones that should fail.
Code #
# pricing.py
def final_price(amount: float, percent: float = 0, is_member: bool = False) -> float:
if amount < 0:
raise ValueError("amount cannot be negative")
if not 0 <= percent <= 100:
raise ValueError("percent must be between 0 and 100")
if is_member:
percent = min(percent + 5, 100)
return round(max(amount * (1 - percent / 100), 0), 2)
# test_pricing.py
import pytest
from pricing import final_price
def test_no_discount_returns_original_amount():
assert final_price(1000) == 1000
def test_percentage_discount_is_applied():
assert final_price(1000, percent=10) == 900
def test_members_get_an_extra_five_percent():
assert final_price(1000, percent=10, is_member=True) == 850
def test_negative_amount_is_rejected():
with pytest.raises(ValueError, match="amount cannot be negative"):
final_price(-1)
@pytest.mark.parametrize("percent", [-1, 101, 500])
def test_invalid_percentages_are_rejected(percent):
with pytest.raises(ValueError):
final_price(1000, percent=percent)
@pytest.fixture
def sample_cart():
return [{"price": 200}, {"price": 300}]
def test_cart_total_uses_fixture(sample_cart):
total = sum(item["price"] for item in sample_cart)
assert final_price(total, percent=50) == 250
pip install pytest
pytest -q # run everything
pytest -k member # run tests whose name contains "member"
How it works #
pytest discovers files named test_*.py and functions named test_*. There is no base class to inherit and no registration step.
Each test follows arrange-act-assert, even when it is one line: set up the inputs, call the function, check the result. Keeping that shape makes tests readable at a glance.
The test names are full sentences. When one fails in CI, the name alone should tell you what broke — test_members_get_an_extra_five_percent is far more useful than test_price_2.
pytest.raises asserts that a call fails in a specific way. Testing the failure paths is often more valuable than testing the happy path, because that is where the bugs live.
@pytest.mark.parametrize runs the same test with several inputs and reports each separately. It replaces a loop and gives you precise failure messages.
A @pytest.fixture provides reusable setup. pytest sees the parameter name sample_cart, finds the matching fixture, calls it and passes the result in. Fixtures are also where you would set up a temporary database or a test client.
Real-world use #
In a working team, tests run automatically on every push. The CI/CD track covers that pipeline; the practical effect is that a broken change never reaches the main branch unnoticed.
The tests that earn their keep are the ones covering business rules — pricing, permissions, state transitions, validation — and the bugs you have already fixed once. Writing a test that reproduces a bug before fixing it guarantees it stays fixed.
What not to test matters too. Testing that a library works, or that a getter returns what you set, adds maintenance cost without catching anything. Tests are code; every one you write is code you now maintain.
For anything that touches a database or a network, tests usually replace the real thing with a stub or use a disposable test database, so they stay fast and repeatable.
Common mistakes #
- Testing only the happy path. Most bugs hide in edge cases and error handling.
- Writing one giant test with fifteen assertions. When it fails you do not know which rule broke.
- Tests that depend on each other or on the order they run in.
- Asserting on exact log text or formatting that changes often, producing brittle tests.
- Chasing 100% coverage. Coverage measures lines executed, not behaviour verified.
Practice #
Write a function parse_duration("1h30m") that returns total minutes and raises ValueError on bad input. Then write tests for a simple case, a minutes-only case, a hours-only case, and at least two invalid inputs using pytest.raises and parametrize.