Testing in Python with pytest: Fixtures, Parametrize and Mocks
Practice4 minInnovialabs editorial team
In short
pytest tests are plain test_* functions with ordinary assert; fixtures prepare data and resources, parametrize runs one test over many inputs, and mocks replace the network and external services.
What you need to know first
pytest finds test_*.py files, runs the test_* functions in them and checks plain assert statements. No base classes or special assertion methods are required. On top of that there are three key tools:
- fixtures — prepare data and resources for tests;
- parametrize — one test over many input sets;
- mocks — replace the network, time and external services.
Installation and your first test
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pytest
Code and test:
# app/pricing.py
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError("percent must be 0..100")
return round(price * (100 - percent) / 100, 2)
# tests/test_pricing.py
import pytest
from app.pricing import apply_discount
def test_discount_basic():
assert apply_discount(200, 10) == 180
def test_discount_invalid_percent():
with pytest.raises(ValueError):
apply_discount(100, 150)
Run pytest from the project root. Useful flags: -v for verbose output, -x to stop at the first failure, -k discount to run tests matching part of a name. When a test fails, pytest shows the values on both sides of the assert, which makes debugging easier.
Fixtures
A fixture is a function decorated with @pytest.fixture; pytest passes its result into a test by argument name.
import pytest
@pytest.fixture
def cart():
return {"items": [], "currency": "UZS"}
def test_empty_cart(cart):
assert cart["items"] == []
If a resource must be closed after the test, use yield: everything after it runs as teardown.
@pytest.fixture
def db_connection():
conn = connect_test_db()
yield conn
conn.close()
The scope parameter sets the lifetime: function (default, a fresh fixture per test), module, session. Expensive resources such as a test database are convenient to create once per session, but then make sure tests don't corrupt shared data.
Built-in fixtures worth knowing: tmp_path for a temporary folder, monkeypatch for replacing attributes and environment variables, capsys for capturing output.
conftest.py
Put fixtures shared across files into conftest.py. pytest picks it up automatically — no imports needed. A conftest.py in tests/ is visible to every test inside it; one in a subfolder applies only to that subfolder.
Parametrize
One test instead of ten near-identical ones:
@pytest.mark.parametrize(
"price, percent, expected",
[
(100, 0, 100),
(100, 25, 75),
(99.99, 10, 89.99),
(100, 100, 0),
],
)
def test_discount(price, percent, expected):
assert apply_discount(price, percent) == expected
Each set appears in the report as a separate test, so you see exactly which case failed. Pass ids=[...] for readable names.
Mocking HTTP calls
Tests should not hit the real network: it is slow, flaky and may change someone else's data. Replace the call at the level of your own code:
# app/rates.py
import requests
def get_usd_rate():
resp = requests.get("https://api.example.com/rates", timeout=5)
resp.raise_for_status()
return resp.json()["USD"]
# tests/test_rates.py
from unittest.mock import Mock
from app import rates
def test_get_usd_rate(monkeypatch):
fake = Mock()
fake.json.return_value = {"USD": 12650.0}
fake.raise_for_status.return_value = None
monkeypatch.setattr(rates.requests, "get", Mock(return_value=fake))
assert rates.get_usd_rate() == 12650.0
The key rule: patch where the object is used, not where it is defined. For convenient HTTP mocking there are dedicated libraries, such as responses for requests or respx for httpx, which let you describe fake responses declaratively.
Test layout for a real project
project/
├── app/
│ ├── pricing.py
│ └── rates.py
├── tests/
│ ├── conftest.py
│ ├── unit/
│ │ └── test_pricing.py
│ └── integration/
│ └── test_rates_api.py
└── pyproject.toml
Settings go into pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
markers = ["integration: tests that need external services"]
Mark slow tests with @pytest.mark.integration and run them separately: pytest -m "not integration".
Common mistakes
- Tests depend on run order or shared state.
- Mocks replace too much — the test checks the mock, not the code.
- Comparing
floatvalues with==instead ofpytest.approx. - One test checks five different things — when it fails, it's unclear what broke.