Skip to content

Software Testing & QA Tools

pytest

pytest is the dominant third-party test framework for Python, generally chosen over the standard library's unittest because tests are plain functions using plain assert statements instead of self.assertEqual-style methods on a test-case class. It rewrites assert internally to produce detailed failure messages without needing a different method per comparison type, and its fixture system provides composable setup and teardown through dependency injection rather than class inheritance. A very large plugin ecosystem — coverage, mocking, parallel execution, Django and Flask integration, async support — extends it well past bare unit testing into integration testing for most real projects.

Why it matters

It's the practical default over Python's built-in unittest
Most Python style guides, tutorials, and open-source projects recommend pytest even though unittest ships with the language for free, because pytest's ergonomics (plain assert, fixtures, parametrize) save enough boilerplate to be worth the extra dependency.
Assertion rewriting makes plain assert readable
pytest inspects the abstract syntax tree of a failing assert x == y at collection time and prints both values and the comparison, so a failure is informative without needing assertEqual, assertTrue, or any matcher-specific method.
Fixtures replace inherited setUp/tearDown with composition
A fixture is a plain function decorated with @pytest.fixture, and a test simply requests the fixtures it needs as parameters — dependencies are explicit per-test rather than inherited from a shared base test class.
The plugin ecosystem covers most real-world testing needs
pytest-django, pytest-asyncio, pytest-mock, pytest-xdist (parallel runs), and pytest-cov (coverage) are close to standard in their respective domains, and most are maintained independently of pytest core.

Why plain assert works

Rather than requiring assertEqual(a, b), assertTrue(x), and a different method for every comparison the way unittest does, pytest lets you write assert a == b directly and still get a detailed failure report. It does this through an import hook that rewrites the abstract syntax tree of assert statements at import time — before they're compiled to bytecode — to capture the values on both sides of the comparison, so a failed assert response.status_code == 200 prints what the actual status code was, not just 'assertion failed'.

Python
import pytest

def divide(a, b):
    return a / b

@pytest.fixture
def numbers():
    return (10, 2)

def test_divide(numbers):
    a, b = numbers
    assert divide(a, b) == 5

@pytest.mark.parametrize("a,b,expected", [(10, 2, 5), (9, 3, 3), (7, 7, 1)])
def test_divide_parametrized(a, b, expected):
    assert divide(a, b) == expected

Fixtures: setup as dependency injection

A fixture is requested by name as a test function's parameter, and pytest resolves and injects it — including fixtures that themselves depend on other fixtures. Fixture scope controls how often it reruns: 'function' (the default, every test), 'class', 'module', or 'session' (once for the whole test run), which matters for anything expensive like a database connection. A fixture written with yield instead of return runs its setup code before the yield and its teardown code after, replacing the separate setUp/tearDown methods unittest requires. Fixtures defined in a conftest.py file are automatically available to every test in that directory and below, with no import needed.

Parametrize instead of hand-written loops

@pytest.mark.parametrize generates one independently reported test per input tuple, rather than one test that loops over cases internally and only reports a single pass or fail. This matters in practice: if input case 3 of 5 fails inside a hand-rolled loop, the test report says 'test failed' with no immediate indication which case; parametrize reports test_divide_parametrized[7-7-1] as its own line, naming exactly which input set failed.

Marks, selective runs, and compatibility with unittest

@pytest.mark.skip and @pytest.mark.xfail mark tests as intentionally skipped or expected-to-fail; custom marks (registered in pytest.ini or pyproject.toml) let you tag tests (e.g. @pytest.mark.slow) and then run only a subset with pytest -m slow or exclude them with -m 'not slow'. Critically, pytest can also collect and run existing unittest.TestCase-based test classes unmodified, which means adopting pytest for a project that already has unittest tests is incremental rather than a rewrite — new tests can use fixtures and parametrize while old ones keep working as-is.

Mistakes people make here

Leaving an expensive fixture at the default function scope
Fixture scope defaults to 'function', so a fixture that opens a real database connection or spins up a test server reruns for every single test unless its scope is explicitly widened to 'module' or 'session' — a suite that should take seconds ends up taking minutes.
Mutating a shared fixture's data in place
A class-, module-, or session-scoped fixture returns the same object to every test that requests it in that scope — a test that mutates a list or dict returned by such a fixture can leave altered state for the next test that requests it, producing failures that depend on test execution order.
Writing tests that depend on running in a specific order
pytest doesn't guarantee tests run in file or declaration order as a contract, and that assumption breaks outright under pytest-xdist parallel execution or a plugin that randomizes order — a test relying on state a previous test happened to create is a latent bug, not a working shortcut.
Reaching for monkeypatch or pytest-mock to stub out most of what a test touches
Both make mocking easy enough that it's tempting to mock away anything inconvenient, but a test that mocks the majority of the code path it's supposedly testing stops verifying real behavior and starts verifying that the mocks were called as configured.

Strengths and trade-offs

Where it is strong

  • Assertion rewriting makes plain assert statements produce informative failure messages without a matcher method per comparison type.
  • Fixtures compose and can depend on each other, avoiding the duplicated setup logic that unittest's class-inheritance model tends to accumulate.
  • Runs existing unittest.TestCase-based suites unmodified, so migrating to pytest doesn't require rewriting an existing test base up front.
  • One of the largest plugin ecosystems of any test framework — async support, Django/Flask integration, parallel execution, and property-based testing via a hypothesis plugin are all mature, widely used add-ons.

The trade-offs

  • Fixture dependency graphs can be genuinely hard to trace in a large suite, since a fixture used by a test might be defined in any conftest.py up the directory tree.
  • Its conveniences — assertion rewriting, fixture injection by parameter name matching, autouse fixtures that apply without being requested — rely on conventions that are non-obvious coming from more explicit frameworks.
  • A project's effective test behavior depends on which plugins are installed and how they're configured, which isn't always visible just from reading the test files themselves.
  • No parallel execution ships built in — pytest-xdist is the standard answer, but it's an additional dependency to add, configure, and keep compatible with fixture scoping.

Who needs this

Any Python codebase writing automated tests; pytest is the practical default even for teams that started with unittest, since it runs existing unittest suites unchanged while adding fixtures, parametrize, and its plugin ecosystem on top.

Questions about pytest

Do I have to rewrite unittest tests to adopt pytest?
No — pytest can discover and run existing unittest.TestCase classes without modification, since it's built to be a compatible superset. New tests can take advantage of fixtures and parametrize while old ones keep working as they were written.
What's the difference between a fixture and a mark?
A fixture provides or sets up something a test needs (data, a connection, a temp directory) and is requested as a parameter. A mark tags a test with metadata that changes how it's selected or run — skip, xfail, or a custom tag used for filtering with -m. Parametrize is technically implemented as a mark, but it's commonly thought of as its own feature.
How does pytest find my tests automatically?
By default it collects files matching test_*.py or *_test.py, functions prefixed test_, and classes prefixed Test that don't define their own __init__ — all of this is configurable in pytest.ini, setup.cfg, or pyproject.toml if a project uses different naming.
Does pytest replace tools like tox or nox?
No — tox and nox manage running a test suite across multiple environments or Python versions; pytest is what actually executes the tests inside each of those environments. They're complementary, not competing tools.

The primary source

Related concepts

← All concept guides