A test is a function that runs your code and fails when the result is wrong. pytest gives you the machinery to collect those functions, report failures clearly, and share setup between them - so the hardest part, writing the assertions, is also the smallest part.
What does pytest look for?
pytest collects any function whose name starts with test_ in a file whose name starts with test_. That is the entire convention. There is no class to inherit from, no @test decorator and no base class - a plain function and a plain assert statement is a test.
# test_math_utils.py
def add(a: int, b: int) -> int:
"""Return the sum of two numbers."""
return a + b
def test_add_returns_the_sum():
assert add(2, 3) == 5
def test_add_handles_negative_numbers():
assert add(-4, 4) == 0
Because the name must start with test_, the function that does the work - here add - lives happily in the same file during the learning phase. In a real project you would move it into your package and import it, so the test exercises the code your users actually run.
How do you run them?
Install pytest once, then run it from the folder that holds your project. python -m pytest collects every test it can find and prints one line per test.
# run everything
python -m pytest
# run everything, showing each test name
python -m pytest -v
# run one file
python -m pytest test_math_utils.py
# run only the tests whose name matches a keyword
python -m pytest -k add
Here is the real output of the verbose run for the file above, once the rest of this page's examples are in place.
collected 8 items
test_math_utils.py::test_add_returns_the_sum PASSED [ 12%]
test_math_utils.py::test_add_handles_negative_numbers PASSED [ 25%]
test_math_utils.py::test_divide_by_zero_raises PASSED [ 37%]
test_math_utils.py::test_sample_file_holds_two_records PASSED [ 50%]
test_math_utils.py::test_increment[1-2] PASSED [ 62%]
test_math_utils.py::test_increment[5-6] PASSED [ 75%]
test_math_utils.py::test_increment[-3--2] PASSED [ 87%]
test_math_utils.py::test_increment[0-1] PASSED [100%]
============================== 8 passed in 0.09s =============================
The header above that block lists your platform and installed plugins, and the timing at the end changes on every run. The part worth reading is the list: each line is one test, and the bracketed text on the parameterised ones is the input that produced it. When a test fails, pytest shows the assertion that broke, the values on both sides of it, and the path to the line that failed - which is why the assert statement should be as plain as possible.
How should a test be written?
Three beats, in this order: arrange the inputs, act by calling the code, assert the outcome. The arrange - act - assert shape is what makes a test readable months later.
def test_discount_applies_to_totals_over_fifty():
cart_total = 80.0
discount_rate = 0.10
final_total = apply_discount(cart_total, discount_rate)
assert final_total == 72.0
One behaviour per test. If a test asserts three unrelated things, a failure tells you only that something broke, and you have to read the code to find out what. The name should describe the behaviour rather than the function - test_discount_applies_to_totals_over_fifty tells you the rule that is being protected, while test_apply_discount tells you nothing.
Compare floats with a tolerance rather than with ==. pytest ships pytest.approx for exactly this.
How do you test that code raises an exception?
Use the pytest.raises context manager. It asserts that the block raises the exception you expect, and it fails the test if nothing is raised.
import pytest
def divide(a: float, b: float) -> float:
"""Return a divided by b."""
if b == 0:
raise ValueError("cannot divide by zero")
return a / b
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="cannot divide"):
divide(1, 0)
The match argument takes a regular expression and checks it against the exception message, so the test verifies the reason for the failure and not just its type. A bare pytest.raises(ValueError) would still pass if the code raised for a completely different reason.
How do you share setup between tests?
Code that runs before every test belongs in a fixture - a function decorated with @pytest.fixture. pytest calls it for each test that asks for it by name and hands over the return value.
import json
import pytest
@pytest.fixture
def sample_file(tmp_path):
"""Write a small JSON file and return its path."""
path = tmp_path / "records.json"
path.write_text(json.dumps([{"id": 1}, {"id": 2}]), encoding="utf-8")
return path
def test_sample_file_holds_two_records(sample_file):
data = json.loads(sample_file.read_text(encoding="utf-8"))
assert len(data) == 2
tmp_path is a built-in fixture that gives you a fresh temporary directory for the test and cleans it up afterwards, so tests never leave files behind and never fight over the same path. Asking for sample_file in the test signature is what triggers the fixture - pytest matches the argument name to the fixture name.
How do you run the same test with many inputs?
Parameterisation turns one test body into many test cases, each with its own row in the report.
import pytest
@pytest.mark.parametrize(
("value", "expected"),
[(1, 2), (5, 6), (-3, -2), (0, 1)],
)
def test_increment(value, expected):
assert value + 1 == expected
The test name in the report shows the parameters - test_increment[1-2], test_increment[-3--2] - so a failure tells you which input broke without opening the file. This is the fastest way to cover a function's edge cases: empty strings, zero, negative numbers and the boundary values, all in a table you can read at a glance.
What to remember
- A test is a function named
test_*containing plainassertstatements. - Run with
python -m pytest; add-vfor names and-kto filter. - Structure every test as arrange, act, assert, with one behaviour per test.
- Use
pytest.raiseswith amatchpattern for error paths, andpytest.approxfor floats. - Share setup with fixtures, use
tmp_pathfor files, and cover edge cases with@pytest.mark.parametrize.
Try it yourself: Take the add function from the first example and write four tests for it: two sums, one negative pair, and one case that checks it refuses a non-number with a TypeError. Run python -m pytest -v and read the report. Then convert the four tests into a single parameterised test and run it again - if the report shows the same four cases, the parameterised version is doing the same work in a quarter of the lines.