Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Unit Testing — pytest cho Python

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à assert mặ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_.py hoặ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 Testkhông có method __init__.
Chạy toàn bộ test chỉ với một lệnh:

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_giam

def 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) -> str trả 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ùng parametrize phủ đủ các điểm biên (8.5, 8.49, 7.0, 5.0, 4.99).
  • Exception. Bổ sung: nếu diem ngoài khoảng 0–10 thì ném ValueError. Viết test dùng pytest.raises cho cả hai đầu (âm và >10).
  • Fixture. Tạo conftest.py với fixture gio_hang_rong trả về giỏ hàng mới. Viết hàm them_san_phamtong_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ùng mocker để mock lời gọi đó trả về 25.400, sau đó test hàm doi_usd_sang_vnd(100) trả về 2.540.000 — mà không thực sự gọi mạng.
  • Coverage. Chạy pytest --cov trê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_*.py và hàm test_ để pytest tự tìm test.
  • Dùng assert thuần cho khẳng định, pytest.raises cho exception.
  • Fixture (kết hợp yieldscope, đặt trong conftest.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-cov như la bàn định hướng, không phải chỉ tiêu cứng.
Ba tình huống ở trên — bug làm tròn phí của PayNow, test flaky vì phụ thuộc 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.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi