Testing

Medium40 min read

pytest — The De-Facto Standard

Why Testing Matters

The Problem: Untested code is broken by default — you just haven't found the bug yet. Refactoring becomes terrifying without a safety net.

The Solution: pytest's plain-assert syntax, fixtures, parametrize, mocking, and coverage make writing tests cheap and reading failures painless.

Real Impact: Teams that test seriously refactor confidently, ship more often, and spend less time firefighting in production.

Real-World Analogy

Think of tests as guardrails on a mountain road:

  • Unit test = a guardrail on one curve — catches a specific kind of mistake
  • Fixture = the rest stop with snacks and tools needed for the next stretch
  • Parametrize = the same guardrail extended across every similar curve
  • Mock = a cardboard cutout of the cliff so you can practice without falling
  • Coverage = the map showing which roads still don't have guardrails

The standard library ships unittest, but pytest has won the ecosystem. Better assertions, fixtures, plugins, and zero boilerplate.

(.venv) $ pip install pytest pytest-cov

Your First Test

# myapp/math.py
def add(a, b):
    return a + b

# tests/test_math.py
from myapp.math import add

def test_add():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -1) == -2
(.venv) $ pytest -v
tests/test_math.py::test_add PASSED
tests/test_math.py::test_add_negative PASSED

pytest Conventions

Fixtures

Fixtures provide reusable setup. Declare them with @pytest.fixture and inject by name.

import pytest

@pytest.fixture
def sample_user():
    return {"name": "Alice", "age": 30}

def test_user_name(sample_user):
    assert sample_user["name"] == "Alice"

Fixtures with Setup & Teardown

@pytest.fixture
def tmp_db():
    db = Database(":memory:")
    db.migrate()
    yield db          # test runs here
    db.close()        # teardown

Scopes — Reuse Across Tests

@pytest.fixture(scope="session")
def api_client():
    client = create_client()
    yield client
    client.close()
# Scopes: function (default), class, module, session

conftest.py

Place fixtures in conftest.py to share across all tests in the directory.

Parametrize

Run the same test against many inputs.

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

4 separate test cases, each shown individually in output. Failures point to the exact parameters.

Marking Tests

@pytest.mark.skip(reason="not yet implemented")
def test_feature(): ...

@pytest.mark.skipif(sys.version_info < (3, 12), reason="needs 3.12+")
def test_new_syntax(): ...

@pytest.mark.xfail             # expected to fail (known bug)
def test_broken(): ...

@pytest.mark.slow              # custom marker
def test_long_running(): ...
$ pytest -m "not slow"      # skip slow tests

Testing Exceptions

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_error_message():
    with pytest.raises(ValueError, match="invalid email"):
        validate_email("not-an-email")

Mocking

unittest.mock ships with the standard library. Use mocker from pytest-mock for cleaner integration.

from unittest.mock import patch, MagicMock

def test_get_user():
    with patch("myapp.api.requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"name": "Alice"}
        result = get_user(42)
    assert result["name"] == "Alice"
    mock_get.assert_called_once_with("https://api.example.com/users/42")

monkeypatch — pytest's built-in

def test_env(monkeypatch):
    monkeypatch.setenv("API_KEY", "test123")
    monkeypatch.setattr("myapp.api.requests.get", fake_get)
    ...

Mock at the import point

If myapp.api does from requests import get, you must patch myapp.api.get, NOT requests.get. Patch where the name is looked up, not where it lives.

Coverage

(.venv) $ pytest --cov=myapp --cov-report=term-missing
--------- coverage: platform darwin, python 3.12.3 ----------
Name                Stmts   Miss  Cover   Missing
-------------------------------------------------
myapp/core.py          45      2    96%   34-35
myapp/cli.py           28      0   100%

100% coverage is not the goal — meaningful tests are. But coverage reports highlight forgotten branches.

🎯 Practice Exercises

Exercise 1: First test suite

Pick a small function (calculator, validator). Write 5 tests covering happy path and edge cases.

Exercise 2: Parametrize

Convert a function with 10 test cases into one parametrized test.

Exercise 3: Fixture chain

Build a fixture for a temp DB, another that uses it to seed sample data. Use both in a test.

Exercise 4: Mock external API

Test a function that calls a public API. Mock requests.get to return fixed JSON. Verify the function processes it correctly.