Giao diện
Fixtures & Mocking Trung cấp
Fixtures = Dependency Injection cho Tests = Setup/Teardown thông minh
Learning Outcomes
Sau khi hoàn thành trang này, bạn sẽ:
- ✅ Hiểu fixture scopes (function, class, module, session)
- ✅ Tổ chức fixtures với conftest.py
- ✅ Viết fixture factories cho flexible test data
- ✅ Sử dụng pytest-mock và unittest.mock hiệu quả
- ✅ Áp dụng dependency injection patterns trong testing
Fixtures là gì?
Fixtures là cách pytest cung cấp test dependencies. Thay vì setUp/tearDown của unittest, fixtures sử dụng dependency injection:
python
import pytest
# ❌ unittest style - implicit setup
class TestUser(unittest.TestCase):
def setUp(self):
self.user = User("Alice", "alice@example.com")
def tearDown(self):
self.user.cleanup()
def test_name(self):
self.assertEqual(self.user.name, "Alice")
# ✅ pytest style - explicit dependency injection
@pytest.fixture
def user():
"""Create a test user."""
u = User("Alice", "alice@example.com")
yield u # Test runs here
u.cleanup() # Teardown
def test_name(user): # Fixture injected as parameter
assert user.name == "Alice"Fixture Flow
Fixture Scopes
Scope quyết định fixture được tạo và destroy khi nào:
| Scope | Lifetime | Use Case |
|---|---|---|
function | Mỗi test function | Default, isolated tests |
class | Mỗi test class | Shared state trong class |
module | Mỗi test file | Expensive setup per file |
session | Toàn bộ test run | Database connections |
Function Scope (Default)
python
import pytest
@pytest.fixture # scope="function" là default
def counter():
"""Fresh counter cho mỗi test."""
print("\n[SETUP] Creating counter")
c = {"value": 0}
yield c
print("[TEARDOWN] Destroying counter")
def test_increment(counter):
counter["value"] += 1
assert counter["value"] == 1
def test_increment_again(counter):
counter["value"] += 1
assert counter["value"] == 1 # Fresh counter!
# Output:
# [SETUP] Creating counter
# test_increment PASSED
# [TEARDOWN] Destroying counter
# [SETUP] Creating counter
# test_increment_again PASSED
# [TEARDOWN] Destroying counterClass Scope
python
import pytest
@pytest.fixture(scope="class")
def database():
"""Database shared within test class."""
print("\n[SETUP] Connecting to database")
db = Database.connect("test_db")
yield db
print("[TEARDOWN] Disconnecting")
db.disconnect()
class TestUserOperations:
def test_create_user(self, database):
user = database.create_user("Alice")
assert user.id is not None
def test_get_user(self, database):
# Same database instance as test_create_user
user = database.get_user("Alice")
assert user.name == "Alice"
# Output:
# [SETUP] Connecting to database
# test_create_user PASSED
# test_get_user PASSED
# [TEARDOWN] DisconnectingModule Scope
python
# test_api.py
import pytest
@pytest.fixture(scope="module")
def api_client():
"""API client shared across all tests in this file."""
print("\n[SETUP] Creating API client")
client = APIClient(base_url="http://test-api.local")
client.authenticate()
yield client
print("[TEARDOWN] Closing API client")
client.close()
def test_get_users(api_client):
users = api_client.get("/users")
assert len(users) > 0
def test_get_products(api_client):
# Same api_client instance
products = api_client.get("/products")
assert len(products) > 0
)Session Scope
python
# conftest.py
import pytest
@pytest.fixture(scope="session")
def docker_compose():
"""Start Docker containers once for entire test session."""
print("\n[SETUP] Starting Docker containers")
import subprocess
subprocess.run(["docker-compose", "up", "-d"], check=True)
# Wait for services to be ready
import time
time.sleep(5)
yield
print("[TEARDOWN] Stopping Docker containers")
subprocess.run(["docker-compose", "down"], check=True)
@pytest.fixture(scope="session")
def database_url(docker_compose):
"""Database URL available after Docker starts."""
return "postgresql://test:test@localhost:5432/testdb"Scope Hierarchy
⚠️ PRODUCTION PITFALL
Higher scope = Shared state = Potential test pollution!
python
# ❌ BAD: Session-scoped mutable fixture
@pytest.fixture(scope="session")
def shared_list():
return [] # All tests share this list!
def test_a(shared_list):
shared_list.append(1)
assert len(shared_list) == 1
def test_b(shared_list):
# Depends on test_a running first!
assert len(shared_list) == 1 # Might be 0 or 2!conftest.py Organization
conftest.py là file đặc biệt để share fixtures across tests:
Directory Structure
tests/
├── conftest.py # Root fixtures (available everywhere)
├── unit/
│ ├── conftest.py # Unit test fixtures
│ ├── test_calculator.py
│ └── test_validators.py
├── integration/
│ ├── conftest.py # Integration test fixtures
│ └── test_api.py
└── e2e/
├── conftest.py # E2E test fixtures
└── test_workflows.pyRoot conftest.py
python
# tests/conftest.py
import pytest
from pathlib import Path
# ============================================
# COMMON FIXTURES (available to all tests)
# ============================================
@pytest.fixture
def sample_data_dir():
"""Path to sample data directory."""
return Path(__file__).parent / "data"
@pytest.fixture
def temp_config(tmp_path):
"""Create temporary config file."""
config_file = tmp_path / "config.json"
config_file.write_text('{"debug": true, "log_level": "INFO"}')
return config_file
# ============================================
# MARKERS REGISTRATION
# ============================================
def pytest_configure(config):
config.addinivalue_line("markers", "slow: marks tests as slow")
config.addinivalue_line("markers", "integration: integration tests")
config.addinivalue_line("markers", "unit: unit tests")
# ============================================
# HOOKS
# ============================================
def pytest_collection_modifyitems(config, items):
"""Auto-mark tests based on directory."""
for item in items:
if "integration" in str(item.fspath):
item.add_marker(pytest.mark.integration)
elif "unit" in str(item.fspath):
item.add_marker(pytest.mark.unit)Unit Test conftest.py
python
# tests/unit/conftest.py
import pytest
from myapp.models import User, Product
@pytest.fixture
def sample_user():
"""Sample user for unit tests."""
return User(
id=1,
name="Test User",
email="test@example.com",
is_active=True
)
@pytest.fixture
def sample_product():
"""Sample product for unit tests."""
return Product(
id=1,
name="Test Product",
price=99.99,
stock=100
)
@pytest.fixture
def user_factory():
"""Factory to create users with custom attributes."""
def _create_user(**kwargs):
defaults = {
"id": 1,
"name": "Test User",
"email": "test@example.com",
"is_active": True
}
defaults.update(kwargs)
return User(**defaults)
return _create_userIntegration Test conftest.py
tests/integration/conftest.py
import pytest import httpx
@pytest.fixture(scope="module") def api_client(): """HTTP client for API tests.""" with httpx.Client(base_url="http://localhost:8000") as client: yield client
@pytest.fixture(scope="module") def authenticated_client(api_client): """Authenticated API client.""" response = api_client.post("/auth/login", json={ "username": "testuser", "password": "testpass" }) token = response.json()["access_token"] api_client.headers["Authorization"] = f"Bearer {token}" yield api_client # Logout on teardown api_client.post("/auth/logout") ) api_client.post("/auth/logout")
---
## Fixture Factories
Factories cho phép tạo test data với custom attributes:
### Basic Factory
```python
import pytest
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Order:
id: int
customer_id: int
total: float
status: str
created_at: datetime
@pytest.fixture
def order_factory():
"""Factory to create orders with custom attributes."""
_counter = 0
def _create_order(**kwargs):
nonlocal _counter
_counter += 1
defaults = {
"id": _counter,
"customer_id": 1,
"total": 100.0,
"status": "pending",
"created_at": datetime.now()
}
defaults.update(kwargs)
return Order(**defaults)
return _create_order
def test_order_total(order_factory):
order = order_factory(total=250.0)
assert order.total == 250.0
def test_order_status(order_factory):
pending = order_factory(status="pending")
completed = order_factory(status="completed")
assert pending.status != completed.statusFactory with Database
python
import pytest
@pytest.fixture
def user_factory(database):
"""Factory that creates users in database."""
created_users = []
def _create_user(**kwargs):
defaults = {
"name": "Test User",
"email": f"user{len(created_users)}@test.com",
"is_active": True
}
defaults.update(kwargs)
user = database.create_user(**defaults)
created_users.append(user)
return user
yield _create_user
# Cleanup: delete all created users
for user in created_users:
database.delete_user(user.id)
def test_multiple_users(user_factory):
alice = user_factory(name="Alice")
bob = user_factory(name="Bob")
assert alice.email != bob.email
assert alice.id != bob.idParametrized Factory
python
import pytest
@pytest.fixture(params=["pending", "processing", "completed", "cancelled"])
def order_with_status(request, order_factory):
"""Create order with each status."""
return order_factory(status=request.param)
def test_order_status_display(order_with_status):
# Runs 4 times with different statuses
assert order_with_status.status in ["pending", "processing", "completed", "cancelled"]Mocking với unittest.mock
Basic Mocking
python
from unittest.mock import Mock, MagicMock, patch
# Create a mock object
mock_api = Mock()
mock_api.get_user.return_value = {"id": 1, "name": "Alice"}
result = mock_api.get_user(1)
assert result == {"id": 1, "name": "Alice"}
mock_api.get_user.assert_called_once_with(1)@patch Decorator
from unittest.mock import patch import requests
def fetch_user(user_id: int) -> dict: response = requests.get(f"https://api.example.com/users/{user_id}") return response.json()
Patch requests.get
@patch("requests.get") def test_fetch_user(mock_get): # Configure mock mock_get.return_value.json.return_value =
# Call function
result = fetch_user(1)
# Assertions
assert result == {"id": 1, "name": "Alice"}
mock_get.assert_called_once_with("https://api.example.com/users/1")
Patch as context manager
def test_fetch_user_context(): with patch("requests.get") as mock_get: mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"} result = fetch_user(1) assert result["name"] == "Alice" )) result = fetch_user(1) assert result["name"] == "Alice"
### Patching the Right Location
```python
# myapp/services.py
from myapp.clients import APIClient
def get_user_data(user_id: int) -> dict:
client = APIClient()
return client.fetch_user(user_id)
# ❌ WRONG: Patch where defined
@patch("myapp.clients.APIClient")
def test_wrong(mock_client):
# This patches the class in clients.py
# But services.py already imported it!
pass
# ✅ CORRECT: Patch where used
@patch("myapp.services.APIClient")
def test_correct(mock_client_class):
mock_instance = mock_client_class.return_value
mock_instance.fetch_user.return_value = {"id": 1, "name": "Alice"}
result = get_user_data(1)
assert result["name"] == "Alice"Mock Side Effects
python
from unittest.mock import Mock, patch
# Side effect: raise exception
mock_api = Mock()
mock_api.get_user.side_effect = ConnectionError("Network error")
try:
mock_api.get_user(1)
except ConnectionError as e:
assert str(e) == "Network error"
# Side effect: return different values
mock_api.get_user.side_effect = [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
ConnectionError("Rate limited")
]
assert mock_api.get_user(1)["name"] == "Alice"
assert mock_api.get_user(2)["name"] == "Bob"
# Third call raises exception
# Side effect: custom function
def custom_get_user(user_id):
if user_id == 0:
raise ValueError("Invalid user ID")
return {"id": user_id, "name": f"User {user_id}"}
mock_api.get_user.side_effect = custom_get_user
assert mock_api.get_user(5)["name"] == "User 5"MagicMock for Dunder Methods
python
from unittest.mock import MagicMock
# MagicMock supports magic methods
mock_list = MagicMock()
mock_list.__len__.return_value = 5
mock_list.__getitem__.return_value = "item"
assert len(mock_list) == 5
assert mock_list[0] == "item"
# Context manager
mock_file = MagicMock()
mock_file.__enter__.return_value = mock_file
mock_file.read.return_value = "file content"
with mock_file as f:
content = f.read()
assert content == "file content"pytest-mock Plugin
pytest-mock cung cấp mocker fixture - cleaner API cho mocking:
bash
pip install pytest-mockBasic Usage
python
def test_with_mocker(mocker):
# Patch
mock_get = mocker.patch("requests.get")
mock_get.return_value.json.return_value = {"id": 1}
# Call
result = fetch_user(1)
# Assert
assert result["id"] == 1
mock_get.assert_called_once()
def test_spy(mocker):
"""Spy: track calls without replacing implementation."""
import json
spy = mocker.spy(json, "dumps")
result = json.dumps({"key": "value"})
assert result == '{"key": "value"}' # Real implementation
spy.assert_called_once_with({"key": "value"}) # Call tracked
def test_stub(mocker):
"""Stub: simple return value."""
mocker.patch("os.getcwd", return_value="/fake/path")
import os
assert os.getcwd() == "/fake/path"Mocker vs unittest.mock
python
# unittest.mock style
from unittest.mock import patch
@patch("myapp.services.APIClient")
def test_with_patch(mock_client):
mock_client.return_value.fetch.return_value = {"data": "value"}
# ...
# pytest-mock style (cleaner)
def test_with_mocker(mocker):
mock_client = mocker.patch("myapp.services.APIClient")
mock_client.return_value.fetch.return_value = {"data": "value"}
# ...Mocker Fixtures
python
# conftest.py
import pytest
@pytest.fixture
def mock_database(mocker):
"""Pre-configured database mock."""
mock_db = mocker.MagicMock()
mock_db.query.return_value = []
mock_db.execute.return_value = True
return mock_db
@pytest.fixture
def mock_api_client(mocker):
"""Pre-configured API client mock."""
mock_client = mocker.patch("myapp.services.APIClient")
mock_instance = mock_client.return_value
mock_instance.get.return_value = {"status": "ok"}
mock_instance.post.return_value = {"id": 1}
return mock_instance
# test_services.py
def test_service_with_mocks(mock_database, mock_api_client):
service = MyService(db=mock_database, api=mock_api_client)
result = service.process()
mock_database.query.assert_called()
mock_api_client.post.assert_called_once()Dependency Injection Patterns
Constructor Injection
python
# myapp/services.py
class UserService:
def __init__(self, database, email_client):
self.database = database
self.email_client = email_client
def create_user(self, name: str, email: str) -> dict:
user = self.database.create_user(name=name, email=email)
self.email_client.send_welcome(email)
return user
# test_services.py
import pytest
@pytest.fixture
def mock_database(mocker):
db = mocker.MagicMock()
db.create_user.return_value = {"id": 1, "name": "Alice"}
return db
@pytest.fixture
def mock_email_client(mocker):
return mocker.MagicMock()
@pytest.fixture
def user_service(mock_database, mock_email_client):
return UserService(
database=mock_database,
email_client=mock_email_client
)
def test_create_user(user_service, mock_database, mock_email_client):
result = user_service.create_user("Alice", "alice@example.com")
assert result["name"] == "Alice"
mock_database.create_user.assert_called_once_with(
name="Alice", email="alice@example.com"
)
mock_email_client.send_welcome.assert_called_once_with("alice@example.com")Protocol-Based Injection
python
from typing import Protocol
class DatabaseProtocol(Protocol):
def create_user(self, name: str, email: str) -> dict: ...
def get_user(self, user_id: int) -> dict: ...
class EmailClientProtocol(Protocol):
def send_welcome(self, email: str) -> None: ...
class UserService:
def __init__(
self,
database: DatabaseProtocol,
email_client: EmailClientProtocol
):
self.database = database
self.email_client = email_client
# Test với mock implementations
class FakeDatabase:
def __init__(self):
self.users = {}
self._counter = 0
def create_user(self, name: str, email: str) -> dict:
self._counter += 1
user = {"id": self._counter, "name": name, "email": email}
self.users[self._counter] = user
return user
def get_user(self, user_id: int) -> dict:
return self.users.get(user_id)
class FakeEmailClient:
def __init__(self):
self.sent_emails = []
def send_welcome(self, email: str) -> None:
self.sent_emails.append(email)
@pytest.fixture
def fake_database():
return FakeDatabase()
@pytest.fixture
def fake_email_client():
return FakeEmailClient()
@pytest.fixture
def user_service(fake_database, fake_email_client):
return UserService(fake_database, fake_email_client)
def test_create_user_with_fakes(user_service, fake_database, fake_email_client):
result = user_service.create_user("Alice", "alice@example.com")
assert result["id"] == 1
assert fake_database.users[1]["name"] == "Alice"
assert "alice@example.com" in fake_email_client.sent_emailsProduction Pitfalls ⚠️
1. Over-Mocking
python
# ❌ BAD: Mock everything
def test_over_mocked(mocker):
mocker.patch("myapp.services.validate_email", return_value=True)
mocker.patch("myapp.services.hash_password", return_value="hashed")
mocker.patch("myapp.services.generate_id", return_value=1)
mocker.patch("myapp.services.send_email")
mocker.patch("myapp.services.log_event")
# Test doesn't test anything real!
result = create_user("alice@example.com", "password")
assert result["id"] == 1 # Just testing mocks
# ✅ GOOD: Mock only external dependencies
def test_properly_mocked(mocker, fake_database):
mocker.patch("myapp.services.send_email") # External service
# Real validation, hashing, ID generation
result = create_user("alice@example.com", "password", db=fake_database)
assert result["id"] is not None
assert fake_database.users[result["id"]]["email"] == "alice@example.com"2. Fixture Scope Mismatch
python
# ❌ BAD: Function-scoped fixture depends on module-scoped
@pytest.fixture(scope="module")
def database():
return Database.connect()
@pytest.fixture # scope="function" (default)
def user(database):
# Creates user in module-scoped database
# User persists across tests!
return database.create_user("Alice")
# ✅ GOOD: Match scopes or cleanup
@pytest.fixture
def user(database):
user = database.create_user("Alice")
yield user
database.delete_user(user.id) # Cleanup3. Mock Not Reset Between Tests
python
# ❌ BAD: Mock state leaks
mock_api = Mock()
def test_first():
mock_api.call_count = 0 # Manual reset needed!
mock_api.get_data()
assert mock_api.call_count == 1
def test_second():
mock_api.get_data()
assert mock_api.call_count == 1 # Fails! Count is 2
# ✅ GOOD: Use fixtures for fresh mocks
@pytest.fixture
def mock_api():
return Mock()
def test_first(mock_api):
mock_api.get_data()
assert mock_api.call_count == 1
def test_second(mock_api):
mock_api.get_data()
assert mock_api.call_count == 1 # Fresh mock4. Patching Wrong Target
python
# myapp/utils.py
from datetime import datetime
def get_current_time():
return datetime.now()
# ❌ WRONG: Patch datetime module
@patch("datetime.datetime")
def test_wrong(mock_datetime):
# Doesn't work! datetime is imported into utils.py
pass
# ✅ CORRECT: Patch where it's used
@patch("myapp.utils.datetime")
def test_correct(mock_datetime):
mock_datetime.now.return_value = datetime(2024, 1, 1, 12, 0, 0)
result = get_current_time()
assert result.year == 2024Bảng Tóm tắt
python
# === FIXTURE SCOPES ===
@pytest.fixture(scope="function") # Default, per test
@pytest.fixture(scope="class") # Per test class
@pytest.fixture(scope="module") # Per test file
@pytest.fixture(scope="session") # Entire test run
# === FIXTURE PATTERNS ===
@pytest.fixture
def simple_fixture():
return "value"
@pytest.fixture
def setup_teardown():
# Setup
resource = create_resource()
yield resource
# Teardown
resource.cleanup()
@pytest.fixture
def factory():
def _create(**kwargs):
return MyObject(**kwargs)
return _create
# === MOCKING ===
from unittest.mock import Mock, patch, MagicMock
# Basic mock
mock = Mock()
mock.method.return_value = "value"
mock.method.side_effect = Exception("error")
# Patch decorator
@patch("module.function")
def test_func(mock_func):
mock_func.return_value = "mocked"
# pytest-mock
def test_with_mocker(mocker):
mock = mocker.patch("module.function")
spy = mocker.spy(module, "function")
# === CONFTEST.PY ===
# tests/conftest.py - shared fixtures
# tests/unit/conftest.py - unit test fixtures
# tests/integration/conftest.py - integration fixturesCross-links
- Prerequisites: Pytest Fundamentals
- Next: Property-Based Testing - Hypothesis library
- Related: Test Architecture - Test doubles patterns
- Related: Protocols & ABCs - Protocol-based DI