pytest Unit Testing in Python — Fixtures, Mocks and Real Patterns
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 # 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
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
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 # 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")
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
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 # 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
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
| Feature / Aspect | pytest | unittest (built-in) |
|---|---|---|
| Test discovery | Any function starting with test_ | Must inherit from TestCase class |
| Assertions | Plain assert statement with rich diffs | Verbose: assertEqual, assertRaises, etc. |
| Fixtures / Setup | @pytest.fixture with dependency injection | setUp / tearDown methods on the class |
| Fixture scoping | function, class, module, session | Only per-method (setUp) or per-class (setUpClass) |
| Parametrize | @pytest.mark.parametrize decorator built-in | Requires external library (ddt) or manual loops |
| Output readability | Coloured diffs showing exact mismatch | Basic AssertionError message |
| Plugin ecosystem | Huge (pytest-cov, pytest-mock, pytest-django, etc.) | Minimal, relies on stdlib only |
| Learning curve | Low — start writing tests in minutes | Higher — 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=inpytest.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 passmatch='expected message fragment'topytest.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.
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.