PythonMastery
intermediate 22 min read · lesson 7 of 14 in Python How-To

Testing with pytest

1 · The lesson

read

Sooner 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

bash
pip install pytest

You only need three conventions to start:

  • Test files are named test_*.py or *_test.py.
  • Test functions are named test_*.
  • Use the built-in assert. There is no self.assertEqual and no special API. pytest rewrites the assert so the failure message shows you both sides.
python
# 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:

bash
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.

python
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:

python
@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=:

ScopeLifetime
function (default)Once per test. Maximum isolation.
classOnce per test class.
moduleOnce per test file.
packageOnce per test package.
sessionOnce for the entire pytest run.
python
@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:

python
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: capture stdout/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:

python
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.

python
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:

bash
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:

toml
[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:

python
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:

python
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 mymodule does from requests import get, patch mymodule.get, because patching requests.get would not change the name mymodule already holds. If mymodule does import requests and calls requests.get (as here), mymodule.requests.get and requests.get are the same attribute, so either path works.
  • MagicMock creates 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:

python
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.

python
tests/
    conftest.py             # fixtures shared by everything
    test_users.py
    integration/
        conftest.py         # extra fixtures for integration tests
        test_signup.py
python
# 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.

bash
pip install pytest-cov
pytest --cov=mypackage --cov-report=term-missing
python
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.

python
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:

python
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:

python
def add(a, b):
    """
    >>> add(2, 3)
    5
    """
    return a + b
bash
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

python
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

python
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

python
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:

python
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:

python
# 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
python
# 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_*.py files and test_* functions. assert is enough, and there is no special API.
  • Fixtures (@pytest.fixture) replace setup/teardown. yield to add teardown. Scopes: function, class, module, session.
  • Built-in fixtures tmp_path, monkeypatch, capsys, caplog cover most boilerplate.
  • @pytest.mark.parametrize runs one test against many inputs. Stacked decorators multiply the number of tests.
  • Marks (slow, skip, xfail) tag tests for selective runs. Register custom marks in pyproject.toml.
  • pytest.raises is 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.py holds shared fixtures, no import needed.
  • Coverage via pytest-cov shows 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.in

Short 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.