What you'll learn
- Write a test: a function named
test_...with a plainassert - Run
pytest, read its output, and understand what a failure report tells you - Test that code raises with
pytest.raises - Lay out a project so tests live next to the code they check and run with one command
Concept
A test is code that calls your code and asserts what should be true afterwards. pytest finds every function whose name starts with test_ in files named test_*.py, runs them, and reports which passed. There is no boilerplate: a test is a function, and a check is the assert keyword. Tests are the only way to change code with confidence, because they tell you within seconds whether the behavior you relied on still holds.

When an assertion fails, pytest rewrites it to show the values on both sides, so assert total([1, 2]) == 4 reports assert 3 == 4 with the call spelled out. That is why plain assert is enough, and why you never need assertEqual-style methods. Read a failure report from the bottom: the assertion line, then the values, then the test's location. One clear assertion per idea makes the report readable; a test that asserts ten unrelated things fails at the first and hides the rest. Around the assertion itself sit a handful of conventions that let pytest find your tests and let a stranger read them: what the files and functions are called, where they live, and the shape each test takes.
| Convention | Why |
|---|---|
files test_*.py or *_test.py |
pytest collects them automatically |
functions def test_...(): |
collected as tests |
tests/ folder next to the package |
separate from shipped code, easy to run all at once |
| one behavior per test | the name and the failure both say what broke |
| arrange, act, assert | set up, call once, check; reads top to bottom |
pytest.raises(ErrorType) |
asserts that a block raises that exception |
Testing failures matters as much as testing success. with pytest.raises(ValueError): wraps a block that must raise; if it does not, the test fails. Add match= to check the message. This is how you test the validation and error paths from the exceptions lessons, and it is often where the important bugs live.
The layout: a package folder with your modules, a tests/ folder next to it with test_<module>.py files, and an empty conftest.py or a pyproject.toml [tool.pytest.ini_options] section so pytest finds the package. Run pytest from the project root and it discovers everything. -v lists each test by name, -x stops at the first failure, -k name runs tests matching a word, and path::test_name runs one test. Tests run in the order found, and each must work alone, so never let one test depend on another having run.

Code
The code under test and its first tests. Each test checks one behavior, and the names say what.
# shop/__init__.py
# shop/pricing.py
def total(prices, tax_rate=0.0):
if tax_rate < 0:
raise ValueError("tax_rate must be non-negative")
return round(sum(prices) * (1 + tax_rate), 2)
def discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError(f"percent out of range: {percent}")
return round(price * (1 - percent / 100), 2)
# tests/test_pricing.py
import pytest
from shop.pricing import discount, total
def test_total_of_empty_list_is_zero():
assert total([]) == 0
def test_total_adds_tax():
assert total([10, 20], tax_rate=0.5) == 45.0
def test_discount_rounds_to_cents():
assert discount(19.99, 15) == 16.99
def test_negative_tax_rate_is_rejected():
with pytest.raises(ValueError, match="non-negative"):
total([1], tax_rate=-0.1)
def test_discount_out_of_range():
with pytest.raises(ValueError):
discount(10, 150)
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
Running the suite. Five dots, five passes; -v names each test.
# run_tests.sh
pytest -q 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
echo "---"
pytest -v --no-header -k discount 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
..... [100%]
5 passed in Ns
---
============================= test session starts ==============================
collecting ... collected 5 items / 3 deselected / 2 selected
tests/test_pricing.py::test_discount_rounds_to_cents PASSED [ 50%]
tests/test_pricing.py::test_discount_out_of_range PASSED [100%]
======================= 2 passed, 3 deselected in Ns ========================A failing test and its report. The assertion is rewritten to show both values, and the location points at the exact line. (Run times are normalized to Ns in these outputs so they stay stable.)
# tests/test_failing.py
from shop.pricing import total
def test_total_with_wrong_expectation():
assert total([10, 20], tax_rate=0.1) == 30
# run_failing.sh
pytest -q tests/test_failing.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
F [100%]
=================================== FAILURES ===================================
______________________ test_total_with_wrong_expectation _______________________
def test_total_with_wrong_expectation():
> assert total([10, 20], tax_rate=0.1) == 30
E assert 33.0 == 30
E + where 33.0 = total([10, 20], tax_rate=0.1)
tests/test_failing.py:5: AssertionError
=========================== short test summary info ============================
FAILED tests/test_failing.py::test_total_with_wrong_expectation - assert 33.0...
1 failed in NsAssertions on collections and floats. pytest.approx handles floating point; rich assertion output shows exactly which items differ.
# tests/test_collections.py
import pytest
from shop.pricing import discount
def test_bulk_discounts():
prices = [10.0, 20.0, 30.0]
assert [discount(p, 10) for p in prices] == [9.0, 18.0, 27.0]
def test_float_comparison():
assert discount(10, 33) == pytest.approx(6.7)
assert 0.1 + 0.2 == pytest.approx(0.3)
def test_shows_the_difference():
assert [discount(p, 10) for p in [10.0, 20.0]] == [9.0, 18.5]
# run_collections.sh
pytest -q tests/test_collections.py 2>&1 | grep -E "passed|failed|^E " | sed -E 's/ in [0-9.]+s/ in Ns/'
E assert [9.0, 18.0] == [9.0, 18.5]
E
E At index 1 diff: 18.0 != 18.5
E Use -v to get more diff
1 failed, 2 passed in NsTry it yourself
Create a textkit/ package with clean(text) and initials(name) from the modules lesson, a tests/test_textkit.py with at least six tests including two that use pytest.raises for bad input (add validation to the functions if needed), and a pyproject.toml with the pytest section. Run pytest -v, then break one function on purpose, run again, and read the report before fixing it.
Common mistakes
- Tests that depend on each other. If
test_bonly passes aftertest_acreated a file, runningtest_balone fails; each test sets up its own world. - One giant test. It stops at the first failed assertion and hides the rest; split by behavior so the names tell the story.
- Testing implementation details. Assert on what the function returns or raises, not on which private helper it called; otherwise every refactor breaks the tests.
What's next
- A test is a
test_function withassert;pytestcollectstest_*.pyfiles and rewrites assertions to show both values. pytest.raisestests error paths;pytest.approxcompares floats;-v,-x, and-kcontrol a run.- Keep code in a package, tests in
tests/, and one line inpyproject.tomlso imports resolve.
You can prove your code works. Next you will remove the repetition from test files with fixtures and run one test over many inputs with parametrize.