PythonMastery
intermediate 22 min read · lesson 6 of 12 in Python How-To

Testing with pytest

1 · The lesson

read

Regressions 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

bash
pip install pytest

Three conventions and you're running:

  • Test files are named test_*.py or *_test.py.
  • Test functions are named test_*.
  • Use the built-in assert — no self.assertEqual, 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

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.

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

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

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

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

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

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 import requests and calls requests.get, you patch mymodule.requests.get, not requests.get.
  • MagicMock auto-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:

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

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

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

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

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

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

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()')

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

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 skipped the one feature designed to fix that. Lift it into conftest.py.

6. Forgetting pytest.raises

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

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 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
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 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_*.py files and test_* functions. assert is enough — 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. Stack carefully.
  • 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 is 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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.