Pytest Tutorial: Python Testing with Fixtures and Parametrize

Go from a first assert to fixtures, parametrized cases, temporary files, environment patching, and coverage, with complete files you can run.

Executive Summary: This pytest tutorial goes from a first assert to fixtures, parametrized tests, temporary files, environment patching, and coverage reports. It explains how plain functions and fixtures keep tests short, where conftest.py can confuse newcomers, and why coverage should guide you rather than serve as a score.

A one-line change to a discount calculation passed code review and shipped. It applied the discount twice for carts with more than one item. A single test with two items would have failed in under a second. Nobody had written it, because the project’s tests needed twenty lines of setup each, so people avoided adding them.

pytest is the most widely used third-party test framework for Python. This pytest tutorial builds a real test file step by step. You write test functions whose names start with test_, use the built-in assert statement, and run one command.

You need Python 3.10 and pytest 7.x. You should be comfortable writing functions. A fixture, which you will meet below, is a function that prepares something a test needs. pytest matches fixtures to test parameters by name, so it helps to know how Python functions take arguments.

My position: test behavior through the public interface, and replace only what crosses the edge of your process, such as the clock, the environment, the file system, and the network. Tests written that way survive refactoring. Tests that mock internal calls break every time you rename a helper.

Set up the project

Create a folder, a virtual environment, and install the two packages.

python --version
mkdir cart-tests
cd cart-tests
python -m venv .venv
source .venv/bin/activate          # macOS and Linux
.venv\Scripts\Activate.ps1         # Windows PowerShell
python -m pip install pytest pytest-cov
python -m pytest --version
pytest 7.1.3

Now add the code under test. Save this as cart.py. It models a shopping cart, reads a discount from an environment variable, and saves carts to JSON files.

import json
import os
from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class Cart:
    items: dict[str, tuple[float, int]] = field(default_factory=dict)

    def add(self, name: str, price: float, quantity: int = 1) -> None:
        if price < 0:
            raise ValueError('price must not be negative')
        if quantity < 1:
            raise ValueError('quantity must be at least 1')
        _, existing = self.items.get(name, (price, 0))
        self.items[name] = (price, existing + quantity)

    def total(self) -> float:
        return round(sum(price * qty for price, qty in self.items.values()), 2)


def discount_rate() -> float:
    return float(os.environ.get('DISCOUNT_RATE', '0'))


def total_with_discount(cart: Cart) -> float:
    return round(cart.total() * (1 - discount_rate()), 2)


def save_cart(cart: Cart, path: Path) -> None:
    path.write_text(json.dumps(cart.items), encoding='utf-8')


def load_cart(path: Path) -> Cart:
    raw = json.loads(path.read_text(encoding='utf-8'))
    return Cart({name: (price, qty) for name, (price, qty) in raw.items()})

This tutorial keeps both files in one folder for simplicity. For a real project, use a src layout with pyproject.toml and install the package in editable mode.

Write your first test

Create test_cart.py in the same folder.

from cart import Cart


def test_total_sums_price_times_quantity():
    cart = Cart()
    cart.add('keyboard', 50.0)
    cart.add('mouse', 20.0, quantity=2)
    assert cart.total() == 90.0
python -m pytest
collected 1 item

test_cart.py .                                                           [100%]

1 passed in 0.01s

pytest found the test through naming conventions alone. It collects files named test_*.py or *_test.py, and inside them, functions named test_*. Running through python -m pytest also adds the current directory to the import path, which is why from cart import Cart works.

The test follows three steps: arrange the data, act on it, and assert on the result. Keep that shape in every test, and name the test after the behavior it checks.

Read a failure

Change the expected value to 100.0 and run again. pytest rewrites assert statements so that failures show the values involved.

    def test_total_sums_price_times_quantity():
        cart = Cart()
        cart.add('keyboard', 50.0)
        cart.add('mouse', 20.0, quantity=2)
>       assert cart.total() == 100.0
E       assert 90.0 == 100.0
E        +  where 90.0 = <bound method Cart.total of Cart(items={'keyboard': (50.0, 1), 'mouse': (20.0, 2)})>()

test_cart.py:8: AssertionError
=========================== short test summary info ===========================
FAILED test_cart.py::test_total_sums_price_times_quantity - assert 90.0 == 100.0
1 failed in 0.03s

You get the actual value, the expected value, and the state of the object, without writing a message. That is the main reason pytest tests are shorter than unittest tests, which need methods such as assertEqual. Restore the value to 90.0 before you continue.

Share setup with fixtures

Several tests will need the same prepared cart. A fixture provides it. Mark a function with @pytest.fixture, then name it as a parameter in any test. If the @ syntax is unfamiliar, read about how decorators work.

import pytest

from cart import Cart


@pytest.fixture
def cart() -> Cart:
    cart = Cart()
    cart.add('keyboard', 50.0)
    cart.add('mouse', 20.0, quantity=2)
    return cart


def test_total_sums_price_times_quantity(cart):
    assert cart.total() == 90.0


def test_add_same_item_increases_quantity(cart):
    cart.add('keyboard', 50.0)
    assert cart.items['keyboard'] == (50.0, 2)

pytest sees the parameter named cart, finds the fixture with that name, calls it, and passes the result in. By default, each test gets a fresh call, so the second test’s extra keyboard cannot affect the first. That isolation is the point: tests must pass in any order.

Teardown with yield, and fixture scope

A fixture that needs cleanup uses yield in place of return. Code after the yield runs when the test finishes, even if it failed. This runnable example uses an in-memory SQLite database.

import sqlite3

import pytest


@pytest.fixture
def db():
    connection = sqlite3.connect(':memory:')
    connection.execute('CREATE TABLE tasks (title TEXT)')
    yield connection
    connection.close()


def test_insert_adds_one_row(db):
    db.execute("INSERT INTO tasks VALUES ('write docs')")
    count = db.execute('SELECT COUNT(*) FROM tasks').fetchone()[0]
    assert count == 1

The scope argument controls how often a fixture runs.

Scope Created Use it for Risk
function (default) Once per test Almost everything None. Slower only if setup is expensive
module Once per test file Costly read-only resources Tests can leak state to each other
session Once per test run Starting a server, building a schema Any mutation affects every later test

Wider scopes make the suite faster and the tests less independent. Use them only for objects that tests do not modify.

Share fixtures with conftest.py

Move a fixture into a file named conftest.py, and every test file in that folder and its subfolders can use it without an import. pytest loads conftest.py automatically. This is convenient, and it is also the implicit behavior that confuses people. When you cannot find where a fixture comes from, run python -m pytest --fixtures to list every available fixture with its location.

Cover many cases with parametrize

Input validation needs several similar checks. Writing one test per case repeats code. Stuffing all cases into one test hides which case failed. @pytest.mark.parametrize runs one test function once per row of data.

@pytest.mark.parametrize(
    ('price', 'quantity', 'message'),
    [
        (-1.0, 1, 'price must not be negative'),
        (10.0, 0, 'quantity must be at least 1'),
        (10.0, -3, 'quantity must be at least 1'),
    ],
    ids=['negative-price', 'zero-quantity', 'negative-quantity'],
)
def test_add_rejects_invalid_input(price, quantity, message):
    with pytest.raises(ValueError, match=message):
        Cart().add('cable', price, quantity)

Two tools appear here. pytest.raises is a context manager that passes only if the block raises the given exception. Its match argument checks the message against a regular expression. The ids list names each case, so a failure reads test_add_rejects_invalid_input[zero-quantity], which tells you the broken scenario before you open the file.

Here is the habit I recommend: always give parametrized cases readable IDs. The test name in a CI log is the first line of a bug report. [zero-quantity] is a report, while [10.0-0-quantity must be at least 1] is a puzzle.

Control the environment with monkeypatch

total_with_discount reads an environment variable. A test must not depend on what happens to be set on your machine or in CI. The built-in monkeypatch fixture changes environment variables, attributes, and dictionary entries, and it restores them after the test.

from cart import total_with_discount


def test_discount_rate_comes_from_environment(cart, monkeypatch):
    monkeypatch.setenv('DISCOUNT_RATE', '0.1')
    assert total_with_discount(cart) == 81.0


def test_no_discount_by_default(cart, monkeypatch):
    monkeypatch.delenv('DISCOUNT_RATE', raising=False)
    assert total_with_discount(cart) == 90.0

The second test removes the variable explicitly. Without that line, the test would fail on any machine where DISCOUNT_RATE is set. Note also that a test can request several fixtures at once, here cart and monkeypatch.

monkeypatch.setattr replaces an attribute. A common use is a fixed clock:

import time


def is_expired(created_at: float, ttl: float) -> bool:
    return time.time() - created_at > ttl


def test_is_expired_uses_the_clock(monkeypatch):
    monkeypatch.setattr(time, 'time', lambda: 1_000.0)
    assert is_expired(created_at=900.0, ttl=60.0)
    assert not is_expired(created_at=990.0, ttl=60.0)

Mock versus fake

These two words are used loosely, and the difference affects how fragile your tests are. A fake is a simple working replacement, such as the fixed clock above or an in-memory database. A mock, for example unittest.mock.Mock, records how it was called so that the test can assert on those calls.

Tests built on fakes check results. Tests built on mocks check interactions, such as “the function called send once with these arguments”. The second kind fails whenever you restructure the code, even when the behavior is unchanged. Therefore, prefer fakes, and keep mocks for cases where the call itself is the behavior, such as “an email was sent”.

Use tmp_path for files

Tests that write files must not leave them in your project or collide with each other. The built-in tmp_path fixture gives each test its own empty directory as a pathlib.Path.

from cart import load_cart, save_cart


def test_cart_round_trips_through_a_file(cart, tmp_path):
    path = tmp_path / 'cart.json'
    save_cart(cart, path)
    assert load_cart(path) == cart

This is a round-trip test: save, load, and compare with the original. It checks both functions with one assertion, and it uses the real file system, so no mocking is involved. pytest keeps the last few temporary directories on disk, which helps when you need to inspect a file after a failure.

The complete test file and a verification run

Here is the full test_cart.py with every import.

import pytest

from cart import Cart, load_cart, save_cart, total_with_discount


@pytest.fixture
def cart() -> Cart:
    cart = Cart()
    cart.add('keyboard', 50.0)
    cart.add('mouse', 20.0, quantity=2)
    return cart


def test_total_sums_price_times_quantity(cart):
    assert cart.total() == 90.0


def test_add_same_item_increases_quantity(cart):
    cart.add('keyboard', 50.0)
    assert cart.items['keyboard'] == (50.0, 2)


@pytest.mark.parametrize(
    ('price', 'quantity', 'message'),
    [
        (-1.0, 1, 'price must not be negative'),
        (10.0, 0, 'quantity must be at least 1'),
        (10.0, -3, 'quantity must be at least 1'),
    ],
    ids=['negative-price', 'zero-quantity', 'negative-quantity'],
)
def test_add_rejects_invalid_input(price, quantity, message):
    with pytest.raises(ValueError, match=message):
        Cart().add('cable', price, quantity)


def test_discount_rate_comes_from_environment(cart, monkeypatch):
    monkeypatch.setenv('DISCOUNT_RATE', '0.1')
    assert total_with_discount(cart) == 81.0


def test_no_discount_by_default(cart, monkeypatch):
    monkeypatch.delenv('DISCOUNT_RATE', raising=False)
    assert total_with_discount(cart) == 90.0


def test_cart_round_trips_through_a_file(cart, tmp_path):
    path = tmp_path / 'cart.json'
    save_cart(cart, path)
    assert load_cart(path) == cart
python -m pytest -v
test_cart.py::test_total_sums_price_times_quantity PASSED                [ 12%]
test_cart.py::test_add_same_item_increases_quantity PASSED               [ 25%]
test_cart.py::test_add_rejects_invalid_input[negative-price] PASSED      [ 37%]
test_cart.py::test_add_rejects_invalid_input[zero-quantity] PASSED       [ 50%]
test_cart.py::test_add_rejects_invalid_input[negative-quantity] PASSED   [ 62%]
test_cart.py::test_discount_rate_comes_from_environment PASSED           [ 75%]
test_cart.py::test_no_discount_by_default PASSED                         [ 87%]
test_cart.py::test_cart_round_trips_through_a_file PASSED                [100%]

8 passed in 0.03s

Six test functions produced eight test cases, because the parametrized function ran three times.

Command-line options worth knowing

Option Effect
-v Show one line per test with its full name
-x Stop at the first failure
-k "discount" Run only tests whose names match the expression
--lf Rerun only the tests that failed last time
-m "not slow" Select tests by marker
--durations=5 List the five slowest tests
--fixtures List available fixtures and where they are defined

Markers label tests. @pytest.mark.skip and @pytest.mark.xfail are built in. You can also define your own, such as @pytest.mark.slow. Register custom markers in pyproject.toml, and pytest will reject misspelled ones when you add --strict-markers:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
    "slow: tests that take more than a second",
]

Measure coverage, and the myth of 100 percent

The pytest-cov plugin reports which lines your tests executed.

python -m pytest --cov=cart --cov-report=term-missing
---------- coverage: platform linux, python 3.10.7-final-0 -----------
Name      Stmts   Miss  Cover   Missing
---------------------------------------
cart.py      24      0   100%
---------------------------------------
TOTAL        24      0   100%

8 passed in 0.06s

The Missing column lists line numbers that no test reached. That list is the useful part.

A popular belief holds that 100 percent coverage means the code is fully tested. It does not. Coverage records that a line ran, not that anything checked its result. Consider this function and its test:

def average(values: list[float]) -> float:
    return sum(values) / len(values)


def test_average():
    assert average([2.0, 4.0]) == 3.0

Coverage is 100 percent, and average([]) still crashes with ZeroDivisionError. The line executed, but the empty input never did. Consequently, use coverage to find code that has no tests at all. Do not use it as proof of quality, and do not chase the last few percent with tests that assert nothing.

Troubleshooting

  • collected 0 items. Your file or function names do not match the conventions. Files need the test_ prefix or _test suffix, and functions need the test_ prefix.
  • ModuleNotFoundError: No module named ‘cart’. You ran the bare pytest command from a different folder, or the package is not installed. Run python -m pytest from the project root, or install the project in editable mode.
  • fixture ‘cart’ not found. The fixture lives in another test file. Move it to conftest.py so that every test file can see it.
  • A test passes alone and fails in the full run. Another test changed shared state. Look for module-level variables, wide-scoped fixtures, and environment changes made without monkeypatch.

How real systems organize pytest suites

  • A tests folder that mirrors the package. src/shop/cart.py is tested by tests/test_cart.py, so anyone can find the tests for a module.
  • Fast tests by default, slow tests marked. Unit tests run on every commit in seconds. Tests that need a database or network carry a marker and run in a separate CI step.
  • Fixtures layered in conftest.py. A root conftest.py holds widely shared fixtures, and subfolders add their own. Factory fixtures return a function, so each test builds the exact object it needs.
  • Real dependencies where cheap. Suites use a temporary directory and an in-memory or disposable database instead of mocking the storage layer.
  • CI fails on warnings and unknown markers. Options such as --strict-markers stop a typo from silently deselecting a test.

A mistake I have seen in production is a session-scoped fixture that returned a shared list of sample orders. One test sorted the list in place. Every test after it saw a different order of data, so the suite passed or failed depending on which tests ran first. It went unnoticed for months, until someone ran a single file alone and got four failures. Changing the fixture to function scope cost a few milliseconds per test and ended the problem.

Choosing a testing tool: a decision framework

  1. Do several tests need the same prepared object? Write a fixture. Keep it function-scoped unless setup is slow and the object is read-only.
  2. Is it the same check with different inputs? Use parametrize with readable ids.
  3. Does the code read the environment, the clock, or a global? Use monkeypatch with a fake value.
  4. Does it read or write files? Use tmp_path and the real file system.
  5. Is the outgoing call itself the behavior? Only then use a mock and assert on the call.

When NOT to reach for a fixture or a mock

  • When two lines of setup are clearer inline. A fixture used by one test hides the arrangement in another place. Readers must jump around to understand a ten-line test.
  • When mocking would replace the thing you are testing. A test that mocks the database query and then asserts that the mock was called proves nothing about the query.
  • When the code is a trivial pass-through. A function that only forwards arguments to a library gains little from a unit test. Cover it through a test of the feature that uses it.

Common mistakes

  • Testing implementation details. Asserting on private attributes or call counts ties tests to the current structure. Every refactor then breaks tests while behavior stays correct.
  • Sharing mutable state across tests. Module-level objects and wide-scoped fixtures make results depend on test order.
  • Putting many unrelated asserts in one test. The first failure hides the rest, and the test name no longer says what broke.
  • Catching exceptions by hand. A try block with assert False in it is easy to get wrong and passes when no exception occurs. Use pytest.raises.
  • Comparing floats with ==. 0.1 + 0.2 == 0.3 is false. Use pytest.approx(0.3) for computed floating-point values.
  • Depending on the real environment. Tests that read actual environment variables or the current date pass locally and fail in CI, or fail once a year.

Key takeaways

  • Name files test_*.py and functions test_*, and use plain assert.
  • Run tests with python -m pytest from the project root.
  • Use fixtures for shared setup, and keep them function-scoped by default.
  • Use parametrize with explicit ids so that failures name the scenario.
  • Reach for monkeypatch and tmp_path before any mocking library.
  • Prefer fakes that produce results over mocks that record calls.
  • Read the coverage report’s missing lines, and ignore the urge to hit 100 percent.

FAQ

What is pytest used for?

pytest is a Python testing framework. You write test functions with plain assert statements, and pytest discovers them, runs them, and reports detailed failures.

What is a fixture in pytest?

A fixture is a function decorated with @pytest.fixture that prepares something a test needs, such as an object, a database connection, or a file. Tests request it by naming it as a parameter.

How does pytest parametrize work?

@pytest.mark.parametrize takes argument names and a list of value sets. pytest runs the test function once per set and reports each run as a separate test case.

What is conftest.py in pytest?

It is a file that pytest loads automatically. Fixtures defined in it are available to every test in the same folder and its subfolders, without an import.

What is the difference between pytest and unittest?

unittest is in the standard library and uses test classes with methods such as assertEqual. pytest is a third-party tool that uses plain functions and assert, adds fixtures and parametrization, and can also run existing unittest tests.

Test the behavior, fake the boundary

pytest removes the excuses for not writing a test: no classes, no special assertion methods, and setup that you write once. The discipline is yours to supply. Check what the code returns, replace only what lies outside your process, and keep each test independent.

Rule of thumb: if a test breaks when you rename a private helper, it was testing the wrong thing.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *