Skip to content
Guide

pytest

pytest is the testing framework that ate the Python ecosystem. A test is a function. An assertion is the assert statement. The framework does everything else: discovery, reporting, fixtures, parametrization, plugins. It is short, it is powerful, and it scales from a single one-line test to a hundred-thousand-test integration suite without changing shape.

By Julien Danjou, Co-founder & CEO of Mergify Updated

In one paragraph

Install pytest, write a function named test_something, run pytest in the project root. The framework discovers the test, runs it, and reports the result with rich assertion introspection. From there, fixtures handle setup and teardown, @pytest.mark.parametrize handles data-driven tests, and a plugin ecosystem of more than 1,000 packages covers everything from async support to coverage reports. The original test syntax never changes.

What a pytest test actually looks like

The whole point of pytest is that the test is the obvious thing. Compare a unittest TestCase to the same test in pytest:

# unittest
import unittest

class TestParser(unittest.TestCase):
    def test_parses_email(self):
        result = parse("name@example.com")
        self.assertEqual(result.local, "name")
        self.assertEqual(result.domain, "example.com")

# pytest
def test_parses_email():
    result = parse("name@example.com")
    assert result.local == "name"
    assert result.domain == "example.com"

The two tests do the same thing. The pytest version has no boilerplate. When the assertion fails, pytest reads the bytecode of the failed assert and prints the actual and expected values without you having to remember which arg of assertEqual goes where. That feature, called assertion rewriting, is the reason pytest can use the plain assert statement and still produce a useful diff.

Fixtures: setup and teardown without inheritance

Most testing frameworks borrow xUnit's setUp/tearDown model: define methods on a class, the framework calls them before and after each test. pytest replaces that with fixtures.

import pytest

@pytest.fixture
def db():
    conn = open_connection()
    yield conn
    conn.close()

def test_inserts_user(db):
    db.execute("INSERT INTO users VALUES (1, 'alice')")
    assert db.query("SELECT name FROM users WHERE id = 1") == "alice"

A fixture is a function decorated with @pytest.fixture. Any test that lists the fixture's name as a parameter receives the fixture's return value. The yield statement separates setup from teardown. There is no inheritance, no base class, no self reference. Fixtures can depend on other fixtures, can be scoped (function, module, session), and can take parameters via @pytest.fixture(params=[...]).

This is what most engineers stay for. Fixture composition turns out to be one of the cleaner ways to handle test state at scale, especially in test suites that touch databases, HTTP services, or filesystem layouts. Once a fixture exists, every test that needs the same setup just lists it as a parameter and gets it.

Parametrize: one function, N tests

For testing the same logic across many cases, @pytest.mark.parametrize is the canonical pattern.

import pytest

@pytest.mark.parametrize("input,expected", [
    ("name@example.com", True),
    ("not-an-email", False),
    ("", False),
    ("@example.com", False),
])
def test_validate_email(input, expected):
    assert is_valid_email(input) == expected

pytest runs the test function four times, once per row, and reports each as a separate test. When one case fails, the failure output shows you which input caused it. The alternative (a single test with a for loop) loses that resolution: the first failing case stops the test, and you have no easy way to know which other cases would have failed.

The plugin ecosystem you will actually use

pytest's plugin API is part of why the framework outlasted its competitors. Most teams end up depending on a small handful of plugins.

  • pytest-asyncio. Teach pytest how to run async def tests as coroutines. Standard for any modern Python project using asyncio.
  • pytest-xdist. Run tests in parallel across CPU cores or remote workers. The first thing you reach for when CI is slow.
  • pytest-cov. Coverage reporting integrated with the test run. Outputs a percentage, an HTML report, and (importantly for CI) a coverage diff per pull request.
  • pytest-mock. A thin wrapper around unittest.mock that exposes it as a fixture, so mocks clean up automatically at test end.
  • pytest-django, pytest-flask, pytest-fastapi. Framework-specific helpers (test client, database fixtures, settings overrides). Worth using over rolling your own.
  • pytest-testmon. Selective test execution: only run tests affected by the changes since the last run. Substantial CI time savings on large suites.
  • pytest-rerunfailures. Automatic retries on test failure. Useful as a temporary mitigation, dangerous as a permanent solution: it masks flakes instead of fixing them.

pytest in CI: the cost-of-time problem

A pytest suite that takes 30 seconds for 50 tests will take 30 minutes for 5,000 tests if nothing changes. The factor that determines whether the project survives that growth is how the suite is split.

Mark fast and slow tests

Add a @pytest.mark.slow marker to anything that touches a database, a network, or a real subprocess. In CI, run pytest -m "not slow" on every push, and pytest (everything) in a second stage at merge time. This is the most common shape of two-step CI, applied to pytest.

Parallelize with pytest-xdist

pytest -n auto shards the test set across CPU cores. On a CI runner with 8 cores, a five-minute serial suite often drops to under a minute. The trade-off is that tests must be independent, which catches teams whose tests share fixtures with implicit ordering assumptions.

Cache the slow fixtures

Most pytest suites that get slow get slow because of the fixtures, not the tests. A fixture that recreates the database schema for every test is several times slower than one that wraps each test in a transaction. Move expensive fixtures to session scope when possible. Wrap database calls in transactions that roll back. Reuse the same Docker container across the test session.

Why pytest suites go flaky

Once a pytest suite passes a few thousand tests, a stable set of flakiness patterns shows up. None of them are about pytest itself; they are about the test code on top of it.

  • Shared mutable state across tests. A global, a class attribute, a module-level cache. One test mutates it, the next test reads the mutation, the order in which the tests run starts to matter.
  • Time-dependent assertions. datetime.now() compared against a fixed value, sleep-based synchronization, timeouts that pass on a fast machine and fail on a slow one. Use freezegun or inject the clock.
  • asyncio event loop leakage. Tasks scheduled in one test continue into the next when the loop is not torn down properly. pytest-asyncio's default loop scope catches most of this; explicit cleanups handle the rest.
  • Order-dependent fixtures. A fixture that yields a connection, mutates a row, and rolls back at teardown. If two tests share the same row, one of them sees the partial state of the other.
  • Network calls hidden inside libraries. A test that should be a unit test, but the library under test reaches for a remote service during import. Add --disable-socket to make this fail loudly.

For the broader framework-level patterns and how to fix them, see the pytest flaky tests guide.

FAQ

What is pytest?

pytest is a testing framework for Python. It is the de facto standard for unit and integration testing in modern Python projects. A pytest test is just a function whose name starts with test_; the framework discovers it, runs it, and reports the result. Beyond that, pytest adds fixtures, parametrization, rich assertion introspection, and a plugin ecosystem that covers everything from coverage reports to async test runners.

What is the difference between pytest and unittest?

unittest is Python's built-in xUnit-style testing framework. Tests are methods on a TestCase subclass, with explicit self.assertEqual / self.assertTrue calls. pytest is a third-party framework where tests are plain functions and assertions use the regular assert statement. pytest can run unittest tests, which makes migrating a unittest suite a non-event. Most new Python projects pick pytest because the syntax is shorter and the fixture model is more powerful.

How do I install pytest?

pip install pytest. From there, pytest discovers any file matching test_*.py or *_test.py and runs the test_-prefixed functions inside. Most projects pin the version in their requirements file or pyproject.toml so CI runs the same version everyone runs locally.

How do I run specific tests in pytest?

Three common patterns. pytest path/to/test_file.py runs all tests in a file. pytest path/to/test_file.py::test_name runs one test. pytest -k expression runs tests whose name matches the expression (substring or boolean). Markers (pytest -m slow) let you tag tests and run subsets by tag.

What are pytest fixtures?

Fixtures are pytest's way of setting up and tearing down test state. A fixture is a function decorated with @pytest.fixture; any test that lists the fixture's name as a parameter receives the fixture's return value. Fixtures can depend on other fixtures, can be scoped (function, module, session), and can yield (setup before yield, teardown after).

What is parametrize in pytest?

@pytest.mark.parametrize is the decorator that runs the same test function with multiple sets of inputs. It turns one function into N tests, each visible separately in the output. The pattern is the standard way to test the same logic across many cases without duplicating the test body.

Is pytest good for async code?

Yes, with the pytest-asyncio plugin. The plugin teaches pytest how to run coroutines as tests. Once installed and configured, an async def test_... function is treated as a real test. For trio users, pytest-trio plays the same role.

How do I speed up pytest in CI?

Four levers. (1) pytest-xdist parallelizes tests across CPU cores. (2) Markers let you run only the fast tests on every PR push and the slow ones in a second stage. (3) Selective test runners (pytest-testmon, or a custom Bazel setup) only re-run tests affected by the change. (4) Tighter fixtures: a database fixture that recreates the schema for every test is several times slower than one that wraps each test in a transaction that rolls back.

A green pytest suite is only useful if every green means the same thing.

Test Insights detects flaky pytest tests, surfaces the patterns behind them, and quarantines them out of the required check until the fix lands. Trust the green, fix the rest.