Writing tests with pytest and fixtures in Python
1. Overview
As the number of tests grows, the data preparation phase (creating customers, orders, database connections…) often gets repeated everywhere. pytest solves this problem with fixtures: a function that provides data or resources to a test and automatically cleans up after the test runs.
This article goes from your first pytest test to fixtures with yield, scope, conftest.py, built-in fixtures, and parameterization. After reading, you’ll be able to organize your Python test suite cleanly, readably, and with less repeated code.
2. Installing pytest
You should install pytest in a virtual environment:
python -m venv .venv
source .venv/bin/activate
pip install pytest
The project structure used in this article looks like this:
shop/
├── shop.py
└── tests/
├── conftest.py
├── test_order.py
└── test_repository.py
pytest automatically finds files named test_*.py or *_test.py. In each file, it runs functions starting with test_.
To import shop from the tests directory, add this configuration to pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
3. First test
Here’s the business logic in shop.py. Money is in VND (int type) to avoid floating-point errors:
from dataclasses import dataclass, field
@dataclass
class Customer:
name: str
is_vip: bool = False
@dataclass
class Order:
customer: Customer
items: list[tuple[str, int, int]] = field(default_factory=list)
def add_item(self, sku: str, quantity: int, price: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.items.append((sku, quantity, price))
def total(self) -> int:
subtotal = sum(qty * price for _, qty, price in self.items)
return subtotal * 90 // 100 if self.customer.is_vip else subtotal
VIP customers get 10% off. Tests for this logic in tests/test_order.py:
import pytest
from shop import Customer, Order
def test_total_for_regular_customer():
order = Order(Customer("An"))
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_add_item_rejects_zero_quantity():
order = Order(Customer("An"))
with pytest.raises(ValueError, match="quantity must be positive"):
order.add_item("BOOK-01", 0, 50_000)
You only need two things:
- Plain Python
assert. You don’t needassertEqualor a separate assertion library. pytest rewrites theassertstatement to provide detailed error messages. pytest.raiseschecks that a block of code raises the correct exception type. Thematchparameter is a regex matched against the exception message.
Run the tests:
pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED [100%]
============================== 2 passed in 0.01s ===============================
If you intentionally change the expected value to 90_000, pytest shows both sides:
> assert order.total() == 90_000
E assert 100000 == 90000
E + where 100000 = total()
4. Basic fixtures
4.1. Declaring and using fixtures
Both tests above create Customer("An") and Order. With dozens of tests, this code gets repeated a lot. Let’s move it into a fixture:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
def test_total_for_regular_customer(order):
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_empty_order_has_zero_total(order):
assert order.total() == 0
How it works:
@pytest.fixturemarks a function as a fixture.- A test declares a parameter with the same name as the fixture. pytest calls the fixture and passes its return value to the test. This is name-based dependency injection.
- The
orderfixture depends on thecustomerfixture. Fixtures can depend on other fixtures, and pytest resolves the order automatically.
By default, each test gets a fresh instance. So add_item in the first test doesn’t affect the order in the second test.
4.2. Factory fixtures
Sometimes a test needs multiple orders with different parameters. Instead of returning an object, a fixture can return a factory function:
@pytest.fixture
def make_order():
def _make(name="An", is_vip=False, items=()):
order = Order(Customer(name, is_vip))
for sku, qty, price in items:
order.add_item(sku, qty, price)
return order
return _make
def test_vip_gets_ten_percent_discount(make_order):
regular = make_order(items=[("BOOK-01", 1, 200_000)])
vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])
assert regular.total() == 200_000
assert vip.total() == 180_000
This pattern keeps the setup concise while letting tests decide what data they need.
5. Setup and teardown with yield
Fixtures really shine when working with resources that need cleanup, like database connections, files, or mock servers. Add a repository using SQLite to shop.py:
import sqlite3
class OrderRepository:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def save(self, customer_name: str, total: int) -> int:
cur = self.conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)",
(customer_name, total),
)
return cur.lastrowid
def count(self) -> int:
return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
A fixture uses yield instead of return:
import sqlite3
import pytest
from shop import OrderRepository
@pytest.fixture
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
return OrderRepository(db_connection)
def test_save_order_returns_id(repository):
order_id = repository.save("An", 100_000)
assert order_id == 1
assert repository.count() == 1
The part before yield is setup. The value after yield is passed to the test. The part after yield is teardown and always runs, even if the test fails. You don’t need try/finally.
The --setup-show flag shows the order clearly:
pytest tests/test_repository.py --setup-show
tests/test_repository.py
SETUP F db_connection
SETUP F repository (fixtures used: db_connection)
tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
TEARDOWN F repository
TEARDOWN F db_connection
The F means scope function. Teardown runs in reverse order of setup.
Note: If an exception occurs before
yield, the teardown part won’t run. That’s because the fixture hasn’t finished setup yet. You should keep the setup short, or wrap it intry/finallyyourself if setup has multiple steps that might fail.
6. Fixture scope
Creating expensive resources (real database connections, starting containers…) for each test makes the test suite slow. The scope parameter determines how long a fixture lives:
| Scope | Recreated when |
|---|---|
function (default) |
each test |
class |
each test class |
module |
each test file |
package |
each package |
session |
once per test run |
Example of sharing a connection across a file but cleaning data after each test:
@pytest.fixture(scope="module")
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
yield OrderRepository(db_connection)
db_connection.execute("DELETE FROM orders")
def test_first_save(repository):
repository.save("An", 100_000)
assert repository.count() == 1
def test_second_save_starts_clean(repository):
repository.save("Binh", 50_000)
assert repository.count() == 1
db_connection is created once. repository has function scope so it clears the table after each test. As a result, the second test still sees an empty table.
There’s an important rule: a fixture can only use fixtures with equal or broader scope than itself. If a session fixture uses a function fixture, pytest reports an error:
ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.
7. Sharing fixtures via conftest.py
The customer and make_order fixtures are useful across many test files. Put them in tests/conftest.py:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
pytest automatically loads conftest.py. All tests in that directory and subdirectories can use these fixtures without importing. You can place conftest.py at multiple directory levels. Fixtures at a closer level override same-named fixtures at outer levels.
Note: Don’t
import conftestin a test file. Doing so may register the fixture twice and cause unpredictable behavior.
To see which test uses which fixtures and where they’re defined, run:
pytest --fixtures-per-test tests/test_order.py
8. Built-in fixtures
pytest provides many handy built-in fixtures. The three most commonly used are tmp_path, monkeypatch, and capsys.
Suppose shop.py has two more functions:
import os
from pathlib import Path
def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
path.write_text("\n".join(lines), encoding="utf-8")
class PaymentService:
def __init__(self):
self.api_key = os.environ["PAYMENT_API_KEY"]
def charge(self, order: "Order") -> str:
print(f"Charging {order.total()} VND")
return "SUCCESS"
Tests for them:
from shop import PaymentService, export_orders_csv
def test_export_orders_csv(tmp_path):
output = tmp_path / "orders.csv"
export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)
assert output.read_text(encoding="utf-8").splitlines() == [
"customer,total",
"An,100000",
"Binh,50000",
]
def test_payment_service_charges_order(monkeypatch, capsys, order):
monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
order.add_item("BOOK-01", 1, 75_000)
result = PaymentService().charge(order)
assert result == "SUCCESS"
assert capsys.readouterr().out == "Charging 75000 VND\n"
tmp_pathis apathlib.Pathpointing to a temporary directory unique to each test. Tests don’t clutter the project directory.monkeypatchmodifies environment variables, attributes, or functions within one test’s scope. All changes are automatically undone when the test ends. Besidessetenv, you often usemonkeypatch.setattr(obj, "name", value).capsyscaptures output written tostdoutandstderr.readouterr()returns what was printed from the start of the test or the last call.
9. Parameterizing tests
9.1. @pytest.mark.parametrize
When the same logic needs to be tested with many datasets, don’t copy the test. Use parametrize:
import pytest
@pytest.mark.parametrize(
("is_vip", "price", "expected"),
[
(False, 100_000, 100_000),
(True, 100_000, 90_000),
(True, 15, 13),
],
ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED
Each parameter set is a separate test, with readable IDs thanks to ids. Note that you can combine fixtures (make_order) with parameters. The vip-rounding-down case clearly documents the floor division behavior.
9.2. Parameterized fixtures
If you want every test using a fixture to run with multiple variants, parameterize the fixture itself:
@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
return Customer("An", is_vip=request.param)
def test_total_is_never_negative(any_customer):
order = Order(any_customer)
order.add_item("BOOK-01", 3, 10_000)
assert order.total() >= 0
request is a special fixture. request.param holds the current value. The test above automatically runs twice.
10. Common mistakes and when not to use
- Wrong fixture name. A parameter name that doesn’t match any fixture causes a
fixture 'oder' not founderror. pytest lists available fixtures right below, read carefully to fix it. - Calling a fixture like a normal function. Writing
customer()inside a test raisesFixture "customer" called directly. Declare it as a parameter instead. - Wide scope with mutable data. An
Orderwithmodulescope that one test adds items to will break another test. The result depends on execution order, very hard to debug. Only use wide scope for expensive, immutable resources, or clean state in afunction-scoped fixture like in section 6. - Abusing
autouse=True. Auto-applied fixtures are convenient (no need to declare them), but readers can’t see them in the test. Only use it for truly global concerns, like blocking the network. - Deep fixture chains. When
orderdepends oncustomer,customerdepends onaddress,addressdepends oncountry…, readers must jump through many files to understand the test. For simple data, creating it directly in the test or using a factory fixture is often more readable.
Note: Fixtures set up the environment; they shouldn’t contain verification logic. Keep
assertstatements in the test function so each test clearly says what it’s checking.
11. Conclusion
You’ve covered the core parts of pytest. Plain assert and pytest.raises verify results. Fixtures with name-based injection eliminate repeated setup code. yield provides safe setup/teardown, while scope balances speed and isolation. conftest.py shares fixtures across files. Built-in fixtures like tmp_path, monkeypatch, and capsys handle files, environment, and output. Finally, parametrize and fixtures with params cover many cases without copying tests.
As your test suite grows, start with function-scope fixtures. Only widen scope when you’ve measured that speed is really a problem.