Mở đầu — vì sao bài này quan trọng
Nếu bạn từng viết unit test bằng những framework "nặng" như unittest của Python — nơi mỗi test phải nằm trong một class kế thừa TestCase, phải nhớ đủ loại assertEqual, assertTrue, assertRaises — thì lần đầu tiên chạm vào pytest sẽ giống như trút được một gánh nặng. Với pytest, một hàm test có thể chỉ đơn giản là:
def test_cong_hai_so():
assert cong(2, 3) == 5
Không class, không method setup rườm rà, chỉ một câu assert thuần Python. Đó là lý do pytest trở thành framework unit testing được ưa chuộng nhất trong hệ sinh thái Python hiện nay — từ các startup fintech ở TP.HCM cho tới những dự án open-source khổng lồ như Django, Flask hay pandas đều dùng nó.
Ở góc độ một SDET (Software Developer in Test) hay QA automation, hiểu pytest không chỉ là biết viết vài hàm test_. Nó là nền tảng để bạn xây dựng cả một test suite có tổ chức: chạy hàng nghìn test trong vài giây, đo độ phủ code (coverage), tham số hóa test để phủ nhiều trường hợp mà không copy-paste, và cô lập phụ thuộc bằng mock. Bài này tập trung hoàn toàn vào unit testing với pytest cho Python — tức lớp đáy của kim tự tháp kiểm thử, nơi bạn kiểm tra từng đơn vị code nhỏ nhất một cách nhanh và rẻ.
Lưu ý phạm vi: các chủ đề như test API bằng Python requests, BDD với Cucumber, hay JUnit cho Java được dạy ở những bài riêng. Ở đây chúng ta chỉ nói về pytest ở tầng unit.
Khái niệm cốt lõi
Vì sao là pytest chứ không phải unittest?
Python có sẵn unittest trong standard library, nhưng nó mang phong cách xUnit từ Java — nhiều boilerplate. pytest giữ lại mọi thứ tốt đẹp (fixture, tổ chức test, chạy chọn lọc) nhưng cắt bỏ phần rườm rà. Ba điểm khiến pytest vượt trội:
- Assert thuần Python với introspection thông minh. Bạn chỉ viết
assert x == y. Khi test fail, pytest tự "mổ xẻ" biểu thức và in ra giá trị thực tế của cả hai vế — điều màassertmặc định của Python không làm được. - Fixture — cơ chế chuẩn bị và dọn dẹp dữ liệu/tài nguyên cho test, tái sử dụng được, có phạm vi (scope) rõ ràng.
- Hệ sinh thái plugin khổng lồ:
pytest-cov(coverage),pytest-mock(mock gọn hơn),pytest-xdist(chạy song song),pytest-html(báo cáo)...
Cài đặt và quy ước đặt tên
pip install pytest pytest-cov pytest-mock
pytest tìm test theo quy ước tự động (auto-discovery). Bạn cần tuân thủ:
- File: đặt tên
test_.pyhoặc_test.py. - Hàm test: bắt đầu bằng
test_, ví dụtest_dang_nhap_thanh_cong. - Class (nếu dùng): bắt đầu bằng
Testvà không có method__init__.
pytest # chạy tất cả, tự tìm test
pytest -v # verbose, in tên từng test
pytest -k "dang_nhap" # chỉ chạy test có tên chứa "dang_nhap"
pytest tests/test_gio_hang.py::test_them_san_pham # chạy đúng 1 test
assert và cách pytest báo lỗi
Giả sử ta có hàm tính giảm giá:
discount.py
def tinh_gia_sau_giam(gia: int, phan_tram: int) -> int:
if not 0 <= phan_tram <= 100:
raise ValueError("Phần trăm giảm phải từ 0 đến 100")
return round(gia * (100 - phan_tram) / 100)
Test tương ứng:
test_discount.py
import pytest
from discount import tinh_gia_sau_giamdef test_giam_20_phan_tram():
assert tinh_gia_sau_giam(100_000, 20) == 80_000
def test_phan_tram_khong_hop_le():
with pytest.raises(ValueError):
tinh_gia_sau_giam(100_000, 150)
Khi test_giam_20_phan_tram fail vì hàm trả sai, pytest in ra kiểu:
assert 79999 == 80000
Bạn thấy ngay giá trị thực (79999) lệch so với kỳ vọng (80000) — không cần thêm dòng debug nào. pytest.raises là cách chuẩn để khẳng định "đoạn code này phải ném exception".
Fixture — trái tim của pytest
Fixture là hàm được đánh dấu @pytest.fixture, dùng để cung cấp dữ liệu hoặc tài nguyên cho test. Test chỉ cần khai báo tên fixture làm tham số, pytest sẽ tự "tiêm" (inject) vào:
import pytest@pytest.fixture
def gio_hang_mau():
return {"ao_thun": 2, "quan_jean": 1}
def test_tong_so_luong(gio_hang_mau):
assert sum(gio_hang_mau.values()) == 3
Fixture có thể dọn dẹp sau test bằng yield:
@pytest.fixture
def ket_noi_db():
conn = mo_ket_noi()
yield conn # trả conn cho test
conn.close() # chạy sau khi test xong, dù pass hay fail
Fixture có scope để kiểm soát vòng đời: function (mặc định, tạo lại mỗi test), class, module, session (tạo một lần cho cả phiên chạy). Dùng session cho tài nguyên đắt đỏ như kết nối DB dùng chung.
Đặt các fixture dùng chung trong file conftest.py — pytest tự nạp mà không cần import.
Parametrize — một test, nhiều trường hợp
Thay vì viết mười hàm test gần giống nhau, dùng @pytest.mark.parametrize:
@pytest.mark.parametrize("gia, phan_tram, ket_qua", [
(100_000, 0, 100_000),
(100_000, 50, 50_000),
(100_000, 100, 0),
(99_000, 10, 89_100),
])
def test_giam_gia_nhieu_truong_hop(gia, phan_tram, ket_qua):
assert tinh_gia_sau_giam(gia, phan_tram) == ket_qua
pytest sẽ sinh ra 4 test riêng biệt. Nếu case thứ 3 fail, ba case kia vẫn báo pass — bạn biết chính xác trường hợp nào hỏng.
Mock — cô lập đơn vị đang test
Unit test đúng nghĩa phải cô lập đơn vị khỏi các phụ thuộc bên ngoài (mạng, DB, thời gian, API bên thứ ba). pytest-mock cung cấp fixture mocker gọn gàng:
def test_gui_email_xac_nhan(mocker):
mock_smtp = mocker.patch("order.smtp_client.send")
xac_nhan_don_hang(order_id=123)
mock_smtp.assert_called_once()
Ở đây ta thay thế lời gọi SMTP thật bằng một object giả, nên test không thực sự gửi email — chạy nhanh và không phụ thuộc mạng. (Lý thuyết sâu về các loại test double — stub, spy, fake — được dạy ở bài riêng; ở đây bạn chỉ cần nắm cách mock cơ bản để cô lập unit.)
Đo coverage
pytest --cov=src --cov-report=term-missing
Lệnh này in phần trăm dòng code được test chạm tới và liệt kê những dòng chưa được phủ. Coverage cao không đảm bảo test tốt, nhưng coverage thấp chắc chắn là dấu hiệu có vùng code chưa ai kiểm.
Tình huống thực tế
Ví dụ 1 — Startup fintech ở TP.HCM: bug làm tròn tiền
Một công ty ví điện tử tại Quận 1 (tạm gọi PayNow) có hàm tính phí giao dịch: phí bằng 0,5% số tiền chuyển, tối thiểu 1.000đ, tối đa 5.000đ. Ban đầu team viết đúng một test cho trường hợp 1 triệu đồng (phí 5.000đ) và cho rằng "ổn rồi".
Sau khi lên production, khách chuyển 150.000đ bị tính phí 750đ — sai, vì phải tối thiểu 1.000đ. Bug lọt lưới vì chỉ có một test duy nhất. Team refactor lại bằng parametrize:
@pytest.mark.parametrize("so_tien, phi_ky_vong", [
(100_000, 1_000), # dưới sàn -> tối thiểu
(150_000, 1_000), # đúng ca bị bug
(400_000, 2_000), # 0,5% bình thường
(1_000_000, 5_000), # chạm trần
(2_000_000, 5_000), # trên trần -> tối đa
])
def test_phi_giao_dich(so_tien, phi_ky_vong):
assert tinh_phi(so_tien) == phi_ky_vong
Bài học: một unit test cho "happy path" là chưa đủ. Với logic có ngưỡng (boundary), phải phủ các điểm biên — dưới sàn, tại sàn, khoảng giữa, tại trần, trên trần. parametrize biến việc này thành 5 dòng thay vì 5 hàm.
Ví dụ 2 — Sàn TMĐT: test phụ thuộc thời gian gây flaky
Một team QA tại một sàn thương mại điện tử Đông Nam Á có hàm la_flash_sale() trả về True nếu thời điểm hiện tại nằm trong khung 12h–13h. Test của họ trực tiếp gọi datetime.now(), nên test chỉ pass khi chạy vào đúng giờ trưa — buổi sáng chạy CI thì fail, gây "flaky test" khiến pipeline đỏ liên tục.
Giải pháp là mock đồng hồ:
def test_dang_trong_flash_sale(mocker):
gio_gia = datetime(2026, 6, 27, 12, 30)
mocker.patch("promo.datetime").now.return_value = gio_gia
assert la_flash_sale() is True
Bài học: unit test phải tất định (deterministic) — cùng input luôn cho cùng kết quả, không phụ thuộc giờ chạy, múi giờ hay mạng. Mọi phụ thuộc vào now(), số ngẫu nhiên, hay API ngoài đều phải được mock. Đây là ranh giới then chốt phân biệt một unit test tốt với một test "may rủi".
Ví dụ 3 — Đội outsource: fixture cứu vãn code trùng lặp
Một team gia công phần mềm ở Đà Nẵng viết test cho module quản lý người dùng. Mỗi test đều lặp lại 6 dòng tạo user mẫu và kết nối SQLite in-memory. Sau 40 test, file dài hơn 800 dòng và mỗi lần đổi schema là sửa 40 chỗ.
Họ gom vào conftest.py:
conftest.py
@pytest.fixture
def db():
conn = tao_db_test() # SQLite in-memory
yield conn
conn.close()@pytest.fixture
def user_mau(db):
return tao_user(db, ten="Nguyễn Văn A", email="a@example.com")
Giờ mỗi test chỉ cần khai báo def test_xoa_user(db, user_mau): — pytest tự dựng DB, tạo user, dọn dẹp sau. File test giảm còn một phần ba, đổi schema chỉ sửa một nơi.
Bài học: fixture không chỉ để tiện, nó là công cụ chống trùng lặp và giảm chi phí bảo trì test — yếu tố sống còn khi test suite lớn dần.
Hướng dẫn từng bước
Ta dựng một project nhỏ có test hoàn chỉnh.
Bước 1 — Cấu trúc thư mục.
my_app/
├── src/
│ └── discount.py
├── tests/
│ ├── conftest.py
│ └── test_discount.py
└── pytest.ini
Bước 2 — Cài môi trường. Luôn dùng virtual environment để cô lập:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pytest pytest-cov pytest-mock
Bước 3 — Cấu hình pytest.ini để pytest biết thư mục test và bật một số mặc định:
[pytest]
testpaths = tests
addopts = -v --cov=src --cov-report=term-missing
Bước 4 — Viết code cần test (src/discount.py, như phần trên).
Bước 5 — Viết test trong tests/test_discount.py, kết hợp assert cơ bản, pytest.raises, và parametrize.
Bước 6 — Chạy và đọc kết quả.
pytest
Đọc dòng tổng kết 5 passed in 0.03s, kiểm tra cột coverage, xem những dòng còn thiếu trong term-missing và bổ sung test cho chúng.
Bước 7 — Chạy chọn lọc khi debug. Khi một test fail, dùng pytest tests/test_discount.py::test_giam_20_phan_tram -v để chạy đúng test đó, thêm -s để in ra print(), và --pdb để nhảy vào debugger ngay tại chỗ fail.
Bước 8 — Đưa vào thói quen. Chạy pytest trước mỗi lần commit. (Việc tự động chạy test trong pipeline được dạy ở các bài CI/CD riêng — ở đây bạn chỉ cần đảm bảo bộ test local luôn xanh.)
Lỗi thường gặp & mẹo
Đặt sai tên file/hàm. Nếu pytest báo "collected 0 items", 90% là vì file không bắt đầu bằng test_ hoặc hàm không bắt đầu bằng test_. Đây là lỗi phổ biến nhất của người mới.
Dùng assert x == True thay vì assert x. Viết assert la_flash_sale() is True khi cần khẳng định đúng kiểu bool là được, nhưng đừng viết assert len(ds) == True — hãy viết assert ds hoặc assert len(ds) == 3.
Test phụ thuộc thứ tự chạy. Nếu test_b chỉ pass khi test_a chạy trước (vì chia sẻ biến toàn cục), test của bạn đã sai thiết kế. Mỗi test phải độc lập. Dùng fixture để tạo trạng thái sạch cho từng test, đừng dùng biến module-level dùng chung.
Lạm dụng mock. Mock quá tay sẽ khiến test "xanh giả" — pass nhưng không kiểm tra hành vi thật. Chỉ mock ranh giới bên ngoài (DB, network, thời gian), đừng mock chính logic bạn đang muốn kiểm.
Quên is khi so sánh với None. Dùng assert ket_qua is None, không phải == None.
Mẹo — dùng marker để phân loại. Đánh dấu test chậm bằng @pytest.mark.slow rồi chạy pytest -m "not slow" khi cần vòng lặp nhanh.
Mẹo — -x dừng ngay khi fail đầu tiên, kết hợp --lf (last failed) để chỉ chạy lại những test vừa hỏng, tiết kiệm thời gian debug.
Mẹo — coverage là la bàn, không phải đích đến. Đừng chạy theo con số 100%. 80% coverage với những assert có ý nghĩa tốt hơn 100% với test chỉ gọi hàm mà không kiểm gì.
Bài tập thực hành
- Cơ bản. Viết hàm
chuyen_doi_diem_chu(diem: float) -> strtrả về xếp loại: >= 8.5 "Giỏi", >= 7.0 "Khá", >= 5.0 "Trung bình", còn lại "Yếu". Viết test dùngparametrizephủ đủ các điểm biên (8.5, 8.49, 7.0, 5.0, 4.99).
- Exception. Bổ sung: nếu
diemngoài khoảng 0–10 thì némValueError. Viết test dùngpytest.raisescho cả hai đầu (âm và >10).
- Fixture. Tạo
conftest.pyvới fixturegio_hang_rongtrả về giỏ hàng mới. Viết hàmthem_san_phamvàtong_tien, rồi viết ba test tái sử dụng fixture này.
- Mock. Viết hàm
lay_ty_gia_usd()gọi một API bên ngoài. Dùngmockerđể mock lời gọi đó trả về 25.400, sau đó test hàmdoi_usd_sang_vnd(100)trả về 2.540.000 — mà không thực sự gọi mạng.
- Coverage. Chạy
pytest --covtrên bài 1–4, xem dòng nào chưa được phủ và thêm test cho tới khi coverage của module đạt trên 90%.
Tóm tắt
pytest là lựa chọn mặc định cho unit testing trong Python nhờ cú pháp assert gọn, cơ chế fixture mạnh mẽ và hệ sinh thái plugin phong phú. Những điểm cốt lõi cần nhớ:
- Tuân thủ quy ước đặt tên
test_*.pyvà hàmtest_để pytest tự tìm test. - Dùng
assertthuần cho khẳng định,pytest.raisescho exception. - Fixture (kết hợp
yieldvàscope, đặt trongconftest.py) để chuẩn bị/dọn dẹp tài nguyên và chống trùng lặp. parametrizeđể phủ nhiều trường hợp, đặc biệt các điểm biên, chỉ với một hàm.- Mock mọi phụ thuộc bên ngoài (thời gian, mạng, DB) để test luôn nhanh, cô lập và tất định.
- Dùng
pytest-covnhư la bàn định hướng, không phải chỉ tiêu cứng.
now(), và fixture cứu team Đà Nẵng khỏi 800 dòng trùng lặp — đều minh họa cùng một chân lý: một unit test tốt phải đầy đủ về trường hợp, tất định về hành vi, và gọn về bảo trì. Nắm vững pytest ở tầng unit chính là nền móng vững chắc để bạn tiến lên các tầng cao hơn của kim tự tháp kiểm thử trong những bài tiếp theo.