Skip to content

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:

ScopeLifetimeUse Case
functionMỗi test functionDefault, isolated tests
classMỗi test classShared state trong class
moduleMỗi test fileExpensive setup per file
sessionToàn bộ test runDatabase 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 counter

Class 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] Disconnecting

Module 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.py

Root 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_user

Integration 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.status

Factory 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.id

Parametrized 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-mock

Basic 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_emails

Production 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)  # Cleanup

3. 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 mock

4. 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 == 2024

Bả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 fixtures