Cách Kiểm Thử Giúp Phát Triển API Vững Chắc: Hướng Dẫn Toàn Diện về Kiểm Thử Tự Động với FastAPI × pytest × TestClient
✅ Tóm tắt nhanh (Mô hình Kim Tự Tháp Ngược)
- Bạn có thể làm gì với bài viết này
Triển khai kiểm thử tự động trong ứng dụng FastAPI và xây dựng một quy trình end-to-end áp dụng thực tế: kiểm thử đơn vị, kiểm thử tích hợp, ghi đè phụ thuộc, chuẩn bị dữ liệu kiểm thử với DB, và xác thực endpoint bất đồng bộ. - Chủ đề chính
- Cài đặt cơ bản và best practices cho
pytest
- Kiểm thử đồng bộ với
fastapi.testclient.TestClient
- Kiểm thử bất đồng bộ với
httpx.AsyncClient
+pytest-asyncio
- Ghi đè phụ thuộc (DB, auth) và sử dụng SQLite chỉ dành cho test
- Quản lý dữ liệu kiểm thử tái sử dụng với fixtures
- Đo lường coverage và tips tích hợp CI
- Cài đặt cơ bản và best practices cho
- Lợi ích bạn nhận được
- Quy trình phát triển nơi bạn không sợ thay đổi (ngăn ngừa regression)
- Specs được ghi lại trong test, hoạt động như tài liệu sống
- Tái tạo bug nhanh → viết test → sửa → xác nhận vòng lặp regression-proof
🎯 Ai nên đọc?
- Nhà phát triển A (Sinh viên năm 3)
Ứng dụng FastAPI đã phát triển lớn hơn, nhưng lo ngại mỗi thay đổi có thể phá vỡ hệ thống. Muốn xây dựng nền tảng test tối thiểu để vừa học vừa cải thiện chất lượng. - Nhóm nhỏ B (công ty agency 3 người)
Thay đổi spec thường xuyên, thỏa thuận thường chỉ nói miệng gây hiểu nhầm. Muốn chuyển thỏa thuận thành test và có tiêu chuẩn review rõ ràng hơn. - Nhà phát triển SaaS C (Startup)
Tính năng được thêm mỗi sprint. Sự cố production đáng sợ, nên muốn có test tự động chạy trên mỗi PR và cải thiện độ tin cậy khi deploy.
♿ Đánh giá và cân nhắc về khả năng truy cập
- Dễ đọc: câu ngắn, bullet points, cân bằng thuật ngữ.
- Cấu trúc: tóm tắt theo chương, dễ cho screen reader theo dõi.
- Code: khối code cố định, có chú thích rõ ràng, tránh dòng dài, tên biến dễ phân biệt.
- Đối tượng: thân thiện cho người mới bắt đầu, có tips nâng cao (DB override, async, coverage).
- Mục tiêu: hướng đến khả năng truy cập mức AA, định nghĩa thuật ngữ quan trọng ngay lần xuất hiện đầu tiên.
1. Chuẩn bị: Ứng dụng tối thiểu & Thư mục test
1.1 Ứng dụng mẫu (Todo)
Mọi ví dụ test sẽ dùng một API CRUD Todo tối thiểu. Bạn có thể copy cấu trúc cho project của mình.
fastapi-testing/
├─ app/
│ ├─ main.py
│ ├─ db.py
│ ├─ models.py
│ └─ schemas.py
└─ tests/
├─ conftest.py
└─ test_todos.py
1.2 Cài đặt dependencies
python3 -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn sqlalchemy pydantic pytest
# Cho test async và HTTP client (khuyến nghị)
pip install httpx pytest-asyncio
# Cho đo lường coverage (khuyến nghị)
pip install pytest-cov
Thuật ngữ
- pytest: test runner phổ biến nhất trong Python. Tự động nhận dạng file test_*.py
- TestClient: client sync đi kèm FastAPI. Đơn giản và nhanh.
- httpx.AsyncClient: HTTP client async. Gần hơn với kiểm thử endpoint async thực tế.
2. Cấu trúc Ứng dụng (Setup tối thiểu)
2.1 app/db.py
(Sync session)
# app/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "sqlite:///./app.db" # Production thì đổi qua env vars
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
2.2 app/models.py
(SQLAlchemy 2.x style)
# app/models.py
from datetime import datetime
from sqlalchemy import String, DateTime, func, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
naming_convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=naming_convention)
class Todo(Base):
__tablename__ = "todos"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
is_done: Mapped[bool] = mapped_column(default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
2.3 app/schemas.py
(Pydantic)
# app/schemas.py
from datetime import datetime
from pydantic import BaseModel
class TodoBase(BaseModel):
title: str
is_done: bool = False
class TodoCreate(TodoBase):
pass
class Todo(TodoBase):
id: int
created_at: datetime
class Config:
from_attributes = True # Pydantic v2 (tương đương orm_mode=True ở v1)
2.4 app/main.py
(FastAPI Core)
# app/main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db import SessionLocal, engine
from app.models import Base, Todo
from app.schemas import Todo, TodoCreate
from typing import List
Base.metadata.create_all(bind=engine) # Production nên dùng Alembic
app = FastAPI(title="Todos API")
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.post("/todos", response_model=Todo, status_code=201)
def create_todo(payload: TodoCreate, db: Session = Depends(get_db)):
todo = Todo(**payload.dict())
db.add(todo)
db.commit()
db.refresh(todo)
return todo
@app.get("/todos", response_model=List[Todo])
def list_todos(db: Session = Depends(get_db)):
return db.query(Todo).order_by(Todo.id.asc()).all()
@app.get("/todos/{todo_id}", response_model=Todo)
def get_todo(todo_id: int, db: Session = Depends(get_db)):
todo = db.get(Todo, todo_id)
if not todo:
raise HTTPException(404, "Todo not found")
return todo
@app.put("/todos/{todo_id}", response_model=Todo)
def update_todo(todo_id: int, payload: TodoCreate, db: Session = Depends(get_db)):
todo = db.get(Todo, todo_id)
if not todo:
raise HTTPException(404, "Todo not found")
for k, v in payload.dict().items():
setattr(todo, k, v)
db.commit()
db.refresh(todo)
return todo
@app.delete("/todos/{todo_id}", status_code=204)
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
todo = db.get(Todo, todo_id)
if not todo:
raise HTTPException(404, "Todo not found")
db.delete(todo)
db.commit()
return None
Tóm tắt
- Thống nhất dùng code đồng bộ để đơn giản, async sẽ trình bày sau.
- Trong test, dependency overrides sẽ chuyển DB sang test DB.
📌 Kết luận (Tiến hóa mà không phá vỡ)
- Với pytest + TestClient, bạn có con đường nhanh nhất để tạo niềm tin, còn httpx cho phép kiểm thử async thực tế.
- Dependency overrides và SQLite chỉ dành cho test giúp test an toàn, không ảnh hưởng DB production.
- Rollback transaction và factories ổn định dữ liệu, tạo ra test đáng tin cậy, không flakey.
- Với coverage + CI, test sẽ luôn chạy, làm cho việc deploy ít căng thẳng hơn nhiều.
Ban đầu viết test có thể hơi đáng sợ — nhưng bắt đầu nhỏ sẽ làm trải nghiệm phát triển hàng ngày mượt mà hơn. Bước tiếp theo là viết chỉ một test đầu tiên cho project của bạn. Từ đó, cả chất lượng và sự tự tin sẽ tự nhiên tăng lên.