Skip to content

Fixtures — Dependency Injection cho Testing

Hãy tưởng tượng bạn đang viết integration tests cho một payment service. Mỗi test cần database connection, Redis cache, mock API client, và một user đã đăng nhập. Nếu copy-paste setup code vào từng test function, bạn sẽ có hàng trăm dòng boilerplate — và khi schema thay đổi, bạn phải sửa ở mọi nơi.

Fixtures giải quyết triệt để vấn đề này. Thay vì setUp/tearDown của unittest, pytest fixtures hoạt động như một hệ thống dependency injection: mỗi test khai báo những gì nó cần, pytest tự động resolve, inject, và cleanup. Kết quả là test code gọn gàng, dễ maintain, và setup logic được tái sử dụng xuyên suốt project.

Insight quan trọng: fixtures không chỉ là "setup code" — chúng là contract giữa test và môi trường. Khi bạn thiết kế fixtures tốt, bạn đang thiết kế testing architecture cho toàn bộ project.


Bức tranh tư duy

Hãy nghĩ về fixtures như phòng thí nghiệm chuẩn bị sẵn dụng cụ. Khi nhà khoa học bước vào lab, tất cả thiết bị đã được hiệu chuẩn, hóa chất đã pha sẵn, mẫu thí nghiệm đã chuẩn bị. Nhà khoa học chỉ cần tập trung vào thí nghiệm — không phải lo pha dung dịch hay rửa ống nghiệm. Sau khi xong, nhân viên lab tự động dọn dẹp.

Trong pytest, mỗi fixture là một dụng cụ lab. Test function chỉ cần liệt kê dụng cụ cần dùng (qua tham số), và pytest — người quản lý phòng lab — sẽ chuẩn bị trước, cung cấp đúng lúc, và thu hồi sau khi xong.

┌───────────────────────────────────────────────────────────┐
│                    FIXTURE SCOPES                         │
│                                                           │
│  ┌─── session ────────────────────────────────────────┐   │
│  │  Tạo 1 lần cho toàn bộ test run                    │   │
│  │  VD: database engine, API client config             │   │
│  │                                                     │   │
│  │  ┌─── module ──────────────────────────────┐        │   │
│  │  │  Tạo 1 lần cho mỗi test file            │        │   │
│  │  │  VD: test schema, seed data              │        │   │
│  │  │                                          │        │   │
│  │  │  ┌─── class ─────────────────────┐       │        │   │
│  │  │  │  Tạo 1 lần cho mỗi test class │       │        │   │
│  │  │  │                                │       │        │   │
│  │  │  │  ┌─── function ────────┐       │       │        │   │
│  │  │  │  │  Tạo mới MỖI test  │       │       │        │   │
│  │  │  │  │  ← DEFAULT scope    │       │       │        │   │
│  │  │  │  └─────────────────────┘       │       │        │   │
│  │  │  └────────────────────────────────┘       │        │   │
│  │  └───────────────────────────────────────────┘        │   │
│  └────────────────────────────────────────────────────┘   │
│                                                           │
│  Scope rộng → ít tạo → nhanh → ít cô lập                 │
│  Scope hẹp  → nhiều tạo → chậm → cô lập tốt             │
└───────────────────────────────────────────────────────────┘

Nguyên tắc vàng: Luôn chọn scope hẹp nhất có thể chấp nhận được về performance. Bắt đầu với function (default), chỉ mở rộng khi có lý do cụ thể.


Cốt lõi kỹ thuật

Fixture cơ bản và yield

Fixture đơn giản nhất trả về giá trị. Khi cần cleanup, dùng yield thay return:

python
import pytest
from dataclasses import dataclass


@dataclass
class User:
    name: str
    email: str
    active: bool = True


@pytest.fixture
def sample_user() -> User:
    return User(name="Alice", email="alice@penalgo.dev")


@pytest.fixture
def temp_file(tmp_path):
    filepath = tmp_path / "test_data.json"
    filepath.write_text('{"key": "value"}')
    yield filepath
    if filepath.exists():
        filepath.unlink()


def test_user_is_active(sample_user):
    assert sample_user.active is True


def test_read_temp_file(temp_file):
    import json
    data = json.loads(temp_file.read_text())
    assert data["key"] == "value"

Cơ chế yield: Mọi thứ trước yield là setup, giá trị yield được inject vào test, mọi thứ sau yield là teardown — luôn chạy kể cả khi test fail.

Fixture Scopes

python
import pytest
import sqlite3


@pytest.fixture  # scope="function" là default
def counter():
    return {"value": 0}


@pytest.fixture(scope="module")
def db_schema():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
    conn.commit()
    yield conn
    conn.close()


@pytest.fixture(scope="session")
def api_base_url():
    return "https://api.staging.penalgo.dev/v1"


def test_counter_a(counter):
    counter["value"] += 10
    assert counter["value"] == 10


def test_counter_b(counter):
    # Fresh counter — không bị ảnh hưởng bởi test trước
    assert counter["value"] == 0

Factory Fixtures

Khi test cần nhiều biến thể của cùng một object, factory fixture trả về callable:

python
import pytest
from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Order:
    order_id: str
    customer_email: str
    total: float
    status: str = "pending"
    created_at: datetime = field(default_factory=datetime.now)


@pytest.fixture
def make_order():
    """Factory tạo Order với defaults có thể override."""
    _counter = 0

    def _factory(
        customer_email: str = "test@penalgo.dev",
        total: float = 99.99,
        status: str = "pending",
    ) -> Order:
        nonlocal _counter
        _counter += 1
        return Order(
            order_id=f"ORD-{_counter:04d}",
            customer_email=customer_email,
            total=total,
            status=status,
        )
    return _factory


def test_multiple_orders(make_order):
    pending = make_order(status="pending")
    shipped = make_order(status="shipped", total=200.00)
    assert pending.order_id != shipped.order_id
    assert shipped.total == 200.00

tmp_path

Pytest cung cấp built-in fixtures để làm việc với temporary files an toàn:

python
import json


def test_json_processing(tmp_path):
    """tmp_path là pathlib.Path, unique cho mỗi test."""
    data = {"users": [{"name": "Alice"}, {"name": "Bob"}]}

    input_file = tmp_path / "input.json"
    input_file.write_text(json.dumps(data))

    raw = json.loads(input_file.read_text())
    processed = {"count": len(raw["users"])}

    output_file = tmp_path / "output.json"
    output_file.write_text(json.dumps(processed))

    result = json.loads(output_file.read_text())
    assert result["count"] == 2

monkeypatch

monkeypatch tạm thời thay đổi attributes, environment variables, dictionary entries — tự động revert sau mỗi test:

python
import os
import pytest


class PaymentGateway:
    def __init__(self):
        self.api_key = os.environ.get("PAYMENT_API_KEY", "")
        self.base_url = os.environ.get("PAYMENT_URL", "https://api.payment.prod")

    def charge(self, amount: float) -> dict:
        if not self.api_key:
            raise ValueError("API key chưa được cấu hình")
        return {"status": "charged", "amount": amount}


def test_payment_missing_key(monkeypatch):
    monkeypatch.delenv("PAYMENT_API_KEY", raising=False)
    gateway = PaymentGateway()
    with pytest.raises(ValueError, match="API key chưa được cấu hình"):
        gateway.charge(100.00)


def test_payment_staging_env(monkeypatch):
    monkeypatch.setenv("PAYMENT_API_KEY", "sk_test_abc123")
    monkeypatch.setenv("PAYMENT_URL", "https://api.payment.staging")
    gateway = PaymentGateway()
    assert gateway.api_key == "sk_test_abc123"
    assert "staging" in gateway.base_url


def test_override_method(monkeypatch):
    monkeypatch.setattr(
        PaymentGateway, "charge",
        lambda self, amount: {"status": "mocked", "amount": amount},
    )
    result = PaymentGateway().charge(50.00)
    assert result["status"] == "mocked"

Hệ thống conftest.py

conftest.py tổ chức fixtures chia sẻ giữa nhiều test files. Pytest tự động discover theo thứ bậc thư mục:

tests/
├── conftest.py              ← fixtures dùng chung toàn project
├── unit/
│   ├── conftest.py          ← fixtures riêng cho unit tests
│   ├── test_user.py
│   └── test_order.py
└── integration/
    ├── conftest.py          ← fixtures riêng cho integration tests
    └── test_api.py
python
# tests/conftest.py — Root level
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base
from app.config import TestConfig


@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine(TestConfig.DATABASE_URL)
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)
    engine.dispose()


@pytest.fixture
def db_session(db_engine):
    """Transaction rollback pattern — mỗi test cô lập hoàn toàn."""
    connection = db_engine.connect()
    transaction = connection.begin()
    session = sessionmaker(bind=connection)()

    yield session

    session.close()
    transaction.rollback()
    connection.close()

Quy tắc conftest.py:

  • Không cần import — pytest tự động discover
  • Fixture ở thư mục cha khả dụng cho tất cả thư mục con
  • Fixture cùng tên ở thư mục con sẽ override fixture cha

Thực chiến

Tình huống: Database test fixtures cho user management service

Cần test CRUD operations với database thật (SQLite cho test), đảm bảo mỗi test cô lập:

python
# app/repository.py
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Optional


@dataclass
class User:
    id: Optional[int] = None
    username: str = ""
    email: str = ""
    password_hash: str = ""


class UserRepository:
    def __init__(self, db_path: str):
        self._db_path = db_path

    @contextmanager
    def _get_conn(self):
        conn = sqlite3.connect(self._db_path)
        conn.row_factory = sqlite3.Row
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    def create_user(self, username: str, email: str, pw_hash: str) -> User:
        with self._get_conn() as conn:
            cur = conn.execute(
                "INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)",
                (username, email, pw_hash),
            )
            return User(id=cur.lastrowid, username=username,
                        email=email, password_hash=pw_hash)

    def get_by_id(self, user_id: int) -> Optional[User]:
        with self._get_conn() as conn:
            row = conn.execute("SELECT * FROM users WHERE id = ?",
                               (user_id,)).fetchone()
            return User(**dict(row)) if row else None

    def delete_user(self, user_id: int) -> bool:
        with self._get_conn() as conn:
            return conn.execute("DELETE FROM users WHERE id = ?",
                                (user_id,)).rowcount > 0
python
# tests/conftest.py
import pytest
import sqlite3
from app.repository import UserRepository

SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL
);
"""


@pytest.fixture
def db_path(tmp_path):
    return tmp_path / "test.db"


@pytest.fixture
def user_repo(db_path) -> UserRepository:
    conn = sqlite3.connect(str(db_path))
    conn.executescript(SCHEMA_SQL)
    conn.close()
    return UserRepository(str(db_path))


@pytest.fixture
def make_user(user_repo):
    """Factory fixture tạo user với defaults."""
    _n = 0
    def _factory(username=None, email=None):
        nonlocal _n
        _n += 1
        return user_repo.create_user(
            username or f"user_{_n}",
            email or f"user_{_n}@penalgo.dev",
            "hashed_test_pw",
        )
    return _factory
python
# tests/test_user_repository.py
import pytest


class TestUserCRUD:
    def test_create_returns_valid_id(self, user_repo):
        user = user_repo.create_user("charlie", "charlie@penalgo.dev", "pw")
        assert user.id is not None and user.id > 0

    def test_duplicate_username_raises(self, user_repo):
        user_repo.create_user("alice", "a1@penalgo.dev", "pw")
        with pytest.raises(Exception):
            user_repo.create_user("alice", "a2@penalgo.dev", "pw")

    def test_get_existing_user(self, make_user, user_repo):
        created = make_user(username="diana")
        fetched = user_repo.get_by_id(created.id)
        assert fetched is not None
        assert fetched.username == "diana"

    def test_get_nonexistent_returns_none(self, user_repo):
        assert user_repo.get_by_id(99999) is None

    def test_delete_and_verify(self, make_user, user_repo):
        user = make_user()
        assert user_repo.delete_user(user.id) is True
        assert user_repo.get_by_id(user.id) is None

    def test_factory_creates_unique_users(self, make_user):
        a, b = make_user(), make_user()
        assert a.id != b.id
        assert a.username != b.username

Sai lầm điển hình

Sai lầm 1: Dùng session scope cho mutable data

python
# ❌ SAI — mutable data shared giữa TẤT CẢ tests
@pytest.fixture(scope="session")
def shared_users():
    return [{"name": "Alice"}, {"name": "Bob"}]

def test_add_user(shared_users):
    shared_users.append({"name": "Charlie"})
    assert len(shared_users) == 3

def test_user_count(shared_users):
    assert len(shared_users) == 2  # ❌ FAIL! Vẫn còn Charlie
python
# ✅ ĐÚNG — function scope cho mutable data
@pytest.fixture
def users():
    return [{"name": "Alice"}, {"name": "Bob"}]

def test_add_user(users):
    users.append({"name": "Charlie"})
    assert len(users) == 3

def test_user_count(users):
    assert len(users) == 2  # ✅ Fresh list

Sai lầm 2: Quên cleanup trong yield fixtures

python
# ❌ SAI — file handle bị leak khi test fail
@pytest.fixture
def log_file(tmp_path):
    f = open(tmp_path / "test.log", "w")
    return f  # Không có cleanup!
python
# ✅ ĐÚNG — yield đảm bảo cleanup luôn chạy
@pytest.fixture
def log_file(tmp_path):
    f = open(tmp_path / "test.log", "w")
    yield f
    f.close()

Sai lầm 3: Fixture dependency cycle

python
# ❌ SAI — circular: config → database → config
@pytest.fixture
def config(database):
    return {"db_url": database.url}

@pytest.fixture
def database(config):
    return connect(config["db_url"])
# → RecursionError
python
# ✅ ĐÚNG — tách dependency chung ra
@pytest.fixture
def db_url():
    return "sqlite:///:memory:"

@pytest.fixture
def config(db_url):
    return {"db_url": db_url}

@pytest.fixture
def database(db_url):
    return connect(db_url)

Sai lầm 4: monkeypatch với scope rộng

python
# ❌ SAI — monkeypatch chỉ hỗ trợ function scope
@pytest.fixture(scope="module")
def patched_env(monkeypatch):  # TypeError!
    monkeypatch.setenv("API_KEY", "test_key")
python
# ✅ ĐÚNG — dùng function scope hoặc tự quản lý
@pytest.fixture
def patched_env(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key")

# Nếu cần module scope — tự cleanup
@pytest.fixture(scope="module")
def patched_env_module():
    import os
    original = os.environ.get("API_KEY")
    os.environ["API_KEY"] = "test_key"
    yield
    if original is None:
        os.environ.pop("API_KEY", None)
    else:
        os.environ["API_KEY"] = original

Sai lầm 5: Fixture làm quá nhiều việc

python
# ❌ SAI — một fixture setup tất cả, khó debug và tái sử dụng
@pytest.fixture
def everything():
    db = create_database()
    db.migrate()
    admin = db.create_user("admin", role="admin")
    cache = Redis()
    api = APIClient(db=db, cache=cache, auth=admin)
    return api
python
# ✅ ĐÚNG — tách nhỏ, composable
@pytest.fixture(scope="session")
def db_engine():
    engine = create_database()
    engine.migrate()
    yield engine
    engine.dispose()

@pytest.fixture
def db_session(db_engine):
    session = db_engine.new_session()
    yield session
    session.rollback()

@pytest.fixture
def admin_user(db_session):
    return db_session.create_user("admin", role="admin")

@pytest.fixture
def api_client(db_session, admin_user):
    return APIClient(db=db_session, auth=admin_user)

Under the Hood

Cơ chế resolve fixture dependencies

Khi pytest gặp một test function, nó thực hiện:

  1. Introspection: Đọc parameter names qua inspect.signature()
  2. Resolution: Tìm fixture cùng tên trong fixture registry
  3. Dependency graph: Xây dựng DAG của fixture dependencies
  4. Topological sort: Fixtures không phụ thuộc nhau chạy trước
  5. Caching: Fixture đã tồn tại trong scope → trả về cached instance
  6. Finalization: Teardown theo thứ tự LIFO (ngược setup)
test_checkout(user, cart, payment_gateway)
         │       │          │
         ▼       ▼          ▼
    ┌────────┐ ┌────┐ ┌──────────────┐
    │  user  │ │cart│ │payment_gateway│
    │ (func) │ │(fn)│ │   (func)     │
    └───┬────┘ └─┬──┘ └──────┬───────┘
        │        │            │
        ▼        ▼            ▼
    ┌───────────────┐   ┌──────────┐
    │  db_session   │   │  config  │
    │  (function)   │   │(session) │  ← cached
    └──────┬────────┘   └──────────┘

    ┌──────────────┐
    │  db_engine   │   ← cached (session scope)
    │  (session)   │
    └──────────────┘

Performance impact của scopes

ScopeSố lần tạo (100 tests)IsolationPhù hợp cho
function100Hoàn toànMutable data, lightweight objects
class~10-20Trong classShared read-only state
module~5-10Trong fileSchema setup, config loading
session1Không cóDB engine, immutable config

Trade-offs

Quyết địnhƯu điểmNhược điểm
Luôn function scopeCô lập hoàn toàn, dễ debugChậm nếu setup tốn kém
Session cho DB engineNhanh, tạo 1 lầnQuản lý state phức tạp
Factory thay directLinh hoạt, nhiều biến thểCode verbose hơn
monkeypatch thay mockKhông thêm dependencyChỉ function scope
conftest hierarchy sâuTổ chức rõ ràngKhó trace fixture origin

Checklist ghi nhớ

✅ Checklist triển khai

Fixture cơ bản

  • [ ] Dùng yield thay return khi cần cleanup — teardown luôn chạy kể cả khi test fail
  • [ ] Bắt đầu với function scope, chỉ mở rộng khi performance yêu cầu
  • [ ] Factory fixture khi cần nhiều biến thể của cùng một object
  • [ ] Tên fixture mô tả output (user), không phải action (create_user)

Scope và lifecycle

  • [ ] Session scope chỉ cho immutable hoặc connection-like resources
  • [ ] Không dùng mutable data với scope rộng hơn function
  • [ ] monkeypatch chỉ hoạt động ở function scope
  • [ ] Scope hẹp có thể request scope rộng, nhưng không ngược lại (ScopeMismatch)

conftest.py

  • [ ] Fixtures chia sẻ đặt trong conftest.py, không import giữa test files
  • [ ] Tổ chức theo thứ bậc: root → unit/integration → feature
  • [ ] Fixture cùng tên ở thư mục con override fixture cha

Production testing

  • [ ] Database tests dùng transaction rollback cho isolation
  • [ ] tmp_path cho mọi thao tác file I/O trong tests
  • [ ] Environment variables patch qua monkeypatch.setenv(), không sửa os.environ trực tiếp

Bài tập luyện tập

Bài 1: Thiết kế fixture hierarchy — Intermediate

Bạn có e-commerce test suite. Viết conftest.py với: product_catalog (session scope, danh sách sản phẩm), shopping_cart (function scope, giỏ trống), và make_product (factory fixture).

🧠 Quiz

Tại sao product_catalog nên dùng session scope?

  • [ ] A. Vì nó cần thay đổi giữa các test
  • [x] B. Vì catalog là read-only data, tạo 1 lần tiết kiệm thời gian
  • [ ] C. Vì pytest yêu cầu list phải dùng session scope
  • [ ] D. Vì catalog cần cleanup phức tạp

Giải thích: product_catalog chứa dữ liệu tĩnh không bị modify bởi test nào. Session scope tạo 1 lần duy nhất. Nếu catalog bị mutate trong test, phải đổi về function scope.

Lời giải tham khảo
python
import pytest
from dataclasses import dataclass


@dataclass
class Product:
    sku: str
    name: str
    price: float
    stock: int = 100


@pytest.fixture(scope="session")
def product_catalog() -> list[Product]:
    return [
        Product(sku="LAPTOP-001", name="Pro Laptop", price=1299.99),
        Product(sku="MOUSE-001", name="Wireless Mouse", price=29.99),
    ]


@pytest.fixture
def shopping_cart() -> dict:
    return {"items": [], "total": 0.0}


@pytest.fixture
def make_product():
    _n = 0
    def _factory(name="Test Product", price=9.99, stock=100):
        nonlocal _n
        _n += 1
        return Product(sku=f"TEST-{_n:04d}", name=name,
                        price=price, stock=stock)
    return _factory


def test_add_to_cart(shopping_cart, product_catalog):
    laptop = product_catalog[0]
    shopping_cart["items"].append(laptop)
    shopping_cart["total"] += laptop.price
    assert shopping_cart["total"] == 1299.99

def test_cart_isolation(shopping_cart):
    assert len(shopping_cart["items"]) == 0  # Fresh cart

Bài 2: monkeypatch và fixture composition — Intermediate

Viết test cho NotificationService dưới đây. Service gửi notification qua email (normal priority) hoặc tất cả channels (critical priority). Dùng fake channels làm fixtures.

python
class NotificationService:
    def __init__(self, email, sms, push):
        self.email = email
        self.sms = sms
        self.push = push

    def notify(self, message: str, recipient: str, priority: str = "normal"):
        channels = (
            [self.email, self.sms, self.push]
            if priority == "critical"
            else [self.email]
        )
        for ch in channels:
            ch.send(recipient, message)
        return {"channels_used": len(channels)}

🧠 Quiz

Fixture nào nên dùng function scope?

  • [x] A. Tất cả fake channels và notification_service
  • [ ] B. Chỉ notification_service, fake channels dùng session
  • [ ] C. Chỉ fake channels có mutable state
  • [ ] D. Không cần quan tâm scope

Giải thích: Fake channels tích lũy sent messages. Nếu dùng session scope, test sau sẽ thấy messages từ test trước. Function scope đảm bảo mỗi test có channels sạch.

Lời giải tham khảo
python
import pytest
from dataclasses import dataclass, field


@dataclass
class FakeChannel:
    sent: list = field(default_factory=list)
    def send(self, recipient: str, message: str):
        self.sent.append({"to": recipient, "msg": message})


@pytest.fixture
def fake_email():
    return FakeChannel()

@pytest.fixture
def fake_sms():
    return FakeChannel()

@pytest.fixture
def fake_push():
    return FakeChannel()

@pytest.fixture
def svc(fake_email, fake_sms, fake_push):
    return NotificationService(fake_email, fake_sms, fake_push)


def test_normal_email_only(svc, fake_email, fake_sms):
    result = svc.notify("Đơn hàng xác nhận", "a@penalgo.dev")
    assert result["channels_used"] == 1
    assert len(fake_email.sent) == 1
    assert len(fake_sms.sent) == 0

def test_critical_all_channels(svc, fake_email, fake_sms, fake_push):
    result = svc.notify("Bảo mật", "a@penalgo.dev", priority="critical")
    assert result["channels_used"] == 3
    assert len(fake_email.sent) == 1
    assert len(fake_sms.sent) == 1
    assert len(fake_push.sent) == 1

Liên kết học tiếp

Từ khóa: pytest fixtures, fixture scope, conftest.py, yield fixture, factory fixture, monkeypatch, tmp_path, dependency injection, test isolation, fixture caching, teardown, session scope