Testing ด้วย TestClient และ pytest
supertest / Jest → TestClient + pytest
หัวข้อที่มีชื่อว่า “supertest / Jest → TestClient + pytest”ใน Node คุณใช้ supertest ทำ HTTP requests กับ Express หรือ NestJS app ใน process และ Jest เป็น test runner ใน FastAPI คุณใช้ TestClient (จาก Starlette, bundled กับ FastAPI) สำหรับ in-process HTTP requests และ pytest เป็น test runner
// Jest + supertest — app.test.tsimport request from 'supertest';import { app } from '../src/app';
describe('GET /items/:id', () => { it('returns an item', async () => { const res = await request(app).get('/items/1'); expect(res.status).toBe(200); expect(res.body.id).toBe(1); });
it('returns 404 for unknown id', async () => { const res = await request(app).get('/items/9999'); expect(res.status).toBe(404); });});# pytest + TestClient — test_items.pyfrom fastapi.testclient import TestClientfrom app.main import app
client = TestClient(app)
def test_get_item(): response = client.get("/items/1") assert response.status_code == 200 assert response.json()["id"] == 1
def test_get_item_not_found(): response = client.get("/items/9999") assert response.status_code == 404Testing POST พร้อม request body
หัวข้อที่มีชื่อว่า “Testing POST พร้อม request body”// supertest POSTit('creates an item', async () => { const res = await request(app) .post('/items') .send({ name: 'Widget', price: 9.99 }) .set('Content-Type', 'application/json');
expect(res.status).toBe(201); expect(res.body.name).toBe('Widget');});
it('rejects invalid input', async () => { const res = await request(app) .post('/items') .send({ name: '', price: -1 }); expect(res.status).toBe(422);});# TestClient POSTdef test_create_item(): response = client.post( "/items", json={"name": "Widget", "price": 9.99}, ) assert response.status_code == 201 assert response.json()["name"] == "Widget"
def test_create_item_invalid(): response = client.post( "/items", json={"name": "", "price": -1}, ) assert response.status_code == 422Override dependencies ใน tests
หัวข้อที่มีชื่อว่า “Override dependencies ใน tests”pattern การ testing ที่สำคัญใน FastAPI คือ app.dependency_overrides ซึ่งแทนที่ dependencies จริง (เช่น database session) ด้วย test doubles เทียบเท่ากับ jest.mock() หรือ overrideProvider() ของ NestJS
// NestJS — override provider ใน testsconst moduleRef = await Test.createTestingModule({ providers: [ItemsService],}).overrideProvider(DatabaseService).useValue({ findById: jest.fn().mockResolvedValue({ id: 1 }) }).compile();# FastAPI — dependency_overridesfrom fastapi.testclient import TestClientfrom app.main import appfrom app.database import get_db
# Fake DB session สำหรับ testsdef override_get_db(): yield FakeDBSession()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
def test_get_item_with_fake_db(): response = client.get("/items/1") assert response.status_code == 200
# Restore หลัง tests (หรือใช้ pytest fixture)app.dependency_overrides.clear()pytest fixtures สำหรับ setup/teardown
หัวข้อที่มีชื่อว่า “pytest fixtures สำหรับ setup/teardown”@pytest.fixture ของ pytest เทียบเท่ากับ beforeEach / afterEach ของ Jest ไฟล์ conftest.py แชร์ fixtures ข้าม test files เหมือน Jest global setup file
// Jest beforeEach / afterEachlet client: TestClient;
beforeAll(async () => { const app = await NestFactory.create(AppModule); await app.init(); client = app.getHttpServer();});
afterAll(async () => { await app.close(); });# pytest — conftest.py fixturesimport pytestfrom fastapi.testclient import TestClientfrom app.main import appfrom app.database import Base, engine, get_dbfrom sqlalchemy import create_enginefrom sqlalchemy.orm import sessionmaker
TEST_DB_URL = "sqlite:///./test.db"test_engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})TestSession = sessionmaker(bind=test_engine)
@pytest.fixture(scope="module")def client(): Base.metadata.create_all(bind=test_engine) def override_db(): db = TestSession() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_db with TestClient(app) as c: yield c Base.metadata.drop_all(bind=test_engine)ทดสอบ async route: httpx AsyncClient
หัวข้อที่มีชื่อว่า “ทดสอบ async route: httpx AsyncClient”TestClient รัน async handler บน event loop ของตัวเอง ซึ่งเพียงพอสำหรับ test ส่วนใหญ่ แต่เมื่อตัว test เองต้องเป็น async เพราะต้อง await DB fixture หรือสั่งงาน background task ให้ขับ app ด้วย AsyncClient ของ httpx ผ่าน ASGITransport นี่คือวิธีมาตรฐานปัจจุบันสำหรับ async test ของ FastAPI และไม่ต้องรัน server จริง
// Node — supertest ค่อนข้าง sync; ถ้า setup เป็น async ก็ await เองimport request from 'supertest';
test('GET /items', async () => { await seedDatabase(); const res = await request(app).get('/items'); expect(res.status).toBe(200);});# pytest + httpx AsyncClient — test_items_async.pyimport pytestfrom httpx import ASGITransport, AsyncClientfrom app.main import app
@pytest.mark.anyioasync def test_list_items(seeded_db): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/items")
assert response.status_code == 200 assert response.json() == []
# anyio ต้องระบุ backend; กำหนดครั้งเดียวใน conftest.py:@pytest.fixturedef anyio_backend(): return "asyncio"pytest.mark.anyio มากับ plugin anyio ที่ติดมากับ test dependencies ของ Starlette ส่วนอีกทางที่นิยมคือ plugin pytest-asyncio ที่ใช้ @pytest.mark.asyncio ไม่ว่าทางไหนคุณต้องบอก pytest ว่า test function เป็น coroutine ต่างจาก Jest ตรงที่ pytest เปล่า ๆ จะเก็บ coroutine ไว้เฉย ๆ โดยไม่ await
รันที่เครื่องตัวเอง — ต้องการ FastAPI + httpx + pytest ติดตั้ง:
uv add --dev pytest pytest-cov httpx anyio(หรือpip installชุดเดียวกัน) Playground ข้ามในบทเรียนนี้: TestClient ต้องการ FastAPI app จริงและ HTTP lifecycle