Testing with pytest and unittest

Python · Practical · 12 min

Tests let you change code confidently; pytest is the de facto standard in the Python ecosystem.

Python ships unittest in the standard library, but most projects use pytest (pip install pytest) because tests are plain functions using plain assert. Put tests in files named test_*.py, functions named test_*, and run pytest from the project root. When an assertion fails, pytest rewrites it to show the values involved, so assert total == 10 reports what total actually was.

Core features:

  • pytest.raises(ValueError, match="positive") as a context manager asserts that code raises
  • @pytest.mark.parametrize("a,b,expected", [...]) runs one test over many input sets
  • fixtures are functions decorated with @pytest.fixture that provide setup (and teardown via yield); a test receives one by naming it as a parameter
  • built-in fixtures like tmp_path (a fresh temporary directory) and monkeypatch (temporarily patch attributes or environment variables)
  • pytest.approx compares floats with tolerance

Useful flags: -x stop at the first failure, -k name select tests by name, -q quiet, --lf rerun last failures.

unittest organises tests as methods on unittest.TestCase subclasses with methods like assertEqual and assertRaises; pytest runs those too, so you can mix. unittest.mock (Mock, patch) is the standard way to replace collaborators such as network calls, and works with either runner.

Aim tests at behaviour, not internals: test what a function returns or does for a given input, including edge cases (empty input, boundaries, invalid values). Fast, isolated tests are the ones people actually run.

Example

# test_pricing.py  -- run with:  pytest -q
import pytest
def apply_discount(price: float, percent: float) -> float:
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return round(price * (1 - percent / 100), 2)

def test_basic_discount():
    assert apply_discount(100, 25) == 75
@pytest.mark.parametrize("price, percent, expected", [
    (10, 0, 10), (10, 100, 0), (19.99, 10, 17.99),
])
def test_edges(price, percent, expected):
    assert apply_discount(price, percent) == pytest.approx(expected)
def test_rejects_bad_percent():
    with pytest.raises(ValueError, match="between 0 and 100"):
        apply_discount(10, 150)

@pytest.fixture
def config_file(tmp_path):          # tmp_path: built-in fixture
    path = tmp_path / "shop.ini"
    path.write_text("currency = EUR")
    return path
def test_reads_config(config_file):
    assert "EUR" in config_file.read_text()

Remember

  • pytest uses plain assert with detailed failure output
  • Use pytest.raises, parametrize and fixtures like tmp_path
  • unittest.mock replaces external dependencies in tests

Watch out for

  • Testing private implementation details, so every refactor breaks the tests.

Check yourself

  1. How does a pytest test get a fixture named `db`?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/unittest.html