Neural Tech Daily
dev-tutorials

pytest end-to-end: from your first test to fixtures, parametrize, and CI

A working pytest setup in 60 minutes — install, conftest.py, fixtures, parametrize, pytest-cov and pytest-xdist plugins, and a minimal GitHub Actions matrix.

Updated ~13 min read
Share

The bottom line

This tutorial gets a working pytest setup running end-to-end in about 60 minutes: install on Python 3.10 or newer, write a first test, factor reusable setup into fixtures in conftest.py, expand test coverage with @pytest.mark.parametrize, add the pytest-cov and pytest-xdist plugins for coverage reporting and parallel runs, and ship a minimal GitHub Actions matrix that runs the suite on push. The current pytest line is 9.x; pytest 9.0.0 shipped on 8 November 2025 5 and dropped Python 3.9 support, so the supported floor is now Python 3.10 through 3.14. 6 Skip this if you already have a fixtures-based suite running in CI; come back when you need to add parallelism or matrix coverage.

Prerequisites

A Python 3.10+ install (the pytest 9.x line dropped 3.9 support per its PyPI metadata 6 ), a terminal, and a project folder. The examples use a plain venv and pip; the same commands work with uv, poetry, or conda substitutions. No prior pytest experience assumed beyond having written a function and called it.

Step 1: Install pytest and write a first test

Create a project folder, set up a virtual environment, and install pytest from PyPI. 6

mkdir pytest-tutorial && cd pytest-tutorial
python3 -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install pytest
pytest --version

The last line confirms install. As of 2026-05-19 PyPI lists pytest 9.0.x as the current stable line. 6

Now write the smallest possible test. pytest’s get-started page 1 uses the same pattern: any file named test_*.py with functions named test_* is auto-collected. Create test_basics.py:

# test_basics.py

def add(a, b):
    return a + b

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

def test_add_handles_negatives():
    assert add(-1, 1) == 0

Run it:

pytest

The expected output is 2 passed in 0.0Xs. No unittest.TestCase subclassing, no self.assertEqual, no test runner config; pytest discovers and runs the file directly. The assert statement is rewritten by pytest at collection time so failure output shows the actual values, not just “False”.

pytest project page on PyPI showing the current published package metadata.

Image: pytest on PyPI, used for editorial coverage of the current package version and install metadata.

Step 2: Add fixtures to share setup between tests

When two or more tests need the same setup (a temp file, a database connection, a mocked HTTP client), extract that setup into a fixture. Per the fixtures how-to, 2 a fixture is a function decorated with @pytest.fixture, and a test that takes the fixture’s name as a parameter gets the fixture’s return value injected.

# test_users.py
import pytest

class UserStore:
    def __init__(self):
        self._users = {}

    def add(self, name, email):
        self._users[name] = email

    def get(self, name):
        return self._users.get(name)

@pytest.fixture
def empty_store():
    return UserStore()

@pytest.fixture
def store_with_one_user(empty_store):
    empty_store.add("ada", "ada@example.com")
    return empty_store

def test_empty_store_returns_none(empty_store):
    assert empty_store.get("ada") is None

def test_store_with_user_returns_email(store_with_one_user):
    assert store_with_one_user.get("ada") == "ada@example.com"

Two patterns are worth naming. First, fixtures compose: store_with_one_user takes empty_store as an argument, which means pytest builds the empty store first, then layers user data on top. Second, fixtures default to function scope, so each test gets a fresh instance. For an expensive setup (a database container, a logged-in browser session), declare a wider scope: @pytest.fixture(scope="module") reuses the same instance across every test in the module, and scope="session" reuses across the entire test run. 2

A fixture that needs teardown uses yield instead of return:

@pytest.fixture
def tmp_log_file(tmp_path):
    path = tmp_path / "app.log"
    path.write_text("")
    yield path
    # teardown runs after the test, even if the test failed
    if path.exists():
        path.unlink()

tmp_path is a pytest-built-in fixture that provides a per-test temp directory; the full list of built-ins is in the fixtures reference. 13

Step 3: Move shared fixtures to conftest.py

When the same fixture is used across multiple test files, move it to conftest.py. Per the fixtures how-to, 2 pytest auto-discovers conftest.py in the test directory and every parent directory; fixtures defined there are available to every test below that directory without import.

Layout:

pytest-tutorial/
├── conftest.py          # shared fixtures
├── tests/
│   ├── conftest.py      # tests-folder-scoped fixtures
│   ├── test_users.py
│   └── test_billing.py
└── src/
    └── app/

conftest.py does not get imported by tests; pytest reads it as part of test collection. 13 Move the empty_store fixture from test_users.py to conftest.py:

# conftest.py
import pytest

class UserStore:
    def __init__(self):
        self._users = {}

    def add(self, name, email):
        self._users[name] = email

    def get(self, name):
        return self._users.get(name)

@pytest.fixture
def empty_store():
    return UserStore()

Both test_users.py and test_billing.py can now request empty_store as an argument and receive a fresh instance per test, no import needed. This is the canonical pattern for app-wide test setup: shared infrastructure in the root conftest.py, narrower fixtures in folder-scoped conftest.py, single-use fixtures inline in the test file.

Step 4: Run the same test against many inputs with parametrize

When a test asserts the same shape across a list of inputs, use @pytest.mark.parametrize. Per the parametrize how-to, 3 the decorator takes a comma-separated list of argument names and a list of value tuples; pytest runs the test once per tuple.

# test_math.py
import pytest

def add(a, b):
    return a + b

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

Run pytest test_math.py -v and the output shows four separate test cases: test_add[2-3-5], test_add[0-0-0], and so on. Each runs in isolation; one failing tuple doesn’t short-circuit the others.

Two refinements worth knowing. Custom IDs make the report readable for opaque inputs:

@pytest.mark.parametrize("payload,expected", [
    pytest.param({"x": 1}, 1, id="single-key"),
    pytest.param({"x": 1, "y": 2}, 3, id="two-keys"),
])
def test_sum_values(payload, expected):
    assert sum(payload.values()) == expected

And pytest.param(..., marks=pytest.mark.skip) lets you skip individual rows without removing them from the table. This helps when a row documents an expected failure pinned to a tracked bug.

Fixtures can be parametrized too. Per the parametrize how-to, 3 @pytest.fixture(params=[...]) runs every dependent test once per param value:

@pytest.fixture(params=["postgres", "sqlite"])
def db_backend(request):
    return request.param

def test_writes_persist(db_backend):
    # runs once for postgres, once for sqlite
    ...

This is the cleanest pattern for cross-backend test matrices: one fixture, every test that uses it automatically runs across both backends.

pytest-cov project page on PyPI showing the coverage plugin's current published version.

Image: pytest-cov on PyPI, used for editorial coverage of the coverage-plugin version metadata.

Step 5: Add coverage with pytest-cov

pytest-cov integrates coverage.py into the pytest run, reporting which lines of your application code were executed by the test suite. As of 2026-05-19 the current version is 7.1.0. 9

pip install pytest-cov
pytest --cov=src --cov-report=term-missing

The --cov=src flag points coverage at the source directory; --cov-report=term-missing prints a per-file table with uncovered line numbers. For CI artefacts, add --cov-report=xml (Codecov, Coveralls) or --cov-report=html (browsable local report). 8

A common gotcha: coverage measures import-time code as covered the moment a test imports the module, which can mask code that’s never actually exercised. Pair --cov-report=term-missing with --cov-branch to also report which branches of every if / else were taken; this catches the “module imported, function never called” pattern that line coverage alone misses.

To configure persistent options, add a pytest section to pyproject.toml:

# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-branch"
testpaths = ["tests"]

Now pytest alone runs with coverage. addopts is the canonical place for default CLI flags; testpaths tells pytest where to look for tests so it doesn’t walk the whole tree.

Step 6: Parallelise with pytest-xdist

pytest-xdist distributes tests across multiple processes. For a suite that’s CPU-bound or has independent I/O, this drops wall-clock time roughly linearly with worker count. The current version is 3.8.0. 11

pip install pytest-xdist
pytest -n auto

-n auto uses one worker per CPU core. -n 4 pins to four workers. pytest -n auto --dist=loadfile distributes work in file-sized chunks rather than test-sized chunks, which is the right choice when tests within a file share an expensive module-scoped fixture.

Two compatibility notes. First, pytest-xdist and pytest-cov work together but require a small adjustment: per the pytest-cov xdist docs, 10 coverage data from workers is automatically combined at session end, so pytest -n auto --cov=src produces a unified report. Second, tests that rely on shared mutable state (writing to the same file, mutating a module-level singleton) will fail under parallel runs in non-deterministic ways. Fix the tests, not the parallelism. A test that needs to be the only one running is a smell.

Step 7: Wire it into GitHub Actions

A minimal GitHub Actions workflow that runs the suite on every push, across a Python version matrix, with coverage reporting. Create .github/workflows/tests.yml:

name: tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -e ".[test]"
      - name: Run tests
        run: pytest -n auto --cov=src --cov-report=xml
      - name: Upload coverage
        if: matrix.python-version == '3.12'
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.xml

The setup-python action documentation 14 covers the cache: 'pip' option, which caches the ~/.cache/pip directory keyed on the dependency-file hash; first-run installs cost the full duration, subsequent runs are seconds. Pin the coverage upload to a single Python version to avoid four separate uploads racing on the same commit.

fail-fast: false is opinionated: it tells the matrix to keep running every Python version even after one fails, so a single environment-specific bug doesn’t hide failures elsewhere. Drop it back to default true if your team wants the cheaper “stop on first failure” cost profile.

pytest-xdist project page on PyPI showing the distributed-testing plugin's current version.

Image: pytest-xdist on PyPI, used for editorial coverage of the parallel-testing plugin metadata.

Common gotchas

A short list of the failure modes that bite first-time pytest users.

test_*.py naming is mandatory. pytest collects files matching test_*.py or *_test.py by default. 1 Files named tests.py or my_tests.py are silently ignored. The discovery rules are configurable in pyproject.toml under python_files, but the default is the path of least surprise.

Fixtures must be requested by name, not imported. A test that imports a fixture from conftest.py will fail because the fixture function isn’t meant to be called directly; pytest calls it during test setup. The right pattern is just declaring the fixture name as a test parameter. 2

conftest.py is per-directory. A fixture in tests/api/conftest.py is visible to tests in tests/api/ and below, but not to tests in tests/billing/. To share across both, move the fixture to tests/conftest.py or the project root. 13

Parametrize IDs leak into CI logs. When parametrizing with complex objects (dicts, lists), pytest generates auto-IDs that include the repr of each value, which can be both unreadable and a source of test-name churn between runs. Use pytest.param(..., id="descriptive-label") to pin readable IDs. 3

Coverage shows 100% but tests don’t actually exercise the code. Module import counts as line coverage. Combine --cov-report=term-missing with --cov-branch to surface branches that import-time coverage hides. 8

xdist breaks tests that share state. A test suite that passes serially and fails under pytest -n auto has hidden coupling. The pytest-xdist documentation 10 recommends marking specifically-non-parallel tests with @pytest.mark.serial and excluding them with a -m "not serial" filter on the parallel run, then running them in a separate sequential pass.

GitHub Actions setup-python action repository on GitHub showing the action's project page header.

Image: GitHub Actions setup-python repository, used for editorial coverage of the CI Python-setup integration.

Where to go next

Three plugin extensions cover most of what’s beyond this tutorial. pytest-mock wraps unittest.mock in a fixture-friendly API: instead of nesting with patch(...) blocks, request a mocker fixture and call mocker.patch(...) directly. pytest-asyncio is the canonical plugin for async test functions; it provides an event-loop fixture and supports both function-scoped and session-scoped loops. pytest-django (for Django apps) and pytest-flask (for Flask) provide framework-specific fixtures: a client for HTTP requests, a db fixture that wraps transactions for rollback after each test.

For the pytest version-line story, the pytest-9.0.0 announcement 5 spells out the breaking changes: dropped Python 3.9, PytestRemovedIn9Warning deprecations are now errors, and non-unique parametrize IDs raise an error rather than auto-suffixing. The pytest changelog 4 is the authoritative reference for everything since.

The pytest source itself 7 is unusually readable for a project of its size; the conftest.py files inside testing/ are a working example of every fixture pattern the docs describe.

For test selection at the command line, pytest’s filtering primitives compose: pytest -k "users and not billing" runs tests matching the keyword expression, pytest tests/api/ runs everything under a path, pytest tests/api/test_users.py::test_login runs a single function, and pytest -m "slow" runs only tests tagged with @pytest.mark.slow. The plugins-and-marks doc 12 covers the full marker syntax. In CI, a common pattern is to split a slow integration suite (-m slow) into a separate workflow that runs nightly, while the unit suite (-m "not slow") runs on every push.

One last operational pattern worth flagging: pytest --lf reruns only the tests that failed in the previous run, and pytest --ff runs the failed tests first, then the rest. Both modes pay off when iterating on a single broken test inside a large suite. The state is stored in .pytest_cache/, which should be added to .gitignore.

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.