Testing: pytest, fixtures, mock, patch, parametrize, coverage
Advanced testing patterns for production code
Interview Question
"How do you write effective unit tests in Python? Explain pytest fixtures, mocking, patching, and parametrize. How do you achieve high test coverage while maintaining test quality?"
Difficulty: Medium | Frequently asked at Google, Meta, Amazon
Theoretical Foundation
Why Test?
# Example: Untested code with bugs
def calculate_discount(price: float, discount_percent: float) -> float:
"""Calculate discounted price."""
# Bug 1: No validation
# Bug 2: Wrong calculation
return price - discount_percent # Should be price * (1 - discount_percent/100)
# Without tests, bugs go unnoticed
assert calculate_discount(100, 20) == 80 # Fails! Returns 80.0 but for wrong reason
assert calculate_discount(100, 0) == 100 # Passes
assert calculate_discount(100, 110) == -10 # Should validate!
βΉοΈ
Key Concept: Tests catch bugs early, document behavior, and enable safe refactoring.
pytest Basics
First Test
# test_calculator.py
import pytest
def add(a: float, b: float) -> float:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# Basic test functions
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_add_zero():
assert add(0, 0) == 0
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
# Run with: pytest test_calculator.py -v
Assertions
import pytest
def test_assertions_example():
# Equality
assert 1 + 1 == 2
# Identity
a = [1, 2, 3]
b = a
assert a is b
# Membership
assert 2 in [1, 2, 3]
# Boolean
assert True
assert not False
# Approximate (for floats)
assert 0.1 + 0.2 == pytest.approx(0.3)
# Exception
with pytest.raises(ZeroDivisionError):
1 / 0
# Custom message
assert 1 + 1 == 2, "Math is broken!"
pytest Fixtures
Basic Fixtures
import pytest
from typing import List
# Simple fixture
@pytest.fixture
def sample_data() -> List[int]:
"""Provide sample data for tests."""
return [1, 2, 3, 4, 5]
# Fixture with cleanup
@pytest.fixture
def temp_file(tmp_path):
"""Create temporary file for testing."""
file = tmp_path / "test_data.txt"
file.write_text("line1\nline2\nline3")
yield file
# Cleanup happens automatically
# Fixture with setup/teardown
@pytest.fixture
def database():
"""Database fixture with setup/teardown."""
# Setup
db = {"users": [], "connected": True}
print("Database connected")
yield db
# Teardown
db.clear()
print("Database disconnected")
# Usage in tests
def test_sample_data(sample_data):
assert len(sample_data) == 5
assert sum(sample_data) == 15
def test_temp_file(temp_file):
content = temp_file.read_text()
assert "line1" in content
def test_database(database):
database["users"].append({"id": 1, "name": "Alice"})
assert len(database["users"]) == 1
Fixture Scopes
import pytest
import time
# Function scope (default) - runs once per test function
@pytest.fixture(scope="function")
def function_fixture():
print("\nSetting up function fixture")
yield
print("\nTearing down function fixture")
# Class scope - runs once per test class
@pytest.fixture(scope="class")
def class_fixture():
print("\nSetting up class fixture")
yield
print("\nTearing down class fixture")
# Module scope - runs once per module
@pytest.fixture(scope="module")
def module_fixture():
print("\nSetting up module fixture")
yield
print("\nTearing down module fixture")
# Session scope - runs once per test session
@pytest.fixture(scope="session")
def session_fixture():
print("\nSetting up session fixture")
yield
print("\nTearing down session fixture")
# Usage
class TestWithFixtures:
def test_one(self, function_fixture, class_fixture):
pass
def test_two(self, function_fixture, class_fixture):
pass
Parametrized Fixtures
import pytest
# Parametrized fixture
@pytest.fixture(params=["sqlite", "mysql", "postgresql"])
def database(request):
"""Provide different database backends."""
if request.param == "sqlite":
db = {"type": "sqlite", "connected": True}
elif request.param == "mysql":
db = {"type": "mysql", "connected": True}
else:
db = {"type": "postgresql", "connected": True}
yield db
db.clear()
# Fixture with indirect parametrize
@pytest.fixture
def user(request):
"""Create user with parametrized role."""
role = request.param
return {"name": "Alice", "role": role}
# Usage - runs test 3 times (once for each database)
def test_database_connection(database):
assert database["connected"] is True
print(f"Testing {database['type']}")
# Indirect parametrize
@pytest.mark.parametrize("user", ["admin", "user", "guest"], indirect=True)
def test_user_permissions(user):
if user["role"] == "admin":
assert "delete" in get_permissions(user["role"])
elif user["role"] == "user":
assert "read" in get_permissions(user["role"])
def get_permissions(role):
permissions = {
"admin": ["read", "write", "delete"],
"user": ["read", "write"],
"guest": ["read"]
}
return permissions.get(role, [])
π‘
Interview Tip: Explain fixture scopes: function (default), class, module, session. Higher scopes are faster for expensive setup.
Mocking and Patching
unittest.mock
from unittest.mock import Mock, MagicMock, patch, call
import pytest
# Basic Mock
def test_basic_mock():
mock_obj = Mock()
mock_obj.method.return_value = 42
result = mock_obj.method()
assert result == 42
mock_obj.method.assert_called_once()
# Mock with side effects
def test_mock_side_effects():
mock_obj = Mock()
mock_obj.method.side_effect = [1, 2, 3, Exception("Error")]
assert mock_obj.method() == 1
assert mock_obj.method() == 2
assert mock_obj.method() == 3
with pytest.raises(Exception, match="Error"):
mock_obj.method()
# Mock calls tracking
def test_mock_calls():
mock_obj = Mock()
mock_obj.method(1, 2)
mock_obj.method(3, 4)
mock_obj.other("test")
# Check calls
assert mock_obj.method.call_count == 2
assert mock_obj.method.call_args_list == [call(1, 2), call(3, 4)]
assert mock_obj.other.called
Patching
from unittest.mock import patch, MagicMock
import requests
import pytest
# Module to test
def get_user_data(user_id: int) -> dict:
"""Fetch user data from API."""
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
# Test with patching
class TestGetUserData:
@patch('requests.get')
def test_get_user_success(self, mock_get):
# Configure mock
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
# Call function
result = get_user_data(1)
# Assertions
assert result == {"id": 1, "name": "Alice"}
mock_get.assert_called_once_with("https://api.example.com/users/1")
@patch('requests.get')
def test_get_user_not_found(self, mock_get):
# Configure mock to raise exception
mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
# Call function and expect exception
with pytest.raises(requests.exceptions.HTTPError):
get_user_data(999)
Mocking Classes
from unittest.mock import Mock, patch, MagicMock
import pytest
# Class to test
class UserService:
def __init__(self, db, cache):
self.db = db
self.cache = cache
def get_user(self, user_id: int) -> dict:
# Check cache first
cached = self.cache.get(f"user:{user_id}")
if cached:
return cached
# Fetch from database
user = self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
if user:
self.cache.set(f"user:{user_id}", user, ttl=300)
return user
# Test with mocked dependencies
class TestUserService:
@pytest.fixture
def mock_db(self):
return Mock()
@pytest.fixture
def mock_cache(self):
return Mock()
@pytest.fixture
def service(self, mock_db, mock_cache):
return UserService(mock_db, mock_cache)
def test_get_user_from_cache(self, service, mock_cache):
# Configure cache hit
mock_cache.get.return_value = {"id": 1, "name": "Alice"}
result = service.get_user(1)
assert result == {"id": 1, "name": "Alice"}
mock_cache.get.assert_called_once_with("user:1")
service.db.query.assert_not_called()
def test_get_user_from_db(self, service, mock_db, mock_cache):
# Configure cache miss
mock_cache.get.return_value = None
mock_db.query.return_value = {"id": 1, "name": "Alice"}
result = service.get_user(1)
assert result == {"id": 1, "name": "Alice"}
mock_db.query.assert_called_once()
mock_cache.set.assert_called_once_with("user:1", {"id": 1, "name": "Alice"}, ttl=300)
β οΈ
Common Mistake: Forgetting to patch the right location. Always patch where the object is used, not where it's defined.
Parametrize
Basic Parametrize
import pytest
def is_palindrome(s: str) -> bool:
"""Check if string is palindrome."""
s = s.lower().replace(" ", "")
return s == s[::-1]
# Single parameter
@pytest.mark.parametrize("input_str,expected", [
("racecar", True),
("hello", False),
("A man a plan a canal Panama", True),
("", True),
("ab", False),
])
def test_is_palindrome(input_str, expected):
assert is_palindrome(input_str) == expected
# Multiple parameters
def add(a: int, b: int) -> int:
return a + b
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expected
# Parametrize with IDs
@pytest.mark.parametrize("input_val", [
pytest.param(1, id="positive"),
pytest.param(-1, id="negative"),
pytest.param(0, id="zero"),
])
def test_abs(input_val):
assert abs(input_val) >= 0
Advanced Parametrize
import pytest
from typing import List, Tuple
# Matrix testing
@pytest.mark.parametrize("matrix,expected", [
([[1, 2], [3, 4]], 10), # Sum
([[0, 0], [0, 0]], 0),
([[1, 1], [1, 1]], 4),
])
def test_matrix_sum(matrix: List[List[int]], expected: int):
assert sum(sum(row) for row in matrix) == expected
# Cross product parametrize
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_cross_product(x, y):
result = x * y
assert result in [10, 20, 30, 40, 60]
# Parametrize with fixtures
@pytest.fixture
def database(request):
return request.param
@pytest.mark.parametrize("database", ["sqlite", "mysql"], indirect=True)
def test_database_operation(database):
assert database["connected"] is True
Parametrize Classes
import pytest
class TestMathOperations:
"""Test multiple math operations."""
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(self, a, b, expected):
assert a + b == expected
@pytest.mark.parametrize("a,b,expected", [
(10, 2, 5),
(0, 1, 0),
(-4, 2, -2),
])
def test_divide(self, a, b, expected):
if b == 0:
pytest.skip("Cannot divide by zero")
assert a / b == expected
βΉοΈ
Performance Tip: Parametrize runs each case as a separate test, making failures easier to identify.
Coverage Analysis
Running Coverage
# Run pytest with coverage
pytest --cov=src --cov-report=html
# Coverage configuration (.coveragerc or pyproject.toml)
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if __name__ == .__main__.",
]
show_missing = true
fail_under = 80
Coverage in Tests
import pytest
from coverage import Coverage
def test_with_coverage():
"""Test that measures coverage."""
# Start coverage
cov = Coverage()
cov.start()
# Code to test
result = complex_function()
# Stop coverage
cov.stop()
cov.save()
# Check coverage
coverage_percent = cov.report()
assert coverage_percent >= 80, f"Coverage {coverage_percent}% is below 80%"
Coverage Configuration
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --cov=src --cov-report=html --cov-report=term"
[tool.coverage.run]
source = ["src"]
branch = true
parallel = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if __name__ == .__main__.",
"@abstractmethod",
]
show_missing = true
precision = 2
fail_under = 85
[tool.coverage.html]
directory = "htmlcov"
Advanced Testing Patterns
Test Doubles
from abc import ABC, abstractmethod
from typing import List
import pytest
# Abstract base
class UserRepository(ABC):
@abstractmethod
def get_user(self, user_id: int) -> dict:
pass
@abstractmethod
def save_user(self, user: dict) -> bool:
pass
# Real implementation
class DatabaseUserRepository(UserRepository):
def __init__(self, db_connection):
self.db = db_connection
def get_user(self, user_id: int) -> dict:
return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")
def save_user(self, user: dict) -> bool:
return self.db.execute("INSERT INTO users ...", user)
# Test double (Fake)
class FakeUserRepository(UserRepository):
def __init__(self):
self.users = {}
self.id_counter = 1
def get_user(self, user_id: int) -> dict:
return self.users.get(user_id)
def save_user(self, user: dict) -> bool:
user["id"] = self.id_counter
self.users[self.id_counter] = user
self.id_counter += 1
return True
# Test double (Spy)
class SpyUserRepository(UserRepository):
def __init__(self):
self.get_calls = []
self.save_calls = []
def get_user(self, user_id: int) -> dict:
self.get_calls.append(user_id)
return {"id": user_id, "name": "Test"}
def save_user(self, user: dict) -> bool:
self.save_calls.append(user)
return True
# Tests using test doubles
class TestUserService:
def test_get_user_with_fake(self):
repo = FakeUserRepository()
repo.save_user({"name": "Alice"})
user = repo.get_user(1)
assert user["name"] == "Alice"
def test_save_user_with_spy(self):
repo = SpyUserRepository()
repo.save_user({"name": "Bob"})
assert len(repo.save_calls) == 1
assert repo.save_calls[0]["name"] == "Bob"
Mocking Async Code
import pytest
from unittest.mock import AsyncMock, patch
import asyncio
# Async function to test
async def fetch_user_data(user_id: int) -> dict:
"""Fetch user data asynchronously."""
await asyncio.sleep(0.1) # Simulate async operation
return {"id": user_id, "name": "Alice"}
class AsyncUserService:
def __init__(self, db):
self.db = db
async def get_user(self, user_id: int) -> dict:
return await self.db.fetch_one(f"SELECT * FROM users WHERE id = {user_id}")
# Tests
class TestAsyncCode:
@pytest.mark.asyncio
async def test_fetch_user_data(self):
result = await fetch_user_data(1)
assert result == {"id": 1, "name": "Alice"}
@pytest.mark.asyncio
async def test_async_service_with_mock(self):
mock_db = AsyncMock()
mock_db.fetch_one.return_value = {"id": 1, "name": "Alice"}
service = AsyncUserService(mock_db)
user = await service.get_user(1)
assert user == {"id": 1, "name": "Alice"}
mock_db.fetch_one.assert_called_once()
Property-Based Testing
from hypothesis import given, strategies as st
import pytest
def reverse_string(s: str) -> str:
"""Reverse a string."""
return s[::-1]
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
# Property-based tests
class TestProperties:
@given(st.text())
def test_reverse_idempotent(self, s: str):
"""Reversing twice gives original string."""
assert reverse_string(reverse_string(s)) == s
@given(st.text())
def test_reverse_length(self, s: str):
"""Reversed string has same length."""
assert len(reverse_string(s)) == len(s)
@given(st.integers(), st.integers())
def test_add_commutative(self, a: int, b: int):
"""Addition is commutative."""
assert add(a, b) == add(b, a)
@given(st.integers())
def test_add_identity(self, a: int):
"""Zero is additive identity."""
assert add(a, 0) == a
π‘
Interview Tip: Property-based testing finds edge cases automatically. Use Hypothesis library for powerful testing.
Test Organization
Directory Structure
project/
βββ src/
β βββ __init__.py
β βββ calculator.py
β βββ user_service.py
βββ tests/
β βββ __init__.py
β βββ conftest.py # Shared fixtures
β βββ test_calculator.py
β βββ test_user_service.py
β βββ integration/
β βββ conftest.py
β βββ test_database.py
βββ pyproject.toml
βββ pytest.ini
conftest.py
# tests/conftest.py
import pytest
from typing import Generator
@pytest.fixture(scope="session")
def db_connection() -> Generator:
"""Session-wide database connection."""
# Setup
connection = create_db_connection()
yield connection
# Teardown
connection.close()
@pytest.fixture(autouse=True)
def reset_state():
"""Reset state between tests."""
yield
# Cleanup
clear_cache()
@pytest.fixture
def sample_user() -> dict:
"""Provide a sample user."""
return {
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"role": "admin"
}
Test Markers
import pytest
# Mark tests
@pytest.mark.slow
def test_expensive_operation():
pass
@pytest.mark.integration
def test_database_operation():
pass
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
pass
@pytest.mark.skipif(
sys.platform == "win32",
reason="Unix only test"
)
def test_unix_only():
pass
# Run specific markers
# pytest -m "not slow"
# pytest -m "integration"
Interview Tips
Common Follow-up Questions
-
"When should you mock vs use real objects?"
- Mock external dependencies (APIs, databases)
- Use real objects for business logic
- Mock slow or unreliable systems
-
"How do you test private methods?"
- Test through public interface
- Or use name mangling:
_ClassName__method() - Prefer testing behavior over implementation
-
"What's the difference between mock and patch?"
mock: Create mock objectspatch: Replace objects in a namespace- Often used together
Code Review Tips
# BAD: Testing implementation details
def test_internal_calculation():
service = UserService()
# Accessing private attribute
assert service._internal_cache == {}
# GOOD: Testing behavior
def test_user_caching_behavior():
service = UserService()
user = service.get_user(1)
# Verify behavior, not implementation
assert user is not None
# BAD: Tests that depend on order
def test_create_user():
create_user({"name": "Alice"})
def test_get_user():
# Fails if test_create_user hasn't run
user = get_user(1)
assert user["name"] == "Alice"
# GOOD: Independent tests
@pytest.fixture
def user():
return create_user({"name": "Alice"})
def test_get_user(user):
retrieved = get_user(user["id"])
assert retrieved["name"] == "Alice"
β οΈ
Common Mistake: Writing tests that depend on execution order. Each test should be independent.
Summary
| Tool/Concept | Purpose | When to Use |
|---|---|---|
| pytest | Test runner | Always |
| Fixtures | Test setup/teardown | Shared resources |
| Mock/Patch | Isolate dependencies | External systems |
| Parametrize | Multiple test cases | Similar tests |
| Coverage | Measure test completeness | CI/CD pipelines |
| Hypothesis | Property-based testing | Edge cases |
Best Practices
- Write tests first (TDD/BDD)
- Keep tests independent
- Use descriptive names
- Test edge cases
- Mock external dependencies
- Aim for 80%+ coverage
- Run tests in CI/CD
βΉοΈ
Key Takeaway: Good tests catch bugs early, document behavior, and enable confident refactoring.
Practice Problems
- Test Calculator: Write tests for a calculator class with error handling
- Mock API: Test an API client with mocked HTTP responses
- Parametrize Login: Test login with various valid/invalid credentials
- Integration Test: Write integration tests with database fixtures
- Coverage Analysis: Achieve 90%+ coverage on a module
Further Reading
- pytest Docs: https://docs.pytest.org/
- unittest.mock: https://docs.python.org/3/library/unittest.mock.html
- Hypothesis: https://hypothesis.readthedocs.io/
- Books: "Python Testing with pytest" by Brian Okken
Remember: Testing is not just about finding bugsβit's about building confidence in your code.