Skip to content
Innovialabs

Testing in Python with pytest: Fixtures, Parametrize and Mocks

Practice4 min

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 float values with == instead of pytest.approx.
  • One test checks five different things — when it fails, it's unclear what broke.

FAQ

pytest runs unittest tests too, but is shorter to write: plain assert, fixtures instead of setUp, built-in parametrization and a rich plugin ecosystem.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.