Lesson 34 • Advanced
Testing with pytest: Fixtures, Parametrisation & Mocks
Pytest is the standard testing framework for modern Python. Master professional testing strategies used by real engineering teams.
What You'll Master
This lesson takes you from basic testing → professional test suite architecture.
✔ Why pytest vs unittest
✔ Fixtures for reusable setup/teardown
✔ Fixture scopes and dependencies
✔ Parametrised tests for multiple inputs
✔ Mocking external systems (APIs, DBs, time)
✔ Integration testing strategies
✔ Professional test suite structure
Part 1: Core pytest — Fixtures, Parametrisation & Basic Mocking
🔥 1. Why pytest?
pytest gives you:
| Feature | pytest | unittest |
|---|---|---|
| Assertion syntax | assert x == y | self.assertEqual(x, y) |
| Boilerplate | None — just functions | Classes required |
| Fixtures | Powerful, injectable | setUp/tearDown only |
| Parametrisation | Built-in decorator | Manual loops |
| Plugin ecosystem | Huge (1000+ plugins) | Limited |
Basic Test Example
# test_math_utils.py
def add(a, b):
return a + b
def test_add_two_numbers():
assert add(2, 3) == 5
print("✓ test_add_two_numbers passed!")
# Run the test
test_add_two_numbers()Run: pytest
🧪 2. Basic Test Structure
Naming rules (by convention):
- Files: test_*.py
- Functions: test_*
- Classes: class TestSomething: (no __init__)
Test Structure
# test_strings.py
def to_upper(text: str) -> str:
return text.upper()
def test_to_upper_basic():
assert to_upper("hello") == "HELLO"
print("✓ test_to_upper_basic passed!")
def test_to_upper_already_upper():
assert to_upper("WORLD") == "WORLD"
print("✓ test_to_upper_already_upper passed!")
# Run tests
test_to_upper_basic()
test_to_upper_already_upper()⚙️ 3. What Is a Fixture?
A fixture is reusable setup logic that you can "inject" into tests via function arguments.
Fixture Example
# Simulating pytest fixtures
def sample_user():
"""Fixture that provides a sample user"""
return {"id": 1, "name": "Brayan", "role": "admin"}
def test_user_has_name():
user = sample_user()
assert user["name"] == "Brayan"
print("✓ test_user_has_name passed!")
def test_user_is_admin():
user = sample_user()
assert user["role"] == "admin"
print("✓ test_user_is_admin passed!")
test_user_has_name()
test_user_is_admin()🧱 4. Fixture Scopes
- • function = Clean after every guest (fresh for each test)
- • class = Clean after a group of guests checks out
- • module = Clean once a day (per file)
- • session = Deep clean once a week (per test run)
By default, fixtures are function-scoped. You can control lifespan with scope:
| Scope | Runs | Use Case |
|---|---|---|
| function | Once per test | Safest, most isolated (default) |
| class | Once per class | Related tests share instance |
| module | Once per file | Expensive setup (DB connections) |
| session | Once per test run | Global resources (app config) |
Fixture Scopes
print("Fixture Scopes in pytest:")
print("=" * 40)
print("""
@pytest.fixture(scope="function") # default - fresh per test
def db_connection():
...
@pytest.fixture(scope="class") # once per test class
def api_client():
...
@pytest.fixture(scope="module") # once per file
def config():
...
@pytest.fixture(scope="session") # once per entire test run
def app_env():
...
""")
print("When to use which?")
print("-" * 30)
print("function → safest; isolated; most common")
prin
...🔁 5. Setup & Teardown with yield
Yield Fixtures
import tempfile
import os
def temp_file_fixture():
"""Fixture with setup and teardown"""
# Setup
fd, path = tempfile.mkstemp(suffix=".txt")
os.write(fd, b"initial content")
os.close(fd)
print(f"Setup: Created temp file {path}")
# Return value to test
yield path
# Teardown
os.unlink(path)
print(f"Teardown: Deleted temp file")
def test_temp_file():
# Use generator to simulate fixture
gen = temp_file_fixture()
file_path = next(gen)
...🧪 6. Parametrised Tests
Instead of writing multiple duplicate tests, use parametrisation:
Parametrised Tests
# Simulating @pytest.mark.parametrize
def add(a, b):
return a + b
test_cases = [
(1, 2, 3),
(10, 5, 15),
(-1, 1, 0),
(0, 0, 0),
]
def test_add_parametrized():
for a, b, expected in test_cases:
result = add(a, b)
assert result == expected, f"Failed: add({a}, {b}) = {result}, expected {expected}"
print(f"✓ add({a}, {b}) = {expected}")
test_add_parametrized()
print("\nAll parametrised tests passed!")🧪 7. Basic Mocking
Basic Mocking
from unittest.mock import Mock
def fetch_user(api):
"""Function that uses an API"""
return api.get("/user")
def test_fetch_user():
# Create a mock API
fake_api = Mock()
fake_api.get.return_value = {"id": 1, "name": "Boopie"}
# Call function with mock
result = fetch_user(fake_api)
# Assertions
assert result["name"] == "Boopie"
fake_api.get.assert_called_once_with("/user")
print("✓ test_fetch_user passed!")
test_fetch_user()Part 2: Advanced Fixtures & Mocking
🔧 Factory Fixtures
Factory Fixtures
def make_user(name, role="viewer"):
"""Factory function for creating users"""
return {"name": name, "role": role}
def test_user_factory():
u1 = make_user("Alice")
u2 = make_user("Bob", role="admin")
assert u1["role"] == "viewer"
assert u2["role"] == "admin"
print(f"✓ Created users: {u1}, {u2}")
test_user_factory()🧠 Combining Fixtures + Parametrisation
Combined Example
# Simulating combined fixtures and parametrisation
numbers = [1, 2, 3]
factors = [10, 20]
def test_multiplied():
results = []
for number in numbers:
for factor in factors:
result = number * factor
results.append((number, factor, result))
print(f"✓ {number} × {factor} = {result}")
print(f"\nTotal combinations: {len(results)}")
test_multiplied()🔐 Monkeypatching
Monkeypatching
# Original function
def get_price():
return 999 # Imagine this calls an API
# Test with monkeypatch simulation
original_get_price = get_price
def test_price():
global get_price
# Monkeypatch
get_price = lambda: 10
assert get_price() == 10
print("✓ Monkeypatched get_price() returns 10")
# Restore
get_price = original_get_price
test_price()
print(f"Original get_price(): {get_price()}")🛰 Mocking External APIs
API Mocking
from unittest.mock import Mock
def fetch_user_data(client):
response = client.get("https://api.example.com/user")
return response.json()
def test_fetch_user_with_mock():
# Create mock client
mock_client = Mock()
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Boopie"}
mock_client.get.return_value = mock_response
# Test
result = fetch_user_data(mock_client)
assert result["name"] == "Boopie"
mock_client.get.assert_
...🧪 Spy Objects
Spy Objects
from unittest.mock import Mock
def test_call_tracking():
# Create a mock that tracks calls
func = Mock()
# Make some calls
func("hello")
func("world", count=5)
# Verify calls
func.assert_called()
assert func.call_count == 2
# Check specific calls
func.assert_any_call("hello")
func.assert_any_call("world", count=5)
print(f"✓ Function called {func.call_count} times")
print(f"✓ Call history: {func.call_args_list}")
test_call_
...Part 3: Professional Test Suite Architecture
🧪 Test Suite Structure
Professional Structure
print("""
Professional Test Suite Structure:
===================================
project/
app/
core/
api/
models/
tests/
unit/ → pure logic, fast tests
integration/ → DB, API, filesystem
e2e/ → browser/full system
fixtures/ → large reusable mocks
conftest.py → global fixtures
Key principles:
---------------
✓ Separate unit, integration, and e2e tests
✓ Use conftest.py for shared fixtures
✓
...🧱 Integration Testing
Integration Testing
import sqlite3
def test_database_integration():
# Setup: Create in-memory database
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
# Create table
cur.execute("CREATE TABLE users (id INT, name TEXT)")
# Insert data
cur.execute("INSERT INTO users VALUES (1, 'Alice')")
conn.commit()
# Query and verify
result = cur.execute("SELECT name FROM users WHERE id=1").fetchone()
assert result[0] == "Alice"
# Cleanup
conn.close()
...🧱 Mocking Time and Randomness
Mocking Time
import time
import random
# Save originals
original_time = time.time
original_randint = random.randint
def test_mocked_time():
# Mock time
time.time = lambda: 1000.0
assert time.time() == 1000.0
print(f"✓ Mocked time: {time.time()}")
# Restore
time.time = original_time
def test_mocked_random():
# Mock random
random.randint = lambda a, b: 42
assert random.randint(1, 100) == 42
print(f"✓ Mocked random: {random.randint(1, 100)}")
# Restore
...🎓 Summary
You've learned professional pytest strategies:
✅ Plain assert-based tests
✅ Fixtures for reusable setup/teardown
✅ Fixture scopes and dependencies
✅ Parametrised tests for multiple inputs
✅ Mocking external systems
✅ Integration testing patterns
✅ Professional test suite structure
📋 Quick Reference — pytest
| Syntax | What it does |
|---|---|
| def test_fn(): | Define a test (must start with test_) |
| @pytest.fixture | Reusable setup/teardown function |
| @pytest.mark.parametrize | Run test with multiple inputs |
| pytest.raises(ValueError) | Assert an exception is raised |
| mocker.patch('module.fn') | Mock a function with pytest-mock |
🎉 Great work! You've completed this lesson.
You can now write fixtures, parametrised tests, and mocks — the full professional pytest toolkit used at tech companies worldwide.
Up next: CLI Tools — build polished command-line applications with argparse and Typer.
Sign up for free to track which lessons you've completed and get learning reminders.