
Let's be honest about something. Most tutorials teach you how to write a test. They show you assert 1 + 1 == 2, maybe walk you through a simple function, and call it a day. What they rarely show you is what happens when your codebase grows past a dozen files, when you have twenty tests that all need the same database connection, when a single function needs to be verified against a hundred different inputs, and when half your logic depends on an external API you cannot call in a test environment. That's where most developers hit a wall. Their test files become tangled, brittle messes of copy-pasted setup code and fragile assertions. Tests start failing randomly because one test left dirty data in a shared resource. The whole suite takes three minutes because every test is making real HTTP requests. Developers stop running tests locally because they're too slow, too noisy, and too painful to fix when they break.
The good news is that pytest was built specifically to solve these problems, and it does so with three tools that, once you internalize them, will fundamentally change how you think about testing. Fixtures give you a clean, composable way to manage test setup and teardown without duplication. The parametrize decorator lets you express data-driven testing so naturally that you'll wonder how you lived without it. Mocking lets you cut your code free from its external dependencies entirely, so you can test a payment processor without charging a card or test an API client without touching the network. Together, these three tools are the difference between a test suite that slows your team down and one that gives you genuine confidence to ship.
In this article, we're going to level up from the fundamentals you learned in article 41. You'll see how to use fixtures for clean setup and teardown, how parametrize lets you write one test that runs with many different inputs, and how mocking lets you test API consumers without actually hitting the network. We'll cover the concepts that trip up intermediate developers, scope confusion, patch target mistakes, and the subtle differences between spying and mocking. By the end, you'll have the skills to write tests that are both thorough and maintainable. You'll also understand the "why" behind each tool, not just the "how," which is what separates developers who can write tests from developers who can build testable systems.
Table of Contents
- Why Fixtures Changed Testing
- Fixtures: The Foundation of Test Setup
- Fixture Scopes Explained
- Sharing Fixtures with conftest.py
- Parametrize: Running One Test with Many Inputs
- Combining Fixtures and Parametrize: Matrix Testing
- When and How to Mock
- Mocking HTTP Requests
- Mocking with patch.object
- Spies: Mocking While Keeping Real Behavior
- Common Mocking Patterns
- Testing External APIs with responses Library
- Common Testing Mistakes
- Key Takeaways
Why Fixtures Changed Testing
Before fixtures, Python developers mostly used setUp and tearDown methods from the unittest module. If you needed a database connection in ten different test methods, you'd call the connection setup in setUp and tear it down in tearDown, methods that ran before and after every single test in the class. This worked, but it had serious limitations. You couldn't easily share setup code across different test classes or files. You couldn't compose multiple setup routines together. And the class-based structure forced you to group tests in ways that made organizational sense from a setup perspective rather than a behavior perspective.
Pytest's fixture system solved all of this by turning setup and teardown into composable, injectable functions. Instead of inheritance-based setup methods, you declare what a test needs by name, and pytest wires it up for you automatically. This dependency injection model means you can mix and match fixtures freely. A test that needs a database connection and a logged-in user just declares both fixtures as parameters, and pytest handles the orchestration. Neither fixture needs to know about the other. You can combine them in any combination across any test file in the project.
This composability is the key insight. Fixtures are not just a cleaner syntax for setUp, they are a fundamentally different architectural approach. A fixture can depend on other fixtures, forming a dependency graph that pytest resolves automatically. A test that needs an authenticated API client might depend on a fixture that creates a client, which itself depends on a fixture that creates a user, which depends on a fixture that provides a database connection. You write each piece once, and pytest assembles them. When your authentication logic changes, you update one fixture, and every test that uses it automatically gets the new behavior. That's leverage.
Fixtures: The Foundation of Test Setup
Fixtures are pytest's answer to the question: "How do I avoid repeating the same setup code in every test?" They're functions decorated with @pytest.fixture that pytest will call automatically and inject into your test functions as arguments. Think of them as reusable test infrastructure, building blocks you compose to create exactly the environment each test needs, without any more setup than that test actually requires.
Let's say you have a simple class that needs a database connection:
class UserRepository:
def __init__(self, db_connection):
self.db = db_connection
def get_user(self, user_id):
return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
def create_user(self, name, email):
self.db.execute(f"INSERT INTO users (name, email) VALUES ('{name}', '{email}')")Without fixtures, you'd have to create that database connection in every single test, copy-pasting the same three lines of setup code and remembering to close the connection in a finally block to avoid leaks. Instead, you write a fixture once and never think about the setup logistics again:
import pytest
import sqlite3
@pytest.fixture
def db_connection():
"""Create a test database connection."""
connection = sqlite3.connect(':memory:')
yield connection # The test uses this
connection.close() # Teardown happens hereThen any test can use it simply by naming the fixture as a parameter:
def test_get_user(db_connection):
repo = UserRepository(db_connection)
user = repo.get_user(1)
assert user['name'] == 'Alice'
def test_create_user(db_connection):
repo = UserRepository(db_connection)
repo.create_user('Bob', 'bob@example.com')
result = db_connection.query("SELECT * FROM users WHERE name = 'Bob'")
assert result is not NoneSee what happened? Pytest saw that both tests needed db_connection as a parameter, so it called the fixture function automatically and passed the result to each test. The yield keyword is crucial, everything before yield is setup, and everything after is teardown. It's like a context manager, but built into pytest. Even if the test raises an exception, pytest guarantees that the teardown code after yield runs, which means your connections get closed and your temporary files get deleted no matter what.
Fixture Scopes Explained
Scope is one of those concepts that seems simple until you hit a subtle bug at 2am wondering why your test suite is failing intermittently. The core question scope answers is: "How long should this fixture live?" By default, pytest creates a fresh fixture for each test function and destroys it immediately after. But creating certain resources, like a database schema, a test server, or an expensive computation, for every single test would be prohibitively slow.
Pytest offers four scope levels, and understanding when to use each one is essential:
@pytest.fixture(scope="function") # Default: new for each test
def fresh_fixture():
return []
@pytest.fixture(scope="class") # Shared across all tests in a class
def class_fixture():
return {"data": "shared"}
@pytest.fixture(scope="module") # Shared across all tests in a file
def module_fixture():
return connect_to_test_db()
@pytest.fixture(scope="session") # Shared across the entire test run
def session_fixture():
return expensive_resource()The mental model to keep straight: narrower scopes give you test isolation (each test gets a clean slate), while broader scopes give you performance (the resource is only created once). The right choice depends entirely on whether your tests modify the resource. If a test can mutate a fixture's state, you need function scope. If tests only read from a fixture and never change it, you can safely use module or session scope and save yourself the setup overhead.
Here's where people get tripped up: if you're modifying a mutable object (like a list or dict) in a test, and that fixture has scope="class" or broader, your modifications will leak into other tests. So be careful. If you need a fresh copy for each test, use scope="function" or make sure your fixture creates a new object each time.
Let's see this in action:
@pytest.fixture(scope="module")
def test_data():
return {"users": ["alice", "bob"]}
def test_add_user_1(test_data):
test_data["users"].append("charlie")
assert len(test_data["users"]) == 3
def test_add_user_2(test_data):
# If you ran test_add_user_1 first, this will have 4 users!
assert len(test_data["users"]) == 4 # or 3 if this ran firstThis is a bug. The test results depend on execution order, which means your test suite is non-deterministic. You'll see it pass locally and fail in CI, or pass on one developer's machine and fail on another's. The fix? Either use scope="function" (the default), or have the fixture return a fresh copy:
@pytest.fixture(scope="module")
def test_data():
return {"users": ["alice", "bob"]}
def test_add_user_1(test_data):
data_copy = test_data.copy() # Work with a copy
data_copy["users"].append("charlie")
assert len(data_copy["users"]) == 3A useful rule of thumb: if you find yourself asking "should I use module or session scope here?", think about what happens when a test modifies the resource. Session-scope fixtures are great for things like spinning up a test server, compiling a regex, or loading a large dataset from disk, operations that are expensive and where the result is truly read-only. Module-scope fixtures work well for database connections where each test rolls back its own transactions, leaving the connection itself unchanged.
Sharing Fixtures with conftest.py
Your fixtures don't have to live in the same file as your tests. If you create a conftest.py file in your test directory, pytest automatically discovers and makes all fixtures in that file available to every test file in that directory and subdirectories. This is how you build a shared library of test infrastructure that the entire project can use without importing anything, pytest's dependency injection takes care of the wiring.
project/
├── src/
│ └── app.py
└── tests/
├── conftest.py # Shared fixtures here
├── test_users.py
├── test_products.py
└── integration/
├── conftest.py # Fixtures scoped to integration tests
└── test_api.py
In tests/conftest.py:
import pytest
from src.app import create_app
@pytest.fixture
def app():
"""Create and configure a test app instance."""
app = create_app(config='testing')
return app
@pytest.fixture
def client(app):
"""Provide a test client for the app."""
return app.test_client()
@pytest.fixture
def db(app):
"""Provide a test database."""
with app.app_context():
yield app.db
app.db.session.rollback()Notice that client takes app as a parameter, fixtures can depend on other fixtures, and pytest resolves the whole dependency chain for you. When a test requests client, pytest first builds app, then passes it to client, then passes the resulting test client to the test. The ordering is deterministic, and the teardown happens in reverse order.
Now in test_users.py, you don't import these fixtures, you just use them:
def test_user_creation(client, db):
response = client.post('/users', json={'name': 'Alice'})
assert response.status_code == 201
user = db.session.query(User).filter_by(name='Alice').first()
assert user is not NonePytest found client and db in conftest.py and injected them automatically. You can also nest conftest.py files for different scopes of fixtures, integration tests might have their own fixtures in tests/integration/conftest.py. Fixtures in a subdirectory's conftest.py are only available to tests in that directory and its subdirectories, which lets you keep heavyweight integration fixtures from accidentally being used in fast unit tests.
Parametrize: Running One Test with Many Inputs
Parametrize is pytest's way of saying, "Run this test multiple times with different data." Instead of writing the same test over and over with different inputs, a pattern called "test cloning" that produces fragile, hard-to-maintain test suites, you write it once and tell pytest what inputs to use. This is the core of data-driven testing, and once you see it in action you'll wonder why you ever wrote repeated test cases.
import pytest
@pytest.mark.parametrize("input,expected", [
(2, 4),
(3, 9),
(4, 16),
(-1, 1),
])
def test_square(input, expected):
assert input ** 2 == expectedPytest will run test_square four times, once for each tuple in the list. The parameter names (input, expected) become local variables in the test. If any test fails, you'll see which input caused the failure:
test_math.py::test_square[2-4] PASSED
test_math.py::test_square[3-9] PASSED
test_math.py::test_square[4-16] PASSED
test_math.py::test_square[-1-1] PASSED
This output format is one of the most underrated features of parametrize. Each test case gets its own ID derived from the parameter values, so when a test fails in CI you can run exactly that failing case locally with pytest test_math.py::test_square[-1-1] and reproduce it instantly. With copy-pasted test functions, you'd need to search through multiple nearly-identical functions to find the failing one.
You can parametrize multiple arguments at once, which is the right pattern for testing functions where several inputs interact:
@pytest.mark.parametrize("username,password,expected_status", [
("alice", "password123", 200),
("bob", "wrong", 401),
("", "anything", 400),
("charlie", "", 400),
])
def test_login(username, password, expected_status):
response = login(username, password)
assert response.status_code == expected_statusFor complex test data, use indirect parametrization:
@pytest.mark.parametrize("user_data", [
{"name": "alice", "role": "admin"},
{"name": "bob", "role": "user"},
{"name": "charlie", "role": "guest"},
])
def test_user_permissions(user_data):
user = User(**user_data)
assert user.name == user_data["name"]When your parameter sets get large, consider moving them to a module-level variable or loading them from a file. Keeping your test logic and your test data cleanly separated makes both easier to maintain and makes it trivially easy to add new test cases without touching the test function itself.
Combining Fixtures and Parametrize: Matrix Testing
Here's where it gets powerful: you can use fixtures and parametrize together. Pytest will run your test once for every combination of fixture parameter and parametrize parameter. When you need to verify that your code behaves correctly across multiple environments or backends, this combination is invaluable, it gives you comprehensive coverage without any code duplication.
@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def db_backend(request):
"""Fixture that parametrizes across different database backends."""
if request.param == "sqlite":
return SQLiteDB(":memory:")
elif request.param == "postgres":
return PostgresDB("test_db")
elif request.param == "mysql":
return MysqlDB("test_db")
def test_user_creation(db_backend):
# This test runs three times: once per database backend
db_backend.execute("CREATE TABLE users (id INT, name TEXT)")
db_backend.execute("INSERT INTO users VALUES (1, 'Alice')")
result = db_backend.query("SELECT * FROM users WHERE id = 1")
assert result[0]['name'] == 'Alice'Pytest will run test_user_creation three times:
test_database.py::test_user_creation[sqlite] PASSED
test_database.py::test_user_creation[postgres] PASSED
test_database.py::test_user_creation[mysql] PASSED
You can combine this with @pytest.mark.parametrize for even more coverage:
@pytest.mark.parametrize("num_records", [1, 10, 100])
def test_query_performance(db_backend, num_records):
# This runs 3 (databases) x 3 (record counts) = 9 times
for i in range(num_records):
db_backend.insert(f"INSERT INTO data VALUES ({i})")
result = db_backend.query("SELECT COUNT(*) FROM data")
assert result[0] == num_recordsNow you've written one test that covers 9 scenarios. That's the power of parametrize. The key insight is that you've separated the "what to test" (query performance) from the "context to test it in" (which database, how many records), and pytest handles the combinatorial expansion automatically. Add a fourth database backend to the fixture, and every parametrized test that uses it automatically gains coverage for that backend without any other changes.
When and How to Mock
Mocking is the tool that makes fast, isolated unit testing possible for real-world code. Here's the problem it solves: most interesting code has side effects. It sends emails, charges cards, writes to S3, calls weather APIs, queries databases. If your tests actually trigger those side effects, they're slow (network round trips), expensive (real API calls cost money), non-deterministic (the API might be down), and dangerous (you might accidentally send a real email to a real customer from your test environment). Mocking breaks those dependencies by replacing the real thing with a controlled fake that behaves exactly how you tell it to.
The when is straightforward: mock anything that crosses a system boundary. Database calls, HTTP requests, file system operations, time functions like datetime.now(), random number generators, email senders, payment processors. Anything that would make your tests slow, flaky, or expensive. The how requires one important mental model: you mock the name as it's used in the module under test, not where it's defined. If your code does import requests and then calls requests.get(...), you patch yourmodule.requests.get, not requests.get. This is the mistake that trips up almost every developer learning mocking for the first time.
Python's unittest.mock module (which pytest works with seamlessly) provides two main tools: MagicMock and patch.
MagicMock is a fake object that records what methods were called on it:
from unittest.mock import MagicMock
def test_api_call():
mock_http = MagicMock()
mock_http.get.return_value = {"status": "ok"}
result = mock_http.get("https://api.example.com/status")
assert result == {"status": "ok"}
mock_http.get.assert_called_once_with("https://api.example.com/status")patch replaces a real object with a mock for the duration of your test:
from unittest.mock import patch
@patch('requests.get')
def test_fetch_user(mock_get):
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
from myapp import fetch_user
user = fetch_user(1)
assert user["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")Here's what's happening: we're replacing requests.get with a mock. When the code calls requests.get(), it gets our fake version, which returns whatever we configured. We can then assert that it was called with the right arguments. Notice that mock_get.return_value.json.return_value chains two .return_value accesses, the first because requests.get() returns a response object, and the second because calling .json() on that response returns the data. MagicMock automatically creates sub-mocks for any attribute access or method call, which is what makes this chaining work.
Mocking HTTP Requests
One of the most common use cases for mocking is testing code that makes HTTP requests. Let's say you have an API client:
import requests
class UserClient:
def __init__(self, base_url):
self.base_url = base_url
def get_user(self, user_id):
response = requests.get(f"{self.base_url}/users/{user_id}")
response.raise_for_status()
return response.json()
def create_user(self, name, email):
response = requests.post(
f"{self.base_url}/users",
json={"name": name, "email": email}
)
response.raise_for_status()
return response.json()You want to test this without hitting the real API. Here's how:
from unittest.mock import patch, MagicMock
@patch('requests.post')
@patch('requests.get')
def test_get_user(mock_get, mock_post):
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
client = UserClient("https://api.example.com")
user = client.get_user(1)
assert user["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")
@patch('requests.post')
@patch('requests.get')
def test_create_user(mock_get, mock_post):
mock_post.return_value.json.return_value = {"id": 2, "name": "Bob"}
client = UserClient("https://api.example.com")
user = client.create_user("Bob", "bob@example.com")
assert user["name"] == "Bob"
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args[0][0] == "https://api.example.com/users"Notice the stacking of decorators: when you have multiple @patch decorators, the mocks are passed to the test function in reverse order (bottom decorator is the first argument). This reversal trips up almost everyone the first time, just remember "bottom to top" when reading the function signature, and you'll always get the mapping right.
For cleaner code with complex mocks, use a fixture:
@pytest.fixture
def mock_requests(monkeypatch):
"""Mock the requests library."""
mock_get = MagicMock()
mock_post = MagicMock()
monkeypatch.setattr('requests.get', mock_get)
monkeypatch.setattr('requests.post', mock_post)
return {'get': mock_get, 'post': mock_post}
def test_get_user(mock_requests):
mock_requests['get'].return_value.json.return_value = {"id": 1, "name": "Alice"}
client = UserClient("https://api.example.com")
user = client.get_user(1)
assert user["name"] == "Alice"The monkeypatch fixture (built into pytest) is another way to temporarily replace objects. It's particularly useful in fixtures because it automatically cleans up after the test. You don't need to worry about teardown, monkeypatch restores the original values when the test ends, even if the test raises an exception. For complex test setups that mock multiple things, this makes the fixture approach significantly more readable than stacking multiple @patch decorators.
Mocking with patch.object
Sometimes you want to mock a method on a specific object instance. That's where patch.object comes in:
from unittest.mock import patch
class Database:
def query(self, sql):
# Real database query
pass
class UserRepository:
def __init__(self, db):
self.db = db
def get_user(self, user_id):
result = self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
return result[0] if result else None
@patch.object(Database, 'query')
def test_get_user_with_patch_object(mock_query):
mock_query.return_value = [{"id": 1, "name": "Alice"}]
db = Database()
repo = UserRepository(db)
user = repo.get_user(1)
assert user["name"] == "Alice"
mock_query.assert_called_once_with("SELECT * FROM users WHERE id = 1")patch.object replaces the method on the class itself, so it affects all instances created after the patch is applied. This is more precise than patching a module-level import, it targets exactly the class you care about rather than every user of a particular import. Use patch.object when you want to mock a specific class's method, and use patch when you want to replace a module-level name like requests.get or os.path.exists.
Spies: Mocking While Keeping Real Behavior
Sometimes you want to mock a function but still call the real version and track what happened. That's called a spy, and you can do it with wraps:
from unittest.mock import MagicMock
def calculate_tax(amount):
return amount * 0.1
mock_tax = MagicMock(wraps=calculate_tax)
result = mock_tax(100)
assert result == 10 # The real function was called
mock_tax.assert_called_once_with(100) # And we can still verify the callThe wraps parameter tells the mock to call the real function and return its result, while still recording the call for assertions. This is useful when you want to verify that a function was called a certain way, but you don't want to replace its implementation. Spies are the right tool when you care about the "how" (was this function called? how many times? with what arguments?) but not the "what" (you still want the real computation to run). A common use case is verifying that a caching layer calls through to the underlying function on a cache miss but not on a cache hit.
Common Mocking Patterns
Here's a practical example that ties everything together:
import pytest
from unittest.mock import patch, MagicMock
class PaymentProcessor:
def __init__(self, payment_gateway):
self.gateway = payment_gateway
def charge_card(self, card_number, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
result = self.gateway.charge(card_number, amount)
if not result['success']:
raise Exception("Payment failed")
return result['transaction_id']
@pytest.fixture
def mock_gateway():
"""Mock the payment gateway."""
gateway = MagicMock()
gateway.charge.return_value = {
'success': True,
'transaction_id': 'txn_12345'
}
return gateway
@pytest.mark.parametrize("amount,expected_success", [
(50, True),
(100, True),
(0, False),
(-10, False),
])
def test_charge_card(mock_gateway, amount, expected_success):
processor = PaymentProcessor(mock_gateway)
if not expected_success:
with pytest.raises(ValueError):
processor.charge_card("4111111111111111", amount)
else:
result = processor.charge_card("4111111111111111", amount)
assert result == 'txn_12345'
mock_gateway.charge.assert_called_once()
def test_charge_card_gateway_failure(mock_gateway):
mock_gateway.charge.return_value = {'success': False}
processor = PaymentProcessor(mock_gateway)
with pytest.raises(Exception, match="Payment failed"):
processor.charge_card("4111111111111111", 50)This example combines:
- Fixtures for test setup
- Parametrize for multiple input scenarios
- Mocking to replace the payment gateway
- Exception testing with
pytest.raises - Call assertions with
assert_called_once()
The result is a test suite that verifies four different payment scenarios plus a gateway failure scenario, all without touching a real payment processor. If the payment gateway vendor changes their API, you update the mock to reflect the new behavior, and your tests continue to verify that your PaymentProcessor class handles all cases correctly.
Testing External APIs with responses Library
For HTTP mocking with more realistic responses, the responses library is excellent:
import pytest
import responses
import requests
@responses.activate
def test_api_with_responses():
responses.add(
responses.GET,
'https://api.example.com/users/1',
json={'id': 1, 'name': 'Alice'},
status=200
)
response = requests.get('https://api.example.com/users/1')
assert response.json() == {'id': 1, 'name': 'Alice'}
@pytest.fixture
def mock_api():
"""Fixture that provides responses context."""
with responses.RequestsMock() as rsps:
yield rsps
def test_api_with_fixture(mock_api):
mock_api.add(
responses.GET,
'https://api.example.com/users',
json=[{'id': 1, 'name': 'Alice'}],
status=200
)
response = requests.get('https://api.example.com/users')
assert len(response.json()) == 1The @responses.activate decorator intercepts all HTTP requests made during the test and returns your mocked responses instead. The responses library has a key advantage over raw patch for HTTP testing: it works at the transport layer, which means it intercepts requests no matter how deeply nested in your call stack they are. You don't need to know the import path of requests in every module that uses it. You register the URL you want to mock, and any code that makes a GET request to that URL gets your response. The library also raises an error if your code makes an unregistered request, which is a useful safety net that prevents your tests from accidentally hitting real endpoints.
Common Testing Mistakes
Every developer learning these tools makes the same mistakes. Knowing them in advance will save you hours of debugging sessions.
The most frequent mistake is patching in the wrong place. When you use @patch('requests.get'), you're patching it in the requests module. But if the code under test does from requests import get, it has already bound the name get to the original function in its own namespace. You need to patch yourmodule.get, not requests.get. The rule is always patch where the name is used, not where it's defined. This single mistake accounts for a large percentage of "why isn't my mock working?" questions.
The second common mistake is forgetting to reset mock state between tests. If a MagicMock lives in a fixture with broad scope, its call history accumulates across tests. Assertions like assert_called_once() will fail for the second test because the mock was already called in the first test. The fix is either to use mock.reset_mock() between tests or to ensure your mocks are function-scoped so each test gets a fresh one. When in doubt, keep mocks at function scope.
A third mistake is over-mocking. When you mock the function you're actually trying to test, or when you mock so deeply that your test no longer resembles anything the real system would do, you're writing tests that can pass even when the real code is broken. Mock external dependencies, things outside the system boundary, but let the internal logic run for real. If you find yourself mocking more than two or three things in a single test, that's often a signal that the code under test has too many dependencies and would benefit from being broken into smaller, more focused pieces.
Finally, watch out for mocking __init__ incorrectly. When you mock a class, calling it returns mock_class.return_value, not a new instance of the original class. If your code does db = Database() and you've patched Database, then db is MockDatabase.return_value. Attributes you set on MockDatabase.return_value are what the code will see when it accesses db.something.
Key Takeaways
Fixtures let you organize test setup and teardown cleanly, conftest.py makes fixtures reusable across your test suite, and fixture scopes control how long fixtures live. Parametrize lets you run the same test with many different inputs without duplication. Mocking lets you test your code in isolation by replacing external dependencies with fake versions that you control. Use spies when you want to verify behavior without changing functionality. Combine these tools to write test suites that are both comprehensive and maintainable.
The power of pytest really shines when you master these three features. Your tests will be faster (no real API calls), more reliable (no flaky network requests), and easier to maintain (less repetition). The patterns we've covered, composable fixtures, parametrized test cases, and targeted mocking, are not just techniques for writing individual tests. They're an architectural approach that shapes how you think about code structure itself. Code that is easy to test is almost always code that is well-structured: loosely coupled, with clear boundaries, and explicit dependencies. Learning to test well is learning to write better code.
In the next article, we'll look at test coverage, how to measure whether your tests actually cover the code you care about, and integration testing for when you do need to test real components together. Understanding coverage will help you find the gaps in the test suite you're now equipped to write well.