Giao diện
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"] == 0Factory 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.00tmp_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"] == 2monkeypatch
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.pypython
# 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 > 0python
# 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 _factorypython
# 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.usernameSai 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 Charliepython
# ✅ ĐÚ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"])
# → RecursionErrorpython
# ✅ ĐÚ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 apipython
# ✅ ĐÚ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:
- Introspection: Đọc parameter names qua
inspect.signature() - Resolution: Tìm fixture cùng tên trong fixture registry
- Dependency graph: Xây dựng DAG của fixture dependencies
- Topological sort: Fixtures không phụ thuộc nhau chạy trước
- Caching: Fixture đã tồn tại trong scope → trả về cached instance
- 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
| Scope | Số lần tạo (100 tests) | Isolation | Phù hợp cho |
|---|---|---|---|
function | 100 | Hoàn toàn | Mutable data, lightweight objects |
class | ~10-20 | Trong class | Shared read-only state |
module | ~5-10 | Trong file | Schema setup, config loading |
session | 1 | Không có | DB engine, immutable config |
Trade-offs
| Quyết định | Ưu điểm | Nhược điểm |
|---|---|---|
Luôn function scope | Cô lập hoàn toàn, dễ debug | Chậm nếu setup tốn kém |
| Session cho DB engine | Nhanh, tạo 1 lần | Quản lý state phức tạp |
| Factory thay direct | Linh hoạt, nhiều biến thể | Code verbose hơn |
| monkeypatch thay mock | Không thêm dependency | Chỉ function scope |
| conftest hierarchy sâu | Tổ chức rõ ràng | Khó trace fixture origin |
Checklist ghi nhớ
✅ Checklist triển khai
Fixture cơ bản
- [ ] Dùng
yieldthayreturnkhi cần cleanup — teardown luôn chạy kể cả khi test fail - [ ] Bắt đầu với
functionscope, 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 - [ ]
monkeypatchchỉ 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_pathcho mọi thao tác file I/O trong tests - [ ] Environment variables patch qua
monkeypatch.setenv(), không sửaos.environtrự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 cartBà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) == 1Liê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