What you'll learn
- Share setup between tests with
@pytest.fixtureand clean up after them withyield - Run one test over many inputs with
@pytest.mark.parametrize - Use the built-in fixtures
tmp_pathandcapsysfor files and printed output - Put shared fixtures in
conftest.pyso every test file can use them
Concept
Tests repeat setup: build the same object, write the same temporary file, prepare the same list. A fixture is a function that produces one of those things, and any test that names it as a parameter receives a fresh one. Decorate with @pytest.fixture, and pytest calls it before each test that asks for it. The test body then contains only the behavior being checked. If the fixture uses yield instead of return, the code after the yield runs after the test, which is how you clean up files, close connections, or reset state.

A fixture can request other fixtures, so small ones compose into larger ones. Its scope decides how often it is built: the default function scope builds it per test, which is what you want for anything mutable; module or session scope builds once for something expensive and read-only, like a large parsed file. Fixtures defined in a file called conftest.py are available to every test in that folder and below, without imports, which is where project-wide ones belong.
| Fixture feature | Effect |
|---|---|
@pytest.fixture |
a function tests can request by name |
yield inside the fixture |
code after it is teardown, run after the test |
scope="module" / "session" |
build once per module / once per run instead of per test |
| a fixture requesting another | composition; pytest resolves the order |
conftest.py |
fixtures shared by every test in the folder tree |
tmp_path |
built in: a fresh temporary folder as a Path |
capsys |
built in: captures print output for assertions |
monkeypatch |
built in: temporarily replace attributes, env vars, dictionary entries |
parametrize turns one test function into many test cases. Give it the parameter names and a list of value tuples, and pytest runs the test once per tuple, reporting each separately with its values in the name. That is the right tool for a function that should behave a certain way across a table of inputs: boundary values, formats, edge cases. Each case is its own pass or fail, so one bad input does not hide the others, and adding a case is adding a tuple.

Two built-in fixtures cover the most common awkward tests. tmp_path gives each test a fresh empty folder, so code that reads and writes files can be tested without touching the real filesystem or cleaning up. capsys captures what the code printed, so functions that report to the console can be checked with captured.out. The next lesson covers monkeypatch, the third one, for replacing things the code depends on.
Code
The code under test: a small shopping cart. The tests below build it through a fixture instead of repeating the setup.
# cart/__init__.py
# cart/model.py
class Cart:
def __init__(self):
self.items = {}
def add(self, name, price, qty=1):
if price < 0 or qty < 1:
raise ValueError("price must be non-negative and qty at least 1")
self.items[name] = self.items.get(name, 0) + qty * price
def remove(self, name):
del self.items[name]
def total(self):
return round(sum(self.items.values()), 2)
def receipt(self):
for name, amount in sorted(self.items.items()):
print(f"{name:<10}{amount:>8.2f}")
print(f"{'total':<10}{self.total():>8.2f}")
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
A fixture with yield teardown, a shared fixture in conftest.py, and tests that use them. The teardown prints so you can see when it runs.
# tests/conftest.py
import pytest
from cart.model import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("pen", 1.5, qty=2)
c.add("book", 12.0)
yield c
print("(teardown: cart discarded)")
# tests/test_cart.py
import pytest
def test_total(cart):
assert cart.total() == 15.0
def test_remove(cart):
cart.remove("book")
assert cart.total() == 3.0
assert "book" not in cart.items
def test_each_test_gets_a_fresh_cart(cart):
assert cart.items == {"pen": 3.0, "book": 12.0}
def test_bad_add(cart):
with pytest.raises(ValueError):
cart.add("x", -1)
# run_fixtures.sh
pytest -q -s tests/test_cart.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
.(teardown: cart discarded)
.(teardown: cart discarded)
.(teardown: cart discarded)
.(teardown: cart discarded)
4 passed in NsParametrize: one test, six cases, each reported by name. The last case is written to fail so the report shows how a single bad row appears.
# tests/test_param.py
import pytest
from cart.model import Cart
@pytest.mark.parametrize(
"price,qty,expected",
[
(1.0, 1, 1.0),
(1.5, 2, 3.0),
(0.0, 3, 0.0),
(19.99, 1, 19.99),
(0.1, 3, 0.3),
(2.0, 2, 5.0),
],
)
def test_add_amounts(price, qty, expected):
cart = Cart()
cart.add("item", price, qty)
assert cart.total() == expected
# run_param.sh
pytest -q tests/test_param.py 2>&1 | grep -E "passed|FAILED" | sed -E 's/ in [0-9.]+s/ in Ns/'
FAILED tests/test_param.py::test_add_amounts[2.0-2-5.0] - assert 4.0 == 5.0
1 failed, 5 passed in Nstmp_path and capsys: testing file output without touching the real disk, and asserting on printed text.
# tests/test_io.py
from cart.model import Cart
def save_receipt(cart, path):
path.write_text(f"total={cart.total()}\n", encoding="utf-8")
def test_receipt_file(cart, tmp_path):
target = tmp_path / "receipt.txt"
save_receipt(cart, target)
assert target.read_text(encoding="utf-8") == "total=15.0\n"
assert len(list(tmp_path.iterdir())) == 1
def test_receipt_output(cart, capsys):
cart.receipt()
out = capsys.readouterr().out
assert out.splitlines()[0].startswith("book")
assert "total 15.00" in out
# run_io.sh
pytest -q tests/test_io.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
.. [100%]
2 passed in NsTry it yourself
Extend the textkit tests from the previous lesson. Add a conftest.py fixture that returns a list of messy names, a parametrized test for clean with at least six input/expected rows including an empty string, a test that writes cleaned names to a file in tmp_path and reads them back, and a test that uses capsys to check a report(names) function's printed output. Run with -v and read the parametrized case names.
Common mistakes
- A module-scoped fixture that tests mutate. One test's change leaks into the next; use the default function scope for anything mutable.
- Parametrize with unclear values.
[(1, 2, 3)]is a puzzle in the report; usepytest.param(..., id="empty-cart")or descriptive values. - Writing real files in tests. They collide between runs and leave litter;
tmp_pathis free and isolated.
What's next
- Fixtures produce fresh setup per test, tear down after
yield, compose, and live inconftest.pywhen shared. parametrizeruns one test over a table of inputs, reporting each case on its own.tmp_pathandcapsysmake file and console behavior testable without side effects.
Your tests are concise and thorough. Next you will test code that talks to the clock, the filesystem, and the network without actually doing so: mocking and test doubles.