Viết test với pytest và fixture trong Python
1. Tổng quan
Khi số lượng test tăng lên, phần chuẩn bị dữ liệu (tạo khách hàng, đơn hàng, kết nối database…) thường bị lặp lại ở khắp nơi. pytest giải quyết vấn đề này bằng fixture: một hàm cung cấp dữ liệu hoặc tài nguyên cho test và tự dọn dẹp sau khi test chạy xong.
Bài này đi từ test pytest đầu tiên đến fixture có yield, scope, conftest.py, các fixture có sẵn và tham số hóa. Đọc xong, bạn có thể tổ chức bộ test Python gọn, dễ đọc và ít lặp code.
2. Cài đặt pytest
Bạn nên cài pytest trong một virtual environment:
python -m venv .venv
source .venv/bin/activate
pip install pytest
Cấu trúc project dùng trong bài như sau:
shop/
├── shop.py
└── tests/
├── conftest.py
├── test_order.py
└── test_repository.py
pytest tự tìm các file tên test_*.py hoặc *_test.py. Trong mỗi file, nó chạy các hàm bắt đầu bằng test_.
Để import shop từ thư mục tests, bạn thêm cấu hình vào pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
3. Test đầu tiên
Đây là code nghiệp vụ trong shop.py. Giá tiền tính bằng đồng (kiểu int) để tránh sai số của float:
from dataclasses import dataclass, field
@dataclass
class Customer:
name: str
is_vip: bool = False
@dataclass
class Order:
customer: Customer
items: list[tuple[str, int, int]] = field(default_factory=list)
def add_item(self, sku: str, quantity: int, price: int) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.items.append((sku, quantity, price))
def total(self) -> int:
subtotal = sum(qty * price for _, qty, price in self.items)
return subtotal * 90 // 100 if self.customer.is_vip else subtotal
Khách VIP được giảm 10%. Test cho logic này trong tests/test_order.py:
import pytest
from shop import Customer, Order
def test_total_for_regular_customer():
order = Order(Customer("An"))
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_add_item_rejects_zero_quantity():
order = Order(Customer("An"))
with pytest.raises(ValueError, match="quantity must be positive"):
order.add_item("BOOK-01", 0, 50_000)
Bạn chỉ cần hai điều:
assertthuần của Python. Bạn không cầnassertEqualhay thư viện assertion riêng. pytest viết lại câuassertđể báo lỗi chi tiết.pytest.raiseskiểm tra một khối code có ném exception đúng loại hay không. Tham sốmatchlà regex so với message của exception.
Chạy test:
pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED [100%]
============================== 2 passed in 0.01s ===============================
Nếu bạn cố tình sửa giá trị mong đợi thành 90_000, pytest in ra cả hai vế:
> assert order.total() == 90_000
E assert 100000 == 90000
E + where 100000 = total()
4. Fixture cơ bản
4.1. Khai báo và sử dụng fixture
Hai test trên đều tạo Customer("An") và Order. Khi có hàng chục test, đoạn code đó lặp lại rất nhiều. Ta chuyển nó vào fixture:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
def test_total_for_regular_customer(order):
order.add_item("BOOK-01", 2, 50_000)
assert order.total() == 100_000
def test_empty_order_has_zero_total(order):
assert order.total() == 0
Cơ chế hoạt động:
@pytest.fixtuređánh dấu một hàm là fixture.- Test khai báo tham số trùng tên fixture. pytest gọi fixture và truyền giá trị trả về vào test. Đây là dependency injection theo tên.
- Fixture
ordernhận fixturecustomer. Fixture có thể phụ thuộc vào fixture khác, pytest tự giải quyết thứ tự.
Mặc định, mỗi test nhận một instance mới. Vì vậy add_item ở test thứ nhất không làm order của test thứ hai bị thay đổi.
4.2. Factory fixture
Đôi khi một test cần nhiều đơn hàng với tham số khác nhau. Thay vì trả về object, fixture có thể trả về một hàm tạo object:
@pytest.fixture
def make_order():
def _make(name="An", is_vip=False, items=()):
order = Order(Customer(name, is_vip))
for sku, qty, price in items:
order.add_item(sku, qty, price)
return order
return _make
def test_vip_gets_ten_percent_discount(make_order):
regular = make_order(items=[("BOOK-01", 1, 200_000)])
vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])
assert regular.total() == 200_000
assert vip.total() == 180_000
Pattern này giữ phần chuẩn bị gọn mà vẫn cho test tự quyết định dữ liệu.
5. Setup và teardown với yield
Fixture thực sự hữu ích khi làm việc với tài nguyên cần dọn dẹp như kết nối database, file hoặc server giả. Thêm một repository dùng SQLite vào shop.py:
import sqlite3
class OrderRepository:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def save(self, customer_name: str, total: int) -> int:
cur = self.conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)",
(customer_name, total),
)
return cur.lastrowid
def count(self) -> int:
return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
Fixture dùng yield thay cho return:
import sqlite3
import pytest
from shop import OrderRepository
@pytest.fixture
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
return OrderRepository(db_connection)
def test_save_order_returns_id(repository):
order_id = repository.save("An", 100_000)
assert order_id == 1
assert repository.count() == 1
Phần trước yield là setup. Giá trị sau yield được truyền vào test. Phần sau yield là teardown và luôn chạy, kể cả khi test fail. Bạn không cần try/finally.
Cờ --setup-show cho thấy rõ thứ tự:
pytest tests/test_repository.py --setup-show
tests/test_repository.py
SETUP F db_connection
SETUP F repository (fixtures used: db_connection)
tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
TEARDOWN F repository
TEARDOWN F db_connection
Chữ F nghĩa là scope function. Teardown chạy theo thứ tự ngược với setup.
Lưu ý: Nếu exception xảy ra trước
yield, phần teardown sẽ không chạy. Lý do là fixture chưa setup xong. Bạn nên giữ phần setup ngắn, hoặc tự bọctry/finallynếu setup gồm nhiều bước có thể lỗi.
6. Scope của fixture
Tạo tài nguyên đắt đỏ (kết nối database thật, khởi động container…) cho mỗi test sẽ làm bộ test chậm. Tham số scope quyết định fixture sống bao lâu:
| Scope | Tạo lại khi |
|---|---|
function (mặc định) |
mỗi test |
class |
mỗi class test |
module |
mỗi file test |
package |
mỗi package |
session |
một lần cho cả lần chạy |
Ví dụ dùng chung một kết nối cho cả file, nhưng xóa dữ liệu sau mỗi test:
@pytest.fixture(scope="module")
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
yield conn
conn.close()
@pytest.fixture
def repository(db_connection):
yield OrderRepository(db_connection)
db_connection.execute("DELETE FROM orders")
def test_first_save(repository):
repository.save("An", 100_000)
assert repository.count() == 1
def test_second_save_starts_clean(repository):
repository.save("Binh", 50_000)
assert repository.count() == 1
db_connection chỉ được tạo một lần. repository có scope function nên dọn bảng sau mỗi test. Nhờ đó test thứ hai vẫn thấy bảng rỗng.
Có một quy tắc quan trọng: fixture chỉ được dùng fixture có scope rộng hơn hoặc bằng nó. Nếu một fixture session dùng fixture function, pytest báo lỗi:
ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.
7. Chia sẻ fixture qua conftest.py
Fixture customer, make_order hữu ích cho nhiều file test. Bạn đặt chúng vào tests/conftest.py:
import pytest
from shop import Customer, Order
@pytest.fixture
def customer():
return Customer("An")
@pytest.fixture
def order(customer):
return Order(customer)
pytest tự nạp conftest.py. Mọi test trong thư mục đó và các thư mục con đều dùng được các fixture này mà không cần import. Bạn có thể đặt conftest.py ở nhiều cấp thư mục. Fixture ở cấp gần test hơn sẽ ghi đè fixture cùng tên ở cấp ngoài.
Lưu ý: Đừng
import conftesttrong file test. Nếu làm vậy, fixture có thể bị đăng ký hai lần và gây hành vi khó đoán.
Để xem test nào dùng fixture nào và fixture đó được định nghĩa ở đâu, bạn chạy:
pytest --fixtures-per-test tests/test_order.py
8. Các fixture có sẵn
pytest cung cấp sẵn nhiều fixture tiện lợi. Ba fixture dùng nhiều nhất là tmp_path, monkeypatch và capsys.
Giả sử shop.py có thêm hai hàm:
import os
from pathlib import Path
def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
path.write_text("\n".join(lines), encoding="utf-8")
class PaymentService:
def __init__(self):
self.api_key = os.environ["PAYMENT_API_KEY"]
def charge(self, order: "Order") -> str:
print(f"Charging {order.total()} VND")
return "SUCCESS"
Test cho chúng:
from shop import PaymentService, export_orders_csv
def test_export_orders_csv(tmp_path):
output = tmp_path / "orders.csv"
export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)
assert output.read_text(encoding="utf-8").splitlines() == [
"customer,total",
"An,100000",
"Binh,50000",
]
def test_payment_service_charges_order(monkeypatch, capsys, order):
monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
order.add_item("BOOK-01", 1, 75_000)
result = PaymentService().charge(order)
assert result == "SUCCESS"
assert capsys.readouterr().out == "Charging 75000 VND\n"
tmp_pathlà mộtpathlib.Pathtrỏ tới thư mục tạm, riêng cho từng test. Test không làm bẩn thư mục project.monkeypatchthay đổi biến môi trường, thuộc tính hoặc hàm trong phạm vi một test. Mọi thay đổi được hoàn tác tự động khi test kết thúc. Ngoàisetenv, bạn hay dùngmonkeypatch.setattr(obj, "name", value).capsysbắt nội dung in rastdoutvàstderr.readouterr()trả về những gì đã in từ đầu test hoặc từ lần gọi trước.
9. Tham số hóa test
9.1. @pytest.mark.parametrize
Khi cùng một logic cần kiểm tra với nhiều bộ dữ liệu, bạn không nên copy test. Dùng parametrize:
import pytest
@pytest.mark.parametrize(
("is_vip", "price", "expected"),
[
(False, 100_000, 100_000),
(True, 100_000, 90_000),
(True, 15, 13),
],
ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED
Mỗi bộ tham số là một test riêng, với ID dễ đọc nhờ ids. Lưu ý bạn kết hợp được fixture (make_order) với tham số. Trường hợp vip-rounding-down ghi lại rõ hành vi làm tròn xuống của phép //.
9.2. Fixture có tham số
Nếu muốn mọi test dùng một fixture đều chạy với nhiều biến thể, bạn tham số hóa chính fixture:
@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
return Customer("An", is_vip=request.param)
def test_total_is_never_negative(any_customer):
order = Order(any_customer)
order.add_item("BOOK-01", 3, 10_000)
assert order.total() >= 0
request là fixture đặc biệt. request.param chứa giá trị hiện tại. Test trên tự động chạy hai lần.
10. Lỗi hay gặp và khi nào không nên dùng
- Sai tên fixture. Tên tham số không khớp fixture nào sẽ gây lỗi
fixture 'oder' not found. pytest liệt kê các fixture khả dụng ngay bên dưới, bạn đọc kỹ để sửa. - Gọi fixture như hàm thường. Viết
customer()bên trong test sẽ báo lỗiFixture "customer" called directly. Hãy khai báo nó là tham số. - Scope rộng với dữ liệu mutable. Một
Ordercó scopemodulebị test này thêm item sẽ làm test kia sai. Kết quả phụ thuộc thứ tự chạy, rất khó debug. Chỉ dùng scope rộng cho tài nguyên đắt và không đổi trạng thái, hoặc dọn trạng thái trong fixture scopefunctionnhư ở mục 6. - Lạm dụng
autouse=True. Fixture tự áp dụng cho mọi test tiện ở chỗ không cần khai báo, nhưng người đọc test không thấy nó đâu. Chỉ nên dùng cho việc thực sự toàn cục, ví dụ chặn network. - Chuỗi fixture quá sâu. Khi
orderphụ thuộccustomer,customerphụ thuộcaddress,addressphụ thuộccountry…, người đọc phải nhảy qua nhiều file mới hiểu test. Với dữ liệu đơn giản, tạo trực tiếp trong test hoặc dùng factory fixture thường dễ đọc hơn.
Lưu ý: Fixture là công cụ chuẩn bị môi trường, không phải nơi chứa logic kiểm tra. Hãy giữ
asserttrong hàm test để mỗi test tự kể được nó đang kiểm tra điều gì.
11. Kết luận
Bạn đã đi qua các phần cốt lõi của pytest. assert thuần và pytest.raises dùng để kiểm tra kết quả. Fixture inject theo tên giúp loại bỏ code chuẩn bị lặp lại. yield cho setup/teardown an toàn, còn scope giúp cân bằng giữa tốc độ và tính cô lập. conftest.py chia sẻ fixture giữa nhiều file. Các fixture có sẵn như tmp_path, monkeypatch, capsys xử lý file, môi trường và output. Cuối cùng, parametrize và fixture có params giúp phủ nhiều trường hợp mà không phải copy test.
Khi bộ test lớn dần, bạn nên bắt đầu với fixture scope function. Chỉ nới rộng scope khi đo được rằng tốc độ thực sự là vấn đề.