PythonTesting with pytest

Testing with pytest

Python ships with a built-in testing framework, unittest, modeled after Java's JUnit. It works, but it is verbose: tests live in classes that inherit from unittest.TestCase, assertions go through special methods like assertEqual and assertTrue, and setup/teardown is wired through setUp/tearDown overrides. pytest is a third-party framework that has become the de facto standard for testing Python code because it removes almost all of that ceremony while producing much more useful failure output.

Why pytest won
  • Plain assert statements — no need to memorize assertEqual, assertIn, assertIsInstance, and dozens of other methods. Write assert x == y and pytest figures out what failed.

  • No boilerplate classes — a test is just a function named test_something. You don't need to inherit from anything.

  • Rich failure introspection — when an assertion fails, pytest rewrites the assert expression to show you the actual values on both sides of the comparison, not just "AssertionError".

  • Fixtures — a dependency-injection style mechanism for setup/teardown that is far more flexible and reusable than setUp/tearDown.

  • A huge plugin ecosystem — coverage reporting (pytest-cov), mocking helpers (pytest-mock), parallel test runs (pytest-xdist), Django/Flask integrations, and hundreds more.

  • pytest can also run existing unittest-style test suites unchanged, which made migration painless — teams could adopt it without rewriting anything.

Installing pytest

Bash
pip install pytest
A plain test function

pytest discovers tests automatically: any file named test_*.py or *_test.py, and inside it any function named test_*, is treated as a test. There is no class, no base class, and no special import beyond what you're actually testing.

Python
# calculator.py
def add(a, b):
    return a + b


def divide(a, b):
    if b == 0:
        raise ValueError("cannot divide by zero")
    return a / b

Python
# test_calculator.py
import pytest

from calculator import add, divide


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


def test_divide():
    assert divide(10, 2) == 5


def test_divide_by_zero_raises():
    with pytest.raises(ValueError):
        divide(10, 0)

Notice pytest.raises is used as a context manager to assert that a specific exception is thrown — no assertRaises method call needed.

Fixtures: dependency-injection style setup

A fixture is a function decorated with @pytest.fixture that prepares something a test needs — a database connection, a temp file, a sample object — and optionally cleans it up afterward. Any test function that declares a parameter with the same name as the fixture automatically receives its return value. pytest resolves this wiring for you; there is no manual setUp/tearDown lifecycle to manage.

Python
import pytest


@pytest.fixture
def sample_cart():
    cart = {"apples": 3, "bananas": 2}
    yield cart  # code after yield runs as teardown
    cart.clear()


def test_cart_has_apples(sample_cart):
    assert sample_cart["apples"] == 3


def test_cart_total_items(sample_cart):
    assert sum(sample_cart.values()) == 5
Note
Using `yield` instead of `return` in a fixture splits it into a setup phase (before `yield`) and a teardown phase (after `yield`), which runs even if the test fails.
Parametrized tests

@pytest.mark.parametrize runs the same test function multiple times with different inputs, so you avoid copy-pasting near-identical test cases.

Python
import pytest

from calculator import add


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

pytest reports each parameter combination as its own test result, so a failure tells you exactly which inputs broke.

Running your tests

Bash
pytest                  # discover and run every test in the project
pytest test_calculator.py   # run one file
pytest -k "divide"       # run tests whose name matches "divide"
pytest -v                # verbose output, one line per test
pytest --maxfail=1        # stop after the first failure
unittest vs pytest

Aspect

unittest

pytest

Assertion style

self.assertEqual(a, b), self.assertTrue(x), dozens of methods

plain assert a == b, assert x

Boilerplate

Test classes must subclass TestCase

Plain functions, no class required

Setup/teardown

setUp / tearDown methods

Reusable, composable fixtures via @pytest.fixture

Failure output

Reports which assert method failed

Shows the actual values in the failing expression

Plugin ecosystem

Standard library, limited extensibility

Hundreds of plugins (coverage, mocking, parallelism, and more)

Adoption

Built-in, used in legacy and stdlib-only projects

De facto standard for new Python projects

Tip
pytest can run `unittest`-based test suites without any changes, so you can start using the `pytest` command today even on an existing `unittest` codebase, then migrate individual tests to plain functions over time.