Testing with pytest
1 · The lesson
readSooner or later, a change will break something that used to work. The question is whether you find out before your users do. Tests are how you find out. Tests also let you refactor code with confidence. Without tests, people stop changing code, because nobody can show that a change is safe.
pytest is the Python testing tool. The standard library includes unittest, but pytest needs less code, gives clearer output, and is what most Python projects use. This lesson covers the rules, fixtures, parametrisation, marks, mocking, coverage, and the habits that give you a test suite you can trust.
1. The Rules
pip install pytest
You only need three conventions to start:
- Test files are named
test_*.pyor*_test.py. - Test functions are named
test_*. - Use the built-in
assert. There is noself.assertEqualand no special API. pytest rewrites the assert so the failure message shows you both sides.
# test_math.py def add(a, b): return a + b def test_add(): assert add(2, 3) == 5 def test_add_negatives(): assert add(-1, -1) == -2
Run it:
pytest # auto-discovers test_*.py from the current dir down pytest -v # verbose: one line per test pytest -k "negatives" # filter by substring of test name pytest test_math.py::test_add # one specific test pytest -x # stop at first failure pytest --lf # re-run only last-failed tests
When an assertion fails, pytest shows the actual and expected values, without any assertEqual wrapper. This is the main reason pytest replaced unittest in most projects.
2. Fixtures: Setup, Teardown, Shared State
A fixture is a function decorated with @pytest.fixture that produces a value tests can request by naming it as a parameter. pytest matches the parameter name to the fixture name and injects the result.
import pytest @pytest.fixture def sample_user(): return {"name": "Alice", "email": "s@example.com"} def test_user_has_name(sample_user): assert sample_user["name"] == "Alice" def test_user_has_email(sample_user): assert "@" in sample_user["email"]
Each test gets a fresh sample_user. There is no global state and no copied setup code.
Teardown happens via yield. Anything before the yield is setup, and anything after it is teardown, which runs whether the test passed or not:
@pytest.fixture def db_connection(): conn = connect("sqlite:///:memory:") yield conn conn.close() # always runs
setup added so this can run · defines pytest, connect
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) pytest = _AutoMock('pytest') def connect(*_a, **_kw): print('-> connect() called') return _AutoMock('connect()')
3. Fixture Scopes
By default a fixture runs once per test function. Override with scope=:
| Scope | Lifetime |
|---|---|
function (default) | Once per test. Maximum isolation. |
class | Once per test class. |
module | Once per test file. |
package | Once per test package. |
session | Once for the entire pytest run. |
@pytest.fixture(scope="session") def docker_postgres(): container = start_postgres() yield container.connection_url container.stop()
setup added so this can run · defines start_postgres, pytest
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def start_postgres(*_a, **_kw): print('-> start_postgres() called') return _AutoMock('start_postgres()') pytest = _AutoMock('pytest')
Wider scopes are faster but riskier: one test can change the shared state and affect the tests after it. Use function unless setup is really slow or expensive.
4. Built-In Fixtures Worth Knowing
pytest includes several fixtures for common, repetitive tasks:
def test_writes_a_file(tmp_path): # tmp_path is a new pathlib.Path for this test only, deleted for you later target = tmp_path / "out.txt" target.write_text("hello") assert target.read_text() == "hello" def test_env_var(monkeypatch): # monkeypatch undoes every change after the test monkeypatch.setenv("API_KEY", "test-token") assert load_config().api_key == "test-token" def test_prints_greeting(capsys): print("hello") captured = capsys.readouterr() assert captured.out == "hello\n" def test_logs_warning(caplog): import logging logging.warning("disk low") assert "disk low" in caplog.text
setup added so this can run · defines load_config
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def load_config(*_a, **_kw): print('-> load_config() called') return _AutoMock('load_config()')
tmp_path: a temporary directory for each test, with no manual cleanup.monkeypatch: patch env vars, attributes and dict entries. The changes are undone automatically.capsys: capturestdout/stderr.caplog: capture log records.
Use these instead of writing your own.
5. Parametrise: One Test, Many Cases
Writing five copies of the same test for five different inputs is what @pytest.mark.parametrize is for:
import pytest @pytest.mark.parametrize("a,b,expected", [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), (100, 200, 300), ]) def test_add(a, b, expected): assert add(a, b) == expected
setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def add(*_a, **_kw): print('-> add() called') return _AutoMock('add()') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ '# test_math.py\ndef add(a, b):\n return a + b\n\ndef test_add():\n assert add(2, 3) == 5\n\ndef test_add_negatives():\n assert add(-1, -1) == -2', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
pytest expands this into four tests, named with the parameter values appended. A failure points at the exact row that broke. You can stack parametrize decorators to test every combination of values. This is useful, but the number of tests grows quickly: 3 values × 4 values × 5 values is already 60 tests.
6. Marks: Tagging and Selective Runs
Marks are labels you put on tests so you can include or exclude them in a run.
import pytest @pytest.mark.slow def test_full_integration(): ... @pytest.mark.skip(reason="API endpoint deprecated") def test_old_endpoint(): ... @pytest.mark.xfail(reason="known bug, tracked in #123") def test_known_failure(): assert broken_thing() == 1
setup added so this can run · defines broken_thing
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def broken_thing(*_a, **_kw): print('-> broken_thing() called') return _AutoMock('broken_thing()')
Run a subset:
pytest -m slow # only slow tests pytest -m "not slow" # everything except slow tests
Register custom marks in pyproject.toml (or pytest.ini) to silence warnings:
[tool.pytest.ini_options]
markers = [
"slow: tests that take more than a second",
"integration: hit external services",
]xfail is different from skip: it runs the test and reports it as expected to fail. If it suddenly passes, pytest tells you (XPASS), which is a useful signal that the bug got fixed.
7. Testing Exceptions
pytest.raises asserts that a block raises a specific exception:
import pytest def divide(a, b): if b == 0: raise ZeroDivisionError("cannot divide by zero") return a / b def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): divide(10, 0) def test_divide_by_zero_message(): with pytest.raises(ZeroDivisionError, match="cannot divide"): divide(10, 0)
The match= argument is a regex against the exception message. Without pytest.raises, a test that expected an error and didn't get one would pass without any warning. For a test, that is the worst way to fail.
8. Mocking: Replacing the Outside World
Tests that hit the network, a database, or the clock are slow and flaky. Replace those parts with stand-ins using unittest.mock:
from unittest.mock import patch, MagicMock import requests def fetch_user(user_id): response = requests.get(f"https://api.example.com/users/{user_id}") response.raise_for_status() return response.json() def test_fetch_user(): fake_response = MagicMock() fake_response.json.return_value = {"id": 1, "name": "Alice"} fake_response.raise_for_status.return_value = None with patch("mymodule.requests.get", return_value=fake_response) as mock_get: user = fetch_user(1) mock_get.assert_called_once_with("https://api.example.com/users/1") assert user["name"] == "Alice"
Key rules:
- Patch where the name is looked up, not where it's defined. If
mymoduledoesfrom requests import get, patchmymodule.get, because patchingrequests.getwould not change the namemymodulealready holds. Ifmymoduledoesimport requestsand callsrequests.get(as here),mymodule.requests.getandrequests.getare the same attribute, so either path works. MagicMockcreates any attribute or method you use, automatically. This is convenient, but it also means typos don't raise an error. Configure only what the test depends on.
The pytest-mock plugin wraps this in a fixture:
def test_fetch_user(mocker): mock_get = mocker.patch("mymodule.requests.get") mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"} assert fetch_user(1)["name"] == "Alice"
setup added so this can run · defines fetch_user
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def fetch_user(*_a, **_kw): print('-> fetch_user() called') return _AutoMock('fetch_user()')
Less indentation, same idea.
9. Test Doubles: The Names
When people say "mock" they often mean any stand-in. The precise terms:
- Dummy: passed in but never used. It only fills a parameter.
- Stub: returns fixed, pre-set answers. Nothing checks how it was called.
- Mock: records calls and lets you assert on them.
- Fake: a working but simple implementation (in-memory DB, fake clock).
- Spy: wraps a real object, records what was called, and still makes the real call.
Most pytest mocking is stubs and mocks. Fakes are used less often than they should be. When a database client has only a few methods, an in-memory dict is often better than a mock.
10. conftest.py: Shared Fixtures
Fixtures defined in conftest.py are visible to every test in that directory and below, automatically. No import needed.
tests/
conftest.py # fixtures shared by everything
test_users.py
integration/
conftest.py # extra fixtures for integration tests
test_signup.py# tests/conftest.py import pytest @pytest.fixture def sample_user(): return {"name": "Alice", "email": "s@example.com"}
test_users.py can use sample_user without importing anything. This is the standard place for any fixture used by more than one file. Copy-pasting fixtures across files is a sign you've skipped conftest.py.
11. Coverage
pytest-cov reports which lines your tests ran.
pip install pytest-cov pytest --cov=mypackage --cov-report=term-missing
Name Stmts Miss Cover Missing -------------------------------------------------- mypackage/core.py 42 3 93% 58-60 mypackage/cli.py 18 0 100%
term-missing is the most useful mode: it prints the line numbers your tests didn't run. Generate an HTML report (--cov-report=html) for browsing.
Two warnings:
- Coverage only tells you which lines ran, not whether they were checked. 100% line coverage with weak assertions tells you nothing.
- Don't chase coverage on glue code (CLI entry points,
__main__) at the cost of testing real logic.
12. Property-Based Testing with Hypothesis
Example-based tests check the cases you thought of. Hypothesis generates the cases you didn't.
from hypothesis import given, strategies as st @given(st.lists(st.integers())) def test_sorted_is_idempotent(xs): assert sorted(sorted(xs)) == sorted(xs) @given(st.integers(), st.integers()) def test_add_commutative(a, b): assert add(a, b) == add(b, a)
setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def add(*_a, **_kw): print('-> add() called') return _AutoMock('add()') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ '# test_math.py\ndef add(a, b):\n return a + b\n\ndef test_add():\n assert add(2, 3) == 5\n\ndef test_add_negatives():\n assert add(-1, -1) == -2', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
Hypothesis runs each test with many generated inputs (100 by default), reduces any failing case to the smallest example that still fails, and saves it so that it is tried again on the next run. It finds edge cases you would never have written: empty lists, negative zero, unusual Unicode characters, and special float values like NaN and infinity. Use it on any function whose output depends only on its inputs.
13. The AAA Pattern
Every test has the same three parts:
def test_deposit_adds_to_balance(): # Arrange: set up everything the test needs account = Account(balance=100) # Act: do the one thing you are testing account.deposit(50) # Assert: check the result assert account.balance == 150
setup added so this can run · defines Account
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def Account(*_a, **_kw): print('-> Account() called') return _AutoMock('Account()')
Keep the parts in that order. Use one act per test. When a test does five things and asserts five times, it is harder to see which step failed. Multiple asserts on the same act are fine, but multiple acts in one test are usually two tests.
14. Doctests and the Test Pyramid
Doctests embed examples in docstrings and check them:
def add(a, b): """ >>> add(2, 3) 5 """ return a + b
python -m doctest mymodule.py -v
Doctests are useful for checking that your documentation examples are still correct, but they are not a main testing tool. They break easily, are hard to debug, and don't work well beyond simple examples. Use them for docstring examples that also work as quick smoke tests.
The bigger picture: unit tests test one function or class on its own, integration tests test the places where components connect, and end-to-end tests run the whole system through its real entry points. A healthy suite has many fast unit tests, fewer integration tests, and a handful of end-to-end tests. Mock at the boundaries (network, DB, time), and don't mock your own code.
Common Mistakes
1. Tests that depend on each other
def test_create_user(): create_user("surya") def test_user_exists(): assert get_user("surya") # only works if the previous test ran first
setup added so this can run · defines create_user, get_user
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def create_user(*_a, **_kw): print('-> create_user() called') return _AutoMock('create_user()') def get_user(*_a, **_kw): print('-> get_user() called') return _AutoMock('get_user()')
When one test fails, the tests after it fail too. Use fixtures and separate setup for each test. Every test should run on its own, in any order.
2. Testing implementation, not behaviour
If your test calls obj._internal_helper() and asserts on private state, the test will break as soon as you refactor, even if the public behaviour is unchanged. Test the public API: what a caller sees.
3. Mocking too deep
Replacing a helper inside your own module with a mock means the test no longer exercises the code it claims to. Mock at the boundary (the HTTP call, the database driver, the filesystem) and let your own code run.
4. Asserting nothing meaningful
def test_calculate(): result = calculate(...) assert result # passes for any truthy value, tells you nothing
setup added so this can run · defines calculate
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def calculate(*_a, **_kw): print('-> calculate() called') return _AutoMock('calculate()')
assert result == 42 is the test. assert result is a placeholder you forgot to finish.
5. No conftest.py
If three test files have the same sample_user fixture copy-pasted at the top, you've missed the feature made for this. Move the fixture into conftest.py.
6. Forgetting pytest.raises
def test_invalid_input(): divide(10, 0) # raises ZeroDivisionError, so the test ERRORS instead of passing
setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def divide(*_a, **_kw): print('-> divide() called') return _AutoMock('divide()') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import pytest except Exception: pass _pm_earlier = [ 'import pytest\n\ndef divide(a, b):\n if b == 0:\n raise ZeroDivisionError("cannot divide by zero")\n return a / b\n\ndef test_divide_by_zero():\n with pytest.raises(ZeroDivisionError):\n divide(10, 0)\n\ndef test_divide_by_zero_message():\n with pytest.raises(ZeroDivisionError, match="cannot divide"):\n divide(10, 0)', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
A test that errors looks like a bug in the test. Wrap the call in pytest.raises(ZeroDivisionError) so the error path is the pass condition.
7. Slow tests with no slow marker
The CI run takes 22 minutes and nobody runs the suite locally any more. Mark slow tests with @pytest.mark.slow and skip them when you run tests during development. Run them in the nightly CI job instead.
🎯 Your Turn: Test a divide Function
Given:
import logging def divide(a, b): """Divide a by b. Logs a warning if b is negative.""" if b == 0: raise ZeroDivisionError("cannot divide by zero") if b < 0: logging.warning("dividing by a negative number: %s", b) return a / b
Write test_divide.py containing:
1. A parametrised test with three happy-path cases (positive numerator/denominator).
2. A test that asserts ZeroDivisionError is raised when b == 0, using pytest.raises.
3. A test using the caplog fixture that confirms a WARNING-level log message is emitted when b < 0.
Skeleton:
# test_divide.py import logging import pytest from mymodule import divide # TODO 1: parametrise three (a, b, expected) cases # TODO 2: pytest.raises(ZeroDivisionError) # TODO 3: use caplog to assert a warning was emitted
Hint 1: Parametrise
@pytest.mark.parametrize("a,b,expected", [(10, 2, 5), (9, 3, 3), (0, 5, 0)]). The test function takes the same names as the parameter string.
Hint 2: caplog level
By default,caplog only receives messages at WARNING and above, because that is the root logger's default level. To capture lower levels, call caplog.set_level(logging.DEBUG). Check the captured records via caplog.records (a list) or the joined text via caplog.text.
Show full solution
# test_divide.py import logging import pytest from mymodule import divide @pytest.mark.parametrize("a,b,expected", [ (10, 2, 5.0), (9, 3, 3.0), (0, 5, 0.0), ]) def test_divide_happy_path(a, b, expected): assert divide(a, b) == expected def test_divide_by_zero_raises(): with pytest.raises(ZeroDivisionError, match="cannot divide"): divide(10, 0) def test_divide_logs_warning_for_negative(caplog): with caplog.at_level(logging.WARNING): result = divide(10, -2) assert result == -5.0 assert any( record.levelname == "WARNING" and "negative" in record.message for record in caplog.records )
Three different testing tools (parametrise, raises, caplog) applied to one small function. A real test module looks like this: every behaviour has its own test, each test is independent, and the failure messages tell you exactly which case broke.
What You Learned
- pytest auto-discovers
test_*.pyfiles andtest_*functions.assertis enough, and there is no special API. - Fixtures (
@pytest.fixture) replace setup/teardown.yieldto add teardown. Scopes:function,class,module,session. - Built-in fixtures
tmp_path,monkeypatch,capsys,caplogcover most boilerplate. @pytest.mark.parametrizeruns one test against many inputs. Stacked decorators multiply the number of tests.- Marks (
slow,skip,xfail) tag tests for selective runs. Register custom marks inpyproject.toml. pytest.raisesis how you test error paths. Without it, the test errors instead of passing.- Mock at boundaries: network, DB, time. Patch where the name is looked up, not where it's defined.
conftest.pyholds shared fixtures, no import needed.- Coverage via
pytest-covshows which lines ran, but it is not a goal in itself. - Hypothesis generates inputs you wouldn't think of. Use it on pure functions.
- AAA: Arrange, Act, Assert. One act per test.
Next: Packaging & Publishing to PyPI. Turn your code into something other people can pip install.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.