← Lập trình Python nâng cao

Bài 6 · Nâng cao · 24 phút· Cập nhật 11/06/2026

Unit test với pytest

Biên soạn bởi Nguyễn Anh Tuấn

Viết unit test Python với pytest: assert, fixture, parametrize, khuôn Arrange · Act · Assert - phủ ca happy/edge/error để tự tin sửa code.

Unit test không phải để “khoe code đúng”, mà để cho bạn tự tin sửa: đổi gì cũng chạy test, xanh hết là yên tâm. Xem một bug bị test bắt - bấm giữa bản LỖI và bản SỬA:

Phiên bản hàm:
def la_so_nguyen_to(n):
    # BUG: quen chan n < 2
    return all(n % i for i in range(2, n))
la_so_nguyen_to(n)camong đợithực tế
n = 0biênFalseTrue✗ FAIL
n = 1biênFalseTrue✗ FAIL
n = 2thườngTrueTrue
n = 3thườngTrueTrue
n = 4thườngFalseFalse
n = 9thườngFalseFalse
n = 11thườngTrueTrue
5/7 test PASS - ĐỎ ở ca biên (n < 2): vòng range(2, n) rỗng nên hàm trả True nhầm. Unit test đã bắt được bug.
  • Test = lưới an toàn để SỬA code về sau mà không sợ hỏng ngầm.
  • Ca BIÊN (n < 2) là nơi bug hay nấp - test phủ chúng mới có giá trị.
  • Mỗi test theo AAA: Arrange · Act · Assert.

test_calc.py

from calc import giam_gia          # ham can kiem (o calc.py)

def test_giam_gia_thuong():
    gia, phan_tram = 100, 10        # Arrange
    kq = giam_gia(gia, phan_tram)   # Act
    assert kq == 90                 # Assert

chạy test

uv run --with pytest pytest -q
# .                                          [100%]
# 1 passed in 0.01s
  • pytest tự TÌM file test_*.py & hàm test_*; assert thường + báo lỗi rõ hai vế.
  • Chạy: uv run --with pytest pytest -q. Thêm vào dự án: uv add --dev pytest.
  • -q gọn, -v chi tiết, -s cho hiện print.

test_param.py

import pytest
from calc import giam_gia

@pytest.mark.parametrize("gia, pt, kq", [
    (100, 10, 90),
    (200, 50, 100),
    (100, 0, 100),
], ids=["thuong", "nua-gia", "khong-giam"])   # ten ro cho tung ca
def test_giam_gia(gia, pt, kq):
    assert giam_gia(gia, pt) == kq

def test_so_thuc():
    assert 0.1 + 0.2 == pytest.approx(0.3)     # KHONG dung == truc tiep cho float

Kết quả khi chạy

....                                       [100%]
4 passed in 0.01s
  • Một test chạy NHIỀU bộ dữ liệu; ids đặt tên dễ đọc cho từng ca khi báo lỗi.
  • float có sai số → dùng pytest.approx thay cho == để khỏi fail oan.
  • Thêm ca = thêm một dòng dữ liệu.

fixture chuẩn bị “nguyên liệu”; dùng yield để có cả dọn dẹp (teardown) sau test:

test_fixture.py

import pytest

@pytest.fixture                  # scope mac dinh = "function" (moi test mot lan)
def tai_nguyen():
    r = {"con": "mo"}            # setup (truoc yield)
    yield r                      # giao cho test
    r["con"] = "dong"           # TEARDOWN (sau yield) - chay du test pass/fail

def test_dung(tai_nguyen):
    assert tai_nguyen["con"] == "mo"

@pytest.fixture(scope="module")  # tao MOT lan cho ca file
def ket_noi_nang():
    ...

Kết quả khi chạy

.                                          [100%]
1 passed in 0.01s
  • yield: trước = setup, sau = teardown (dọn dẹp), chạy kể cả khi test fail.
  • scope: "function" (mỗi test) / "module" / "session" - rộng hơn = tạo ít lần hơn, nhanh hơn.
  • Đặt fixture dùng chung vào conftest.py → mọi test trong thư mục tự dùng (khỏi import). tmp_path là fixture sẵn có.

test_chia.py

import pytest
from calc import chia

def test_happy(): assert chia(6, 2) == 3
def test_edge():  assert chia(0, 5) == 0
def test_error():
    with pytest.raises(ValueError):     # mong doi nem ValueError
        chia(1, 0)

@pytest.mark.skip(reason="chua lam tinh nang nay")
def test_chua_lam(): ...

@pytest.mark.xfail(reason="bug da biet, chua sua")
def test_bug_da_biet(): assert chia(7, 0) == 0

Kết quả khi chạy

...sx                                      [100%]
3 passed, 1 skipped, 1 xfailed in 0.01s
  • Happy (thường), Edge (biên: 0/rỗng/lớn), Error (đầu vào sai → pytest.raises).
  • @pytest.mark.skip: bỏ qua; @pytest.mark.xfail: cho phép fail (bug đã biết) - không làm đỏ bộ test.
  • Đừng chỉ test happy path - bug thật phần lớn ở edge & error.

Khi bộ test lớn, chạy đúng phần cần và đo xem code được test tới đâu:

terminal

pytest -k "giam_gia"     # chi chay test co ten chua "giam_gia"
pytest -x                # dung NGAY khi gap test fail dau tien
pytest --lf              # chi chay lai cac test FAIL lan truoc (last-failed)

# Do do phu (coverage):
uv run --with pytest --with pytest-cov pytest --cov=calc
# ... TOTAL   42   6   86%   <- 86% dong code duoc test cham
  • -k lọc theo tên, -x dừng ở fail đầu, --lf chạy lại test vừa fail - vòng lặp sửa nhanh.
  • pytest-cov: đo % dòng code được test chạm; chỉ ra dòng CHƯA test.
  • Độ phủ cao KHÔNG bằng test tốt: phủ nhiều mà assert yếu vẫn lọt bug - phủ + assert ý nghĩa.

Tiếp theo

Unit test kiểm từng mảnh cô lập. Nhưng phần mềm thật là nhiều mảnh GHÉP với nhau - bài kế tiếp: integration test, kiểm chỗ tiếp giáp (tệp, cơ sở dữ liệu, HTTP) với test doubles.

Câu hỏi thường gặp

Unit test kiểm MỘT đơn vị nhỏ (thường một hàm) tự động. Lợi lớn nhất là TỰ TIN SỬA về sau: đổi code rồi chạy test, xanh hết thì biết chưa làm hỏng thứ cũ. Nó cũng là tài liệu sống về cách hàm nên cư xử, kể cả ở ca biên.

Khuôn 3 bước cho mỗi test: ARRANGE - chuẩn bị đầu vào; ACT - gọi thứ cần kiểm; ASSERT - khẳng định kết quả. Tách rõ giúp test dễ đọc và mỗi test chỉ kiểm MỘT điều.

Vì float có sai số làm tròn: 0.1 + 0.2 != 0.3 (nhớ khoá cơ bản). assert 0.1+0.2 == 0.3 sẽ FAIL oan. Dùng assert 0.1+0.2 == pytest.approx(0.3) để so sánh "gần bằng" trong dung sai hợp lý - đúng cách kiểm float.

Bài Kiểu int, float, bool & str - Python cơ bản →

Fixture dùng yield thay vì return thì phần TRƯỚC yield là setup, phần SAU yield là TEARDOWN (dọn dẹp) - chạy kể cả khi test fail. scope quyết định fixture tạo lại bao lâu một lần: "function" (mặc định, mỗi test), "module", "session"… Scope rộng (vd session) tạo một lần, dùng lại - nhanh nhưng phải cẩn thận trạng thái dùng chung.

Là nơi đặt fixture (và cấu hình) DÙNG CHUNG cho mọi test trong thư mục - pytest tự tìm, không cần import. Vd một fixture dbconftest.py thì mọi test_*.py trong thư mục đều khai tên db để dùng. Giúp khỏi lặp phần chuẩn bị.

@pytest.mark.skip: BỎ QUA test (vd tính năng chưa làm, môi trường thiếu). @pytest.mark.xfail: test ĐƯỢC PHÉP FAIL vì lỗi đã biết - nếu fail thì tính "xfailed" (không làm đỏ bộ test); nếu bất ngờ PASS thì báo "xpassed". Dùng xfail để theo dõi bug chưa sửa mà không che giấu nó.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Lợi ích lớn nhất của unit test, theo bài học, là gì?

  1. 1

    Đỏ rồi xanh

    Dùng widget Bước 1: gõ lại la_so_nguyen_to bản LỖI, viết test cho n=0,1 (thấy ĐỎ), rồi sửa cho XANH.

    Hoàn thành khi: Test bắt bug ca biên (đỏ); sau khi chặn n<2 thì mọi test xanh.

  2. 2

    parametrize + approx

    Viết một test parametrizeids; thêm một ca so sánh float bằng pytest.approx.

    Hoàn thành khi: Nhiều ca trong một test (tên ids rõ); ca float dùng approx, không fail oan.

  3. 3

    fixture teardown

    Viết fixture dùng yield: tạo một tài nguyên trước yield, dọn sau yield. In log để thấy teardown chạy sau test.

    Hoàn thành khi: Thứ tự: setup → test → teardown; teardown chạy kể cả khi test fail.

  4. 4

    pytest.raises + xfail

    Viết test cho chia(1,0) raise ValueError bằng pytest.raises; đánh dấu một bug đã biết bằng @pytest.mark.xfail.

    Hoàn thành khi: Ca error pass nhờ pytest.raises; ca xfail hiện "xfailed" thay vì làm đỏ bộ test.

  5. 5

    Chạy chọn lọc & độ phủ

    Chạy pytest -k "ten_test", pytest -x, và đo coverage: uv run --with pytest --with pytest-cov pytest --cov=module.

    Hoàn thành khi: Bạn chạy được tập con test và xem báo cáo % độ phủ; biết dòng nào chưa được test chạm.