Home Python pytest Unit Testing in Python — Fixtures, Mocks and Real Patterns

pytest Unit Testing in Python — Fixtures, Mocks and Real Patterns

In Plain English 🔥
Imagine you build LEGO sets for a living. Before you ship a 2,000-piece Millennium Falcon to a customer, you test every individual brick connection — does this clip lock? Does that axle spin? Unit testing is exactly that: checking each tiny piece of your code works correctly in isolation, before you connect everything together and discover the whole ship won't fly. pytest is the testing toolbox that makes those checks fast, readable, and even kind of fun.
⚡ Quick Answer
Imagine you build LEGO sets for a living. Before you ship a 2,000-piece Millennium Falcon to a customer, you test every individual brick connection — does this clip lock? Does that axle spin? Unit testing is exactly that: checking each tiny piece of your code works correctly in isolation, before you connect everything together and discover the whole ship won't fly. pytest is the testing toolbox that makes those checks fast, readable, and even kind of fun.

Every professional Python codebase has tests — not because developers enjoy writing extra code, but because untested code is a ticking clock. A function that works today can silently break when a colleague refactors a helper, when a dependency updates, or when an edge case reaches production at 2 AM on a Friday. pytest has become the de-facto testing framework for Python precisely because it removes the ceremony that made Python's built-in unittest feel like bureaucracy. You write plain functions, you get readable failures, and you spend your energy on real problems.

The problem pytest solves is not just 'catching bugs' — that's too vague. It solves the specific problem of giving you fast, repeatable, isolated feedback. Without tests, every change you make requires you to mentally trace through your entire codebase to figure out what you might have broken. With a well-structured pytest suite, that mental overhead collapses to a single command. The framework handles setup, teardown, parameterisation and mocking in ways that feel Pythonic rather than bolted-on.

By the end of this article you'll be able to structure a real pytest suite from scratch, understand why fixtures exist and when to scope them correctly, use parametrize to eliminate copy-paste tests, mock external dependencies so your tests stay fast and deterministic, and avoid the three mistakes that silently corrupt test suites in production codebases.

Writing Your First Meaningful pytest Tests (and Understanding Test Discovery)

pytest doesn't require you to inherit from a class or call any registration function. You write a Python function whose name starts with test_, and pytest finds it automatically. That discovery mechanism is deliberate: it lowers the friction enough that developers actually write tests instead of deferring them.

But 'meaningful' is the word that matters. A test that only verifies the happy path gives you false confidence. Real tests cover the happy path, the edge case, and the failure mode. Think of these three as the three questions you ask about every function: What happens when everything is correct? What happens at the boundary? What happens when the input is wrong?

The assert statement is all you need for assertions — pytest intercepts it and produces a detailed failure message showing the actual versus expected values. No need for assertEqual, assertRaises wrappers from unittest. This keeps the cognitive load low and the code readable for anyone on your team.

test_price_calculator.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839
# test_price_calculator.py
# Run with: pytest test_price_calculator.py -v

def apply_discount(original_price: float, discount_percent: float) -> float:
    """
    Returns the discounted price.
    Raises ValueError if discount is not between 0 and 100.
    """
    if not (0 <= discount_percent <= 100):
        raise ValueError(f"Discount must be between 0 and 100, got {discount_percent}")
    discount_amount = original_price * (discount_percent / 100)
    return round(original_price - discount_amount, 2)


# --- Happy path: normal expected usage ---
def test_apply_discount_returns_correct_price():
    discounted = apply_discount(original_price=200.00, discount_percent=10)
    assert discounted == 180.00  # 10% off £200 should be £180


# --- Edge case: zero discount means price is unchanged ---
def test_apply_discount_with_zero_percent_returns_original():
    discounted = apply_discount(original_price=99.99, discount_percent=0)
    assert discounted == 99.99  # 0% off should change nothing


# --- Edge case: 100% discount means free ---
def test_apply_discount_with_full_discount_returns_zero():
    discounted = apply_discount(original_price=50.00, discount_percent=100)
    assert discounted == 0.00


# --- Failure mode: invalid discount raises the right exception ---
def test_apply_discount_raises_for_negative_discount():
    import pytest
    with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
        apply_discount(original_price=100.00, discount_percent=-5)
        # The match= argument verifies the error message content —
        # this prevents a different ValueError from silently passing
▶ Output
collected 4 items

test_price_calculator.py::test_apply_discount_returns_correct_price PASSED
test_price_calculator.py::test_apply_discount_with_zero_percent_returns_original PASSED
test_price_calculator.py::test_apply_discount_with_full_discount_returns_zero PASSED
test_price_calculator.py::test_apply_discount_raises_for_negative_discount PASSED

4 passed in 0.12s
⚠️
Pro Tip:Always pass `match=` to `pytest.raises()`. Without it, any ValueError — even one thrown for a completely unrelated reason — will make your test pass. The match argument turns a vague catch into a precise assertion.

pytest Fixtures — Reusable Setup That Scales Without Repetition

A fixture is pytest's answer to the question: 'How do I share setup code between tests without copy-pasting it everywhere?' If ten tests all need a database connection, a configured object, or a temporary file, you shouldn't be creating those ten times — you should define them once and let pytest inject them.

Fixtures work through dependency injection: you declare a fixture with @pytest.fixture, then list its name as a parameter in any test function that needs it. pytest sees that parameter name and hands the test function the fixture's return value automatically. No import needed, no manual wiring.

The scope argument is where most intermediate developers unlock a significant performance gain. By default, fixtures are created fresh for every single test. That's correct for something like a clean dictionary, but wasteful for something like spinning up a database connection. Setting scope='module' creates the fixture once per file; scope='session' creates it once for the entire test run. Choosing the right scope is not about performance alone — it's about test isolation. A mutable object shared between tests can cause one test's side effects to corrupt another's results.

test_user_service.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
# test_user_service.py
# Run with: pytest test_user_service.py -v

import pytest


class UserService:
    """Simulates a real service that manages user accounts."""

    def __init__(self, storage: dict):
        self._storage = storage  # In production this would be a DB session

    def create_user(self, username: str, email: str) -> dict:
        if username in self._storage:
            raise ValueError(f"Username '{username}' is already taken")
        user = {"username": username, "email": email, "active": True}
        self._storage[username] = user
        return user

    def deactivate_user(self, username: str) -> None:
        if username not in self._storage:
            raise KeyError(f"User '{username}' does not exist")
        self._storage[username]["active"] = False

    def get_user(self, username: str) -> dict:
        return self._storage.get(username)


# --- Fixture: creates a FRESH UserService for every test ---
# Scope defaults to 'function' — each test gets its own clean slate
@pytest.fixture
def user_service():
    fresh_storage = {}  # Empty dict = no leftover data from other tests
    return UserService(storage=fresh_storage)


# --- Fixture with setup AND teardown using yield ---
@pytest.fixture
def user_service_with_existing_user(user_service):
    # Setup: add a pre-existing user before the test runs
    user_service.create_user(username="alice", email="alice@example.com")
    yield user_service  # Hand control to the test
    # Teardown: anything after yield runs after the test completes
    # (Here the dict goes out of scope anyway, but in a real DB you'd rollback here)


def test_create_user_succeeds(user_service):
    # 'user_service' parameter name matches the fixture — pytest injects it
    new_user = user_service.create_user(username="bob", email="bob@example.com")
    assert new_user["username"] == "bob"
    assert new_user["active"] is True


def test_create_duplicate_user_raises_error(user_service_with_existing_user):
    with pytest.raises(ValueError, match="already taken"):
        # alice already exists — this should fail at the service level
        user_service_with_existing_user.create_user(
            username="alice", email="alice2@example.com"
        )


def test_deactivate_user_sets_active_to_false(user_service_with_existing_user):
    user_service_with_existing_user.deactivate_user(username="alice")
    alice = user_service_with_existing_user.get_user(username="alice")
    assert alice["active"] is False  # Deactivation should flip the flag


def test_deactivate_nonexistent_user_raises_key_error(user_service):
    with pytest.raises(KeyError):
        user_service.deactivate_user(username="ghost_user")
▶ Output
collected 4 items

test_user_service.py::test_create_user_succeeds PASSED
test_user_service.py::test_create_duplicate_user_raises_error PASSED
test_user_service.py::test_deactivate_user_sets_active_to_false PASSED
test_user_service.py::test_deactivate_nonexistent_user_raises_key_error PASSED

4 passed in 0.09s
⚠️
Watch Out:Using scope='session' with a mutable fixture (like a dict or list) means all tests share the same object. If test A adds data and test B expects an empty state, test B will fail — but only when tests run in a certain order. This is called a 'flaky test' and it's one of the hardest bugs to track down. Use function scope for mutable state; reserve session scope for read-only or immutable resources like configuration objects.

parametrize and Mocking — Eliminating Repetition and Isolating Dependencies

@pytest.mark.parametrize solves a specific problem: when you need to run the same test logic against many different inputs, copy-pasting test functions is a maintenance nightmare. Change the function's signature and you have to update ten tests instead of one. Parametrize lets you define the test logic once and feed it a table of inputs and expected outputs.

Mocking solves a different but equally important problem: your code will depend on things you don't control — external APIs, databases, the system clock, file systems. If your test for a 'send welcome email' function actually sends an email every time it runs, your test suite is slow, brittle, and has real-world side effects. unittest.mock.patch (which works perfectly with pytest) replaces a dependency with a controlled fake for the duration of a single test.

The key insight about mocking is WHERE you patch, not what. You patch the name as it's imported into the module under test, not where it's defined. Getting this wrong is the single most common mocking mistake in Python, and it produces confusing failures where the real function still runs despite the patch.

test_email_notifier.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
# test_email_notifier.py
# Run with: pytest test_email_notifier.py -v

import pytest
from unittest.mock import patch, MagicMock


# ── Module under test (inline for demonstration) ──────────────────────────────

def validate_email_format(email: str) -> bool:
    """Basic check: must contain @ and at least one dot after @"""
    if "@" not in email:
        return False
    local, _, domain = email.partition("@")
    return bool(local) and "." in domain


def calculate_order_total(unit_price: float, quantity: int, tax_rate: float) -> float:
    """Total = (unit_price * quantity) + tax"""
    subtotal = unit_price * quantity
    return round(subtotal + (subtotal * tax_rate), 2)


# Simulates a service that calls an external SMTP API
class EmailNotifier:
    def __init__(self, smtp_client):
        self._smtp = smtp_client  # This is the external dependency we'll mock

    def send_welcome_email(self, recipient_email: str, username: str) -> bool:
        if not validate_email_format(recipient_email):
            raise ValueError(f"Invalid email address: {recipient_email}")
        self._smtp.send(
            to=recipient_email,
            subject="Welcome!",
            body=f"Hi {username}, welcome aboard!",
        )
        return True


# ── PARAMETRIZE: test one function with many inputs ───────────────────────────

@pytest.mark.parametrize(
    "unit_price, quantity, tax_rate, expected_total",
    [
        (10.00,  1,  0.00,  10.00),   # No tax
        (10.00,  3,  0.10,  33.00),   # 10% tax on 3 items
        (99.99,  2,  0.20, 239.98),   # 20% VAT on 2 items
        ( 0.00,  5,  0.25,   0.00),   # Free item, any quantity
    ],
    ids=["no_tax", "10pct_tax", "20pct_vat", "free_item"],  # Readable test IDs
)
def test_calculate_order_total(unit_price, quantity, tax_rate, expected_total):
    # Same assertion logic, but pytest runs this four times with different data
    result = calculate_order_total(unit_price, quantity, tax_rate)
    assert result == expected_total


# ── MOCKING: replace external SMTP client with a controlled fake ──────────────

def test_send_welcome_email_calls_smtp_with_correct_args():
    mock_smtp = MagicMock()  # A fake object that records every call made to it

    notifier = EmailNotifier(smtp_client=mock_smtp)
    result = notifier.send_welcome_email(
        recipient_email="charlie@example.com", username="charlie"
    )

    assert result is True
    # Verify the smtp client was called exactly once with the right arguments
    mock_smtp.send.assert_called_once_with(
        to="charlie@example.com",
        subject="Welcome!",
        body="Hi charlie, welcome aboard!",
    )


def test_send_welcome_email_raises_for_invalid_email():
    mock_smtp = MagicMock()
    notifier = EmailNotifier(smtp_client=mock_smtp)

    with pytest.raises(ValueError, match="Invalid email address"):
        notifier.send_welcome_email(
            recipient_email="not-an-email", username="dave"
        )

    # Crucially: the smtp client should NEVER have been called for a bad address
    mock_smtp.send.assert_not_called()


@pytest.mark.parametrize(
    "email, expected_valid",
    [
        ("user@example.com",    True),
        ("bad-email",           False),
        ("missing@dot",         False),
        ("@nodomain.com",       False),
        ("two@dots.are.fine",   True),
    ],
)
def test_validate_email_format(email, expected_valid):
    assert validate_email_format(email) == expected_valid
▶ Output
collected 12 items

test_email_notifier.py::test_calculate_order_total[no_tax] PASSED
test_email_notifier.py::test_calculate_order_total[10pct_tax] PASSED
test_email_notifier.py::test_calculate_order_total[20pct_vat] PASSED
test_email_notifier.py::test_calculate_order_total[free_item] PASSED
test_email_notifier.py::test_send_welcome_email_calls_smtp_with_correct_args PASSED
test_email_notifier.py::test_send_welcome_email_raises_for_invalid_email PASSED
test_email_notifier.py::test_validate_email_format[user@example.com-True] PASSED
test_email_notifier.py::test_validate_email_format[bad-email-False] PASSED
test_email_notifier.py::test_validate_email_format[missing@dot-False] PASSED
test_email_notifier.py::test_validate_email_format[@nodomain.com-False] PASSED
test_email_notifier.py::test_validate_email_format[two@dots.are.fine-True] PASSED

11 passed in 0.14s
🔥
Interview Gold:Interviewers love asking 'where do you patch?' The answer is: patch the name in the module that USES it, not where it's DEFINED. If your module does `from smtplib import SMTP`, you patch `your_module.SMTP`, not `smtplib.SMTP`. Getting this wrong means your mock does nothing and the real code runs — which is a frustratingly hard bug to spot.
Feature / Aspectpytestunittest (built-in)
Test discoveryAny function starting with test_Must inherit from TestCase class
AssertionsPlain assert statement with rich diffsVerbose: assertEqual, assertRaises, etc.
Fixtures / Setup@pytest.fixture with dependency injectionsetUp / tearDown methods on the class
Fixture scopingfunction, class, module, sessionOnly per-method (setUp) or per-class (setUpClass)
Parametrize@pytest.mark.parametrize decorator built-inRequires external library (ddt) or manual loops
Output readabilityColoured diffs showing exact mismatchBasic AssertionError message
Plugin ecosystemHuge (pytest-cov, pytest-mock, pytest-django, etc.)Minimal, relies on stdlib only
Learning curveLow — start writing tests in minutesHigher — class structure required from day one

🎯 Key Takeaways

  • pytest discovers tests automatically from any function named test_* — no class inheritance or registration required, which removes the friction that stops developers from writing tests.
  • Fixtures are dependency injection for tests — define setup logic once with @pytest.fixture and pytest injects it by parameter name. Use yield inside a fixture to add teardown logic that runs even when tests fail.
  • @pytest.mark.parametrize lets you run one test function against a table of inputs — this is the correct way to test multiple cases, not copy-pasting test functions that will drift out of sync.
  • When mocking, patch the name as it's imported into the module under test, not where the object is originally defined — getting this wrong is the #1 reason mocks appear to do nothing.

⚠ Common Mistakes to Avoid

  • Mistake 1: Not using match= in pytest.raises() — Your test catches any exception of that type, including ones thrown for completely different reasons, so it passes even when the wrong code path is hit — Fix: always pass match='expected message fragment' to pytest.raises() to assert both the exception type AND the message.
  • Mistake 2: Sharing mutable fixtures across tests with the wrong scope — Test results depend on execution order; tests pass alone but fail when the full suite runs — Fix: use the default scope='function' for any fixture that holds mutable state (dicts, lists, objects with state). Only promote to 'module' or 'session' scope for read-only, expensive-to-create resources like config objects.
  • Mistake 3: Patching at the wrong import location — Your mock is applied but the real function still executes, so assertions on the mock always fail — Fix: patch the name as seen by the module under test (myapp.services.requests.get), not where it originates (requests.get). The rule is: patch where it's used, not where it lives.

Interview Questions on This Topic

  • QWhat is the difference between a pytest fixture with scope='function' and scope='session', and when would using the wrong one cause test failures?
  • QHow does pytest.mark.parametrize improve test maintainability compared to writing separate test functions for each input case?
  • QIf you mock `requests.get` using `@patch('requests.get')` but your tests show the real HTTP call is still being made, what's the most likely cause and how do you fix it?

Frequently Asked Questions

How do I run only one specific test with pytest instead of the whole suite?

Use the :: syntax to target a specific test: pytest test_user_service.py::test_create_user_succeeds -v. You can also use the -k flag to filter by keyword: pytest -k 'discount' will run any test whose name contains 'discount'. This is invaluable when debugging a single failing test.

What is conftest.py in pytest and why does it exist?

conftest.py is a special file that pytest loads automatically before running tests. Fixtures defined inside it are available to every test in the same directory and all subdirectories — no import needed. It's the right place to put shared fixtures like database connections, API clients, or application configuration that multiple test files need.

Should I mock everything in a unit test, or is it okay to use real objects?

Mock anything that crosses a boundary you don't control — external APIs, databases, the file system, the system clock, and network calls. For pure functions and plain objects that have no external dependencies, use the real thing. Over-mocking is a real problem: if you mock so much that your test no longer exercises real logic, you're testing your mocks rather than your code.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousType Hints in PythonNext →Virtual Environments in Python
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged