Bài 7 · Nâng cao · 24 phút· Cập nhật 11/06/2026
Integration test với pytest
Biên soạn bởi Nguyễn Anh Tuấn
Integration test trong Python: kiểm chỗ ghép DB/tệp/HTTP bằng pytest - round-trip, mock & monkeypatch, fixture và tháp kiểm thử.
Unit test kiểm một mảnh; integration test kiểm các mảnh ghép với nhau ở chỗ tiếp giáp (seam). Một bug “quên trường khi lưu” không sai trong từng hàm, nhưng round-trip lưu→đọc sẽ phát hiện. Đổi serializer giữa ĐÚNG và LỖI:
Task(id=1, tieu_de="Hoc Python", xong=True)
Dữ liệu gốc trong bộ nhớ.
{"id":1,"tieu_de":"Hoc Python","xong":true} Chuyển object thành str JSON để ghi ra ngoài.
p.write_text(json); s = p.read_text()
Đi qua hệ thống tệp thật (thư mục tạm của test) - đây là "chỗ tiếp giáp".
Task(id=1, tieu_de="Hoc Python", xong=True)
Dựng lại object từ JSON đã đọc.
✓ PASS - khớp bản gốc
Round-trip toàn vẹn: lưu rồi đọc lại đúng y nguyên.
- ▸Integration test kiểm seam: model ↔ lưu trữ ↔ tệp/CSDL/HTTP.
- ▸Round-trip (khứ hồi): lưu → đọc lại → phải bằng bản gốc.
- ▸Bug ở chỗ tiếp giáp thường lọt khỏi unit test từng mảnh.
test_roundtrip.py
from store import luu_json, tai_json # cap luu/doc cua ban
def test_roundtrip(tmp_path):
goc = {"id": 1, "tieu_de": "Hoc Python", "xong": True}
f = tmp_path / "task.json"
luu_json(goc, f) # serialize + ghi file
lai = tai_json(f) # doc file + deserialize
assert lai == goc # khu hoi phai khop Kết quả khi chạy
. [100%] 1 passed in 0.01s
- ▸Một test kiểm CẢ đường lưu lẫn đường đọc - bắt lệch giữa hai bên.
- ▸tmp_path đi qua hệ thống tệp THẬT (thư mục tạm) nhưng tự dọn.
- ▸Bỏ sót một trường khi lưu → lai != goc → test đỏ ngay.
Dùng SQLite thật nhưng giữ mỗi test độc lập: chạy trong một transaction rồi rollback ở teardown → DB sạch như chưa có gì:
conftest.py + test_db.py
# conftest.py
import sqlite3, pytest
@pytest.fixture(scope="session") # tao schema MOT lan
def con():
c = sqlite3.connect(":memory:")
c.execute("CREATE TABLE tasks(id INTEGER, tieu_de TEXT)")
yield c
c.close()
@pytest.fixture # MOI test: transaction roi rollback
def db(con):
con.execute("BEGIN")
yield con
con.rollback() # bo moi thay doi -> test sau thay DB sach
# test_db.py
def test_them(db):
db.execute("INSERT INTO tasks VALUES (?, ?)", (1, "Hoc"))
(ten,) = db.execute("SELECT tieu_de FROM tasks WHERE id=?", (1,)).fetchone()
assert ten == "Hoc"
def test_rong(db):
assert db.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0 # khong thay du lieu test truoc Kết quả khi chạy
.. [100%] 2 passed in 0.02s
- ▸Fixture scope="session": tạo schema/kết nối MỘT lần (nhanh).
- ▸Fixture function: BEGIN → yield → rollback → mỗi test bắt đầu với DB SẠCH, độc lập.
- ▸Vẫn truy vấn THAM SỐ HOÁ (?, ...) - thói quen chống SQL injection.
Để kiểm phần ghép mà không kéo theo cả hệ thống, ta dùng test double - "đóng thế" cho thành phần thật. Ba kiểu hay gặp:
| Kiểu | Là gì | Kiểm cái gì |
|---|---|---|
| Stub | Trả sẵn dữ liệu cố định (không logic) | kết quả với đầu vào cho sẵn |
| Fake | Bản cài đơn giản chạy được (sqlite :memory:, dict) | hành vi gần thật, nhanh |
| Mock | Ghi lại lời gọi | TƯƠNG TÁC: được gọi đúng tham số/số lần |
mock_vs_fake.py
from unittest.mock import MagicMock
# MOCK: kiem "co goi dung khong"
gui = MagicMock()
gui("an@x.com", "hi")
gui.assert_called_once_with("an@x.com", "hi") # kiem TUONG TAC
# FAKE: ban cai don gian, kiem KET QUA
class HopThuFake:
def __init__(self): self.da_gui = []
def gui(self, to, body): self.da_gui.append((to, body))
ht = HopThuFake(); ht.gui("an@x.com", "hi")
assert ht.da_gui == [("an@x.com", "hi")] - ▸Stub (dữ liệu sẵn) → Fake (cài đơn giản) → Mock (kiểm lời gọi): chọn loại NHẸ nhất đủ dùng.
- ▸Mock kiểm TƯƠNG TÁC (assert_called_with); Fake kiểm KẾT QUẢ.
- ▸Với DB, ưu tiên FAKE (sqlite tạm) hơn mock toàn bộ - sát thật hơn.
Test KHÔNG nên gọi thật ra mạng (chậm, giòn, tốn phí, tác dụng phụ). Dùng monkeypatch thay client/hàm mạng bằng bản giả:
test_net.py
import gia_service # co ham lay_gia() goi API that
def tong_tien(so_luong):
return so_luong * gia_service.lay_gia()
def test_tong_tien(monkeypatch):
# Thay lay_gia bang ban gia -> KHONG goi internet
monkeypatch.setattr(gia_service, "lay_gia", lambda: 100)
assert tong_tien(3) == 300
def test_doc_env(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key") # thay bien moi truong tam thoi
... Kết quả khi chạy
.. [100%] 2 passed in 0.01s
- ▸monkeypatch.setattr thay hàm/đối tượng; .setenv thay biến môi trường - tự khôi phục sau test.
- ▸Cô lập ở RANH GIỚI (client mạng) → LOGIC của bạn vẫn chạy thật.
- ▸Test mạng-thật để dành cho một ít e2e có chủ đích, không phải mọi test.
| Đỉnh · End-to-end | RẤT ÍT - chạy cả app (vd CLI black-box); chậm & giòn nhưng giống thật nhất. |
| Giữa · Integration | ÍT HƠN - seam: round-trip, DB tạm, test double ở ranh giới. |
| Đáy · Unit | NHIỀU - nhanh, rẻ, phủ logic & ca biên. |
- ▸Nhiều unit (nhanh) → ít integration → rất ít e2e (chậm/giòn).
- ▸E2E CLI black-box: chạy chương trình thật, kiểm đầu vào → đầu ra.
- ▸Tháp cân bằng = bộ test vừa nhanh chạy vừa đủ tin cậy.
Tiếp theo
Câu hỏi thường gặp
Unit test kiểm MỘT mảnh cô lập (một hàm). Integration test kiểm các mảnh GHÉP với nhau ở chỗ tiếp giáp (seam): model ↔ lớp lưu trữ ↔ tệp/CSDL/HTTP. Có những bug chỉ lộ khi đi trọn đường - vd “quên một trường khi lưu” đúng trong từng hàm nhưng round-trip lưu→đọc thì khác bản gốc.
Hai cách phổ biến: (1) tạo DB MỚI mỗi test (vd sqlite :memory:) - đơn giản nhất; (2) một kết nối dùng chung (fixture scope="session") + mỗi test chạy trong một TRANSACTION rồi ROLLBACK ở teardown → DB sạch như chưa có gì, nhanh và độc lập. Cách (2) hợp khi tạo schema tốn kém.
Test double = "đóng thế" cho một thành phần thật trong test. STUB: trả sẵn dữ liệu cố định (không logic). FAKE: bản cài ĐƠN GIẢN nhưng chạy được (vd sqlite :memory: thay Postgres, dict thay cache). MOCK: ghi lại lời gọi để KIỂM nó được gọi đúng (assert đã gọi với tham số nào). Chọn loại nhẹ nhất đủ dùng.
Để THAY tạm một hàm/biến/biến môi trường trong phạm vi một test (tự khôi phục sau test). Vd monkeypatch.setattr(module, "lay_gia", lambda: 100) để khỏi gọi API thật; monkeypatch.setenv("API_KEY", "test"). Đây là cách cài fake/stub gọn nhất trong pytest.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Integration test tập trung kiểm cái gì khác với unit test?
- 1
Bắt bug ở seam
Dùng widget Bước 1: đổi serializer sang bản LỖI (quên
"xong"); vì sao unit test cho riêng hàm lưu/đọc có thể không bắt được?Hoàn thành khi: Mỗi hàm "tự nó" chạy được; lỗi chỉ lộ khi GHÉP lưu→đọc và so với bản gốc.
- 2
Round-trip test
Viết test dùng
tmp_path: tạoTask,luu_jsonrồitai_json→ khẳng định bằng bản gốc.Hoàn thành khi: Pass khi serializer đúng; cố tình bỏ một trường thì FAIL.
- 3
DB cô lập bằng rollback
Viết fixture: kết nối sqlite (
session), mỗi testBEGINtransaction rồiROLLBACKở teardown. Kiểm hai test không thấy dữ liệu của nhau.Hoàn thành khi: Mỗi test bắt đầu với DB sạch; thêm dữ liệu ở test này không lọt sang test kia.
- 4
Fake vs mock
Có hàm
gui_email(to, body)gọi dịch vụ thật. Viết test (a) dùng FAKE (lưu vào list) và (b) dùng MOCK (assert đã gọi đúng tham số).Hoàn thành khi: (a) kiểm KẾT QUẢ (email nằm trong list); (b) kiểm TƯƠNG TÁC (gọi 1 lần, đúng
to/body). Bạn phân biệt được hai kiểu. - 5
Cô lập mạng
Có
lay_gia()gọi API. Dùngmonkeypatchthay nó bằng hàm trả 100; test logictong_tiendùng nó.Hoàn thành khi: Test KHÔNG gọi internet, chạy nhanh & ổn định; bạn kiểm được logic quanh giá trị giả.