Testing with pytest
1 · The lesson
readRegressions are not a possibility — they are a certainty. The only question is whether you find them before your users do. Tests are how you find them, and more importantly, tests are the only reason you'll ever refactor anything with confidence. Code without a test suite calcifies. People stop touching it because nobody can prove a change is safe.
pytest is the Python testing tool. The standard library ships unittest, but pytest is shorter, friendlier, and what every serious codebase uses. This lesson covers the rules, fixtures, parametrisation, marks, mocking, coverage, and the testing habits that separate a suite you trust from one you ignore.
1. The Rules
pip install pytest
Three conventions and you're running:
- Test files are named
test_*.pyor*_test.py. - Test functions are named
test_*. - Use the built-in
assert— noself.assertEqual, 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
pytest -v on a failed assertion shows the actual and expected values inline — no assertEqual wrapper needed. That's the single biggest reason pytest displaced unittest.
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. No global state, no copy-paste setup.
Teardown happens via yield. Anything before the yield is setup, anything after is teardown — 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 — tests can pollute the shared state and contaminate each other. Use function unless setup is genuinely expensive.
4. Built-In Fixtures Worth Knowing
pytest ships several fixtures for the common boring tasks:
def test_writes_a_file(tmp_path): # tmp_path is a fresh pathlib.Path unique to this test, auto-cleaned target = tmp_path / "out.txt" target.write_text("hello") assert target.read_text() == "hello" def test_env_var(monkeypatch): # monkeypatch reverts everything 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— per-test temp directory, no manual cleanup.monkeypatch— patch env vars, attributes, dict entries; reverted automatically.capsys— capturestdout/stderr.caplog— capture log records.
Reach for these before reinventing them.
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 · defines add
# 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()')
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 for a Cartesian product — useful, but keep an eye on combinatorial explosion.
6. Marks — Tagging and Selective Runs
Marks are labels you stick on tests so you can include or exclude them.
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 silently pass — which is the worst possible failure mode for a test.
8. Mocking — Replacing the World
Tests that hit the network, a database, or the clock are slow and flaky. Replace those boundaries 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
mymoduledoesimport requestsand callsrequests.get, you patchmymodule.requests.get, notrequests.get. MagicMockauto-creates any attribute or method you touch — convenient, but means typos pass silently. 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 — A Vocabulary
When people say "mock" they often mean any stand-in. The precise terms:
- Dummy — passed but never used. Fills a parameter slot.
- Stub — returns canned answers. No verification.
- Mock — records calls and lets you assert on them.
- Fake — a working but lightweight implementation (in-memory DB, fake clock).
- Spy — wraps a real object, records what was called, lets the real call through.
Most pytest mocking is stubs and mocks. Fakes are underused — an in-memory dict often beats mocking a database client when the surface area is small.
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 canonical 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 actually executed.
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 useful mode — it prints the line numbers you haven't hit. Generate an HTML report (--cov-report=html) for browsing.
Two warnings:
- Coverage is a floor, not a ceiling. 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 · defines add
# 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()')
Hypothesis runs each test with hundreds of generated inputs, shrinks any failing case to the smallest counter-example, and saves it so the regression sticks. It finds edge cases you would never have written — empty lists, negative zero, surrogate-pair Unicode, sentinel float values. Worth reaching for on any function with a clean input/output contract.
13. The AAA Pattern
Every test has the same three-act shape:
def test_deposit_adds_to_balance(): # Arrange — set up the world account = Account(balance=100) # Act — do the one thing under test 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. One act per test — when a test does five things and asserts five times, debugging which line failed is harder than it should be. Multiple asserts on the same act are fine; 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
Useful for keeping documentation honest, not a primary testing tool — they're brittle, painful to debug, and don't scale beyond trivial examples. Use them for the docstring examples that double as smoke tests.
The bigger picture — unit tests exercise one function or class in isolation, integration tests exercise the seams where components meet, end-to-end tests drive the 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) — 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()')
One test fails and the rest cascade. Use fixtures and isolated setup per test — every test should be runnable 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 the moment you refactor — even if the public behaviour is unchanged. Test the API. Test 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 skipped the one feature designed to fix that. Lift it into conftest.py.
6. Forgetting pytest.raises
def test_invalid_input(): divide(10, 0) # raises ZeroDivisionError — the test ERRORS, doesn't PASS
setup added so this can run · defines divide
# 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()')
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 in the fast-feedback loop — run them in the nightly CI job.
🎯 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 function takes the same names as the parameter string.
Hint 2 — caplog level
caplog captures everything by default but only at WARNING and above unless you 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 primitives — parametrise, raises, caplog — applied to one tiny function. That's the shape of a real test module: every behaviour has its own targeted test, each test is independent, the failure messages tell you precisely which case broke.
What You Learned
- pytest auto-discovers
test_*.pyfiles andtest_*functions.assertis enough — 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. Stack carefully.- 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-covis a floor, not a goal. - Hypothesis generates inputs you wouldn't think of — reach for it on pure functions.
- AAA: Arrange, Act, Assert. One act per test.
Next: Packaging & Publishing to PyPI — turning 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 actually did, not just whether a test passed.