This lesson takes pytest further than the introduction in testing Python code. It focuses on writing tests that catch real bugs: deciding what a test should check, making sure pytest actually finds your tests, covering boundary values with @pytest.mark.parametrize, and asserting exceptions precisely with pytest.raises.
import pytest
@pytest.mark.parametrize("items, expected", [(0, 0), (6, 1), (7, 2)])
def test_trays_needed(items, expected):
assert trays_needed(items, capacity=6) == expectedThe running example is a bakery function that works out how many trays a batch of pastries needs. Install pytest in your project's virtual environment with python -m pip install pytest.
Test contracts
A unit test checks one observable behaviour for a controlled input: given 13 pastries and trays of 6, the function returns 3. Write the expected result down before writing the assertion, from the specification rather than by running the code and copying its output, or you'll enshrine whatever bug the code has.
Good tests follow a simple structure, often called Arrange, Act, Assert:
def test_partly_filled_tray_counts_as_a_whole_tray():
items, capacity = 13, 6 # arrange
result = trays_needed(items, capacity) # act
assert result == 3 # assertTest the public behaviour, not private details. A test that checks which helper function was called, or the exact internal data structure, fails whenever you refactor, even when the results are still correct. Name tests after the behaviour they protect, so a failure report reads like a broken requirement.
Discovery
pytest finds tests by convention:
- files named
test_*.pyor*_test.py, - functions named
test_*at module level, - methods named
test_*inside classes namedTest*(without an__init__).
bakery/
trays.py
tests/
test_trays.py
pyproject.tomlRun pytest from the project root, pytest tests/test_trays.py for one file, pytest -k capacity for tests whose names match, and pytest -x to stop at the first failure. pytest -v lists every test by name.
A green run can be misleading if nothing was collected. A file named trays_test_cases.py or a function called check_capacity is silently ignored, and pytest reports no tests ran. Check the summary line for the number collected. Settings such as testpaths and pythonpath can be recorded under [tool.pytest.ini_options] in pyproject.toml, so everyone runs the same configuration.
Parameterization
Most bugs live at boundaries: zero, one, exactly full, one over, the largest allowed value. parametrize runs one test function with many input sets, reporting each case separately:
@pytest.mark.parametrize(
"items, capacity, expected",
[
(0, 6, 0), # nothing to bake
(1, 6, 1), # a single pastry still needs a tray
(6, 6, 1), # exactly full
(7, 6, 2), # one over
(13, 6, 3),
],
ids=["empty", "one", "exactly-full", "one-over", "typical"],
)
def test_trays_needed(items, capacity, expected):
assert trays_needed(items, capacity) == expectedThe ids appear in the output, as in test_trays_needed[exactly-full] PASSED, so a failure is identified at a glance. Only testing typical values like 13 would never reveal an off-by-one mistake such as items // capacity + 1, which is wrong for exactly 6. For floating-point results, compare with pytest.approx(expected) rather than ==.
Expected failures
pytest.raises asserts that a block raises a particular exception. Add match= to check the message too, which is matched as a regular expression:
def test_negative_items_are_rejected():
with pytest.raises(ValueError, match="Invalid tray request"):
trays_needed(-1, 6)The test fails if no exception is raised, or if a different type is raised. Keep only the call that should fail inside the with block; anything else that raises the same type would make the test pass for the wrong reason.
Avoid writing your own try/except in tests:
try:
trays_needed(-1, 6)
except Exception:
pass # passes even if the function raised TypeError, or nothing at allFor known, temporary problems, @pytest.mark.xfail(reason="...") marks a test as expected to fail, and @pytest.mark.skipif(condition, reason="...") skips it where it can't run, such as on another operating system. Both show up in the summary, so they don't go unnoticed.
Independent practice and a worked check
Task: write parametrized pytest cases for the tray function and a raises test for invalid capacity.
import pytest
def trays_needed(items, capacity):
if items < 0 or capacity < 1:
raise ValueError("Invalid tray request")
return (items + capacity - 1) // capacity
@pytest.mark.parametrize("items, capacity, expected",
[(0, 4, 0), (4, 4, 1), (5, 4, 2), (12, 4, 3)])
def test_trays(items, capacity, expected):
assert trays_needed(items, capacity) == expected
def test_zero_capacity_is_invalid():
with pytest.raises(ValueError, match="Invalid"):
trays_needed(3, 0)Running pytest on this file reports 5 passed tests: four parametrized cases and one raises test. For the worked check, testing an exact capacity boundary exists to detect off-by-one mistakes, where a formula is correct for most inputs but wrong at the edge.
Reference and next practice
The pytest documentation covers parametrize, raises and discovery rules. Continue with mocking and fixtures. Practise with the Python exercises and Python quiz.
Unit Testing and pytest Summary
- Test contracts: A unit test checks an observable result for a controlled input.
- Discovery: pytest discovers conventionally named test files and functions.
- Parameterization: Parametrized cases check several boundary inputs using one declared test contract.
- Expected failures: pytest.raises verifies that a specific invalid operation raises the intended exception.
Unit Testing and pytest Examples
Each example shows the code, the output it produces and what each part does.
Unit Testing and pytest in a small program
A plain assert checks the function directly; the test file versions follow below.
def trays_needed(items, capacity):
if items < 0 or capacity < 1:
raise ValueError("Invalid tray request")
return (items + capacity - 1) // capacity
assert trays_needed(13, 6) == 3
print("Tray calculation checked")Tray calculation checkedLine-by-line explanation
- Lines 2 to 3Invalid input is rejected with a
ValueError. - Line 4Adding
capacity - 1before floor division rounds up: 13 pastries need 3 trays of 6. - Line 6The assertion passes silently, so execution reaches the print.
A complete test module
Save the function as trays.py and the tests as test_trays.py, then run pytest -v. The output below is trimmed: pytest also prints a header, a progress percentage on each line and the run time.
# test_trays.py
import pytest
from trays import trays_needed
@pytest.mark.parametrize("items, expected", [(0, 0), (1, 1), (6, 1), (7, 2)],
ids=["empty", "one", "full", "one-over"])
def test_tray_boundaries(items, expected):
assert trays_needed(items, 6) == expected
@pytest.mark.parametrize("items, capacity", [(-1, 6), (5, 0)])
def test_invalid_requests_raise(items, capacity):
with pytest.raises(ValueError, match="Invalid tray request"):
trays_needed(items, capacity)test_trays.py::test_tray_boundaries[empty] PASSED
test_trays.py::test_tray_boundaries[one] PASSED
test_trays.py::test_tray_boundaries[full] PASSED
test_trays.py::test_tray_boundaries[one-over] PASSED
test_trays.py::test_invalid_requests_raise[-1-6] PASSED
test_trays.py::test_invalid_requests_raise[5-0] PASSED
6 passedLine-by-line explanation
- Lines 5 to 6Four boundary cases with readable ids.
- Lines 10 to 13Both invalid inputs must raise
ValueErrorwith the expected message; pytest generates ids from the values.
Common Unit Testing and pytest Mistakes
Catching the exception yourself instead of using pytest.raises
def test_rejects_negative():
try:
trays_needed(-1, 6)
except Exception:
pass # passes even if nothing is raiseddef test_rejects_negative():
with pytest.raises(ValueError):
trays_needed(-1, 6)Testing only typical values
def test_trays():
assert trays_needed(13, 6) == 3 # an off-by-one bug at exactly 6 goes unnoticed@pytest.mark.parametrize("items, expected", [(5, 1), (6, 1), (7, 2), (13, 3)])
def test_trays(items, expected):
assert trays_needed(items, 6) == expectedUnit Testing and pytest in Real Projects
pytest in professional projects
pytest is the most widely used Python testing framework, and most open-source projects run it in continuous integration on every pull request, often across several Python versions. Teams add plugins for coverage (pytest-cov), parallel runs (pytest-xdist), timeouts and property-based testing with Hypothesis, which generates hundreds of inputs automatically to find edge cases humans miss.
A common workflow when fixing a bug is to write a failing parametrized case that reproduces it, fix the code, and keep the case forever as a regression test.
Unit Testing and pytest FAQ
Why does pytest say "no tests ran"?
pytest didn't find any tests. Check that files are named test_*.py or *_test.py, functions start with test_, test classes start with Test and have no __init__, and that you're running pytest from the right folder.
How do I run a single test with pytest?
Give its node ID: pytest tests/test_trays.py::test_tray_boundaries. For one parametrized case add the id in brackets, pytest "tests/test_trays.py::test_tray_boundaries[full]", or use -k full to select tests by name.
What does pytest.raises match do?
match is a regular expression searched for in the exception's message. The test passes only if the right exception type is raised and its message matches. Escape special characters such as brackets, or use re.escape() for literal text.
How many test cases should I write for a function?
Cover each distinct behaviour: a typical case, each boundary (zero, one, exactly at a limit, just over it), and each kind of invalid input. Parametrize keeps that compact. Coverage reports show untested lines, but a covered line can still be wrong at an untested boundary.
Can pytest run unittest tests?
Yes. pytest collects and runs unittest.TestCase classes as well as its own plain test functions, so existing unittest suites can be migrated gradually.
Unit Testing and pytest Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Why test an exact capacity boundary?
Which review concern is valid for unit testing and pytest?
Unit Testing and pytest Quiz
2 questions to check what you learned.
Why test an exact capacity boundary?
- To guarantee every possible input
- To detect off by one mistakes
- Python ignores the distinction
Which statement correctly describes parameterization?
- Parametrized cases check several boundary inputs using one declared test contract.
- Only the example output matters, not the rule
- Every operation has the same behavior