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.
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_testsuffix, and functions need thetest_prefix. - ModuleNotFoundError: No module named ‘cart’. You ran the bare
pytestcommand from a different folder, or the package is not installed. Runpython -m pytestfrom 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.pyso 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.pyis tested bytests/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.pyholds 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-markersstop 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
- 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.
- Is it the same check with different inputs? Use
parametrizewith readableids. - Does the code read the environment, the clock, or a global? Use
monkeypatchwith a fake value. - Does it read or write files? Use
tmp_pathand the real file system. - 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
tryblock withassert Falsein it is easy to get wrong and passes when no exception occurs. Usepytest.raises. - Comparing floats with ==.
0.1 + 0.2 == 0.3is false. Usepytest.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_*.pyand functionstest_*, and use plainassert. - Run tests with
python -m pytestfrom the project root. - Use fixtures for shared setup, and keep them function-scoped by default.
- Use
parametrizewith explicitidsso that failures name the scenario. - Reach for
monkeypatchandtmp_pathbefore 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.
