Tại sao phải dùng “đồ thật” thay vì Mock?
Đã bao giờ bạn gặp cảnh test pass 100% ở local nhưng vừa deploy lên staging là lỗi SQL văng tứ tung chưa? Mình từng nếm trái đắng này khi quá lạm dụng Mock. Chúng ta thường Mock database, Mock Redis để test chạy nhanh hơn. Tuy nhiên, Mock thường quá “ngoan hiền” và không phản ánh đúng sự khắc nghiệt của môi trường thực tế.
Lấy ví dụ: Một câu query dùng tính năng JSONB của Postgres hay Window Functions phức tạp. Mock sẽ không bao giờ báo cho bạn biết bạn đang viết sai cú pháp. Chỉ khi đối mặt với một con Postgres thật, lỗi mới lộ diện. Trong một dự án mình từng tham gia, việc chuyển từ Mock sang Test thực tế đã giúp giảm 40% số bug liên quan đến Database ngay từ khâu CI.
Testcontainers xuất hiện để giải quyết bài toán này. Nó giúp bạn nhấc nguyên một container Postgres, Redis, hay Kafka vào môi trường test chỉ với vài dòng code Python.
Quick Start: Chạy Test với Postgres thật trong 5 phút
Trước tiên, máy bạn cần cài sẵn Docker. Đừng lo về việc phải docker pull thủ công, Testcontainers sẽ lo hết. Hãy cài đặt các thư viện sau:
pip install pytest testcontainers sqlalchemy psycopg2-binary
Đây là cách bạn khởi tạo một database “xịn” ngay trong test case:
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
def test_postgres_connection():
# Khởi tạo container Postgres 15
with PostgresContainer("postgres:15") as postgres:
# Lấy connection string động (port sẽ được map ngẫu nhiên để tránh xung đột)
engine = create_engine(postgres.get_connection_url())
with engine.connect() as conn:
result = conn.execute("SELECT 1").fetchone()
assert result[0] == 1
print("\nKết nối Postgres thành công!")
Khi gõ lệnh pytest, Testcontainers sẽ tự động kéo image từ Docker Hub. Nó dựng container lên, chạy test và tự dọn dẹp sạch sẽ sau khi xong việc. Lần đầu có thể mất 30 giây để tải image, nhưng các lần sau sẽ cực kỳ nhanh.
Nâng tầm với Pytest Fixtures
Dùng block with như trên khá tiện cho các script nhỏ. Tuy nhiên, nếu bạn có 50 file test mà file nào cũng dựng container mới thì sẽ rất chậm. Để tối ưu, chúng ta sẽ dùng Pytest Fixtures với scope="session".
Cấu trúc file conftest.py
Hãy gom các thiết lập hạ tầng vào file conftest.py để dùng chung cho toàn bộ dự án:
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_db():
# Container chỉ khởi tạo 1 lần duy nhất cho cả quá trình chạy test
postgres = PostgresContainer("postgres:15")
postgres.start()
yield postgres
postgres.stop() # Dọn dẹp sau khi tất cả test đã chạy xong
Bây giờ, việc viết test trở nên cực kỳ nhàn nhã. Bạn chỉ cần truyền postgres_db vào tham số của hàm:
def test_create_user(postgres_db):
db_url = postgres_db.get_connection_url()
# Thực hiện logic lưu user vào DB thật ở đây
assert "postgres" in db_url
Kết hợp Database và Redis (Full Stack Test)
Với các app FastAPI hiện đại, việc kết hợp Postgres để lưu trữ và Redis để làm cache là chuyện cơm bữa. Testcontainers hỗ trợ mix nhiều loại container cùng lúc rất mượt mà.
from testcontainers.redis import RedisContainer
import redis
@pytest.fixture(scope="session")
def redis_client():
with RedisContainer("redis:7") as container:
host = container.get_container_host_ip()
port = container.get_exposed_port(6379)
client = redis.Redis(host=host, port=port)
yield client
def test_cache_logic(redis_client):
redis_client.set("session_key", "active")
assert redis_client.get("session_key") == b"active"
Khi chạy trên GitHub Actions, bạn chỉ cần đảm bảo Runner hỗ trợ Docker. Môi trường test lúc này sẽ giống hệt Production, giúp loại bỏ hoàn toàn tình trạng lỗi lạ chỉ xuất hiện trên server.
Kinh nghiệm thực chiến: Đừng để test chạy rùa bò
Khi số lượng test case tăng từ 10 lên 1000, tốc độ là vấn đề sống còn. Dưới đây là vài mẹo mình rút ra sau nhiều dự án:
- Ưu tiên Session Scope: Luôn dùng chung container cho cả đợt test. Việc khởi động lại Postgres cho mỗi hàm test có thể khiến thời gian chạy tăng lên gấp 10 lần.
- Chiến thuật dọn dẹp (Cleanup): Vì dùng chung DB, dữ liệu từ test case trước có thể làm hỏng test case sau. Hãy dùng fixture để
TRUNCATEcác table sau mỗi hàm test thay vì xóa cả container. - Cấu hình Wait Strategy: Đôi khi container báo
runningnhưng DB bên trong vẫn đang khởi tạo. Hãy dùngwait_forđể đảm bảo database thực sự sẵn sàng nhận kết nối. - Debug qua Log: Nếu test fail khó hiểu, hãy dùng
container.get_logs(). Đôi khi lỗi nằm ở việc DB bị giới hạn RAM hoặc config sai encoding mà Mock không bao giờ chỉ ra được.
Chuyển sang Testcontainers có thể khiến bộ test tốn thêm vài phút khởi động. Thế nhưng, sự an tâm khi biết code của mình đã chạy thực tế với Database là điều vô giá. Bạn sẽ không còn phải bối rối giải thích: “Ơ, máy em chạy ngon mà?” mỗi khi có bug production nữa.

