FastAPI 테스트 — TestClient와 pytest
브라우저로 /docs 버튼을 일일이 눌러 확인하던 시대를 끝낸다. 코드 한 줄로 API 전체가 멀쩡한지 자동으로 검사한다.
8편에서 CRUD 한 벌을, 9편에서 JWT 인증까지 붙였다. 코드가 늘어나면서 슬슬 무서워지는 순간이 온다 — "이 한 줄 고쳤는데, 다른 데가 망가지진 않았을까?" 지금까지 우리가 확인하던 방법은 하나뿐이었다. 서버를 띄우고, 브라우저로 /docs 에 들어가, 엔드포인트마다 직접 버튼을 눌러 보는 것.
이번 편에서 그 수동 노동을 코드로 대체한다. TestClient 로 첫 테스트를 쓰고, pytest 로 깔끔하게 정리한 뒤, 실제 DB 를 건드리지 않고 테스트용 DB 를 끼워 넣는 의존성 오버라이드까지 간다. 끝나면 명령어 한 줄로 API 전체의 건강 검진이 돈다.
1. 왜 자동 테스트인가 — 손가락의 한계
엔드포인트가 셋일 때는 수동 확인이 그럭저럭 견딜 만하다. /docs 에 들어가 GET 한 번, POST 한 번 눌러 보면 끝이니까. 문제는 API 가 자라면서 시작된다. 엔드포인트가 스무 개가 되면, 작은 수정 하나에 스무 곳을 전부 다시 눌러 봐야 진짜 안전한지 알 수 있다. 그걸 매번 할 사람은 없다.
그래서 현실에서는 어떻게 되느냐 — 방금 고친 곳만 확인하고 나머지는 "괜찮겠지" 하고 넘긴다. 그러다 한 달 전에 잘 돌던 회원가입이 어느 순간 깨져 있는 걸 사용자가 먼저 발견한다. 이렇게 예전엔 멀쩡했는데 새 코드 때문에 망가지는 현상을 회귀(regression) 라 부른다. 자동 테스트의 첫 번째 존재 이유가 바로 이 회귀를 막는 것이다.
2. TestClient — 첫 테스트 쓰기
FastAPI 는 테스트 도구를 기본으로 들고 있다. TestClient 는 진짜 서버를 띄우거나 포트를 열지 않고도, 마치 브라우저가 요청을 보낸 것처럼 앱을 직접 호출해 응답을 받아 주는 가짜 클라이언트다. 1편에서 만든 그 루트 엔드포인트를 테스트해 보자.
이제 같은 폴더에 test_main.py 를 만들고 첫 테스트를 적는다. 핵심은 세 줄 — 클라이언트를 만들고, 요청을 보내고, 응답이 기대대로인지 assert 로 단언한다.
읽어 보면 거의 한국어다. client.get("/") 는 "/ 주소로 GET 요청을 보내라", assert 는 "이게 사실이어야 한다, 아니면 실패로 쳐라" 는 뜻이다. response.status_code 로 상태 코드를, response.json() 으로 본문을 꺼내 비교한다. /docs 에서 손으로 하던 일을 글로 옮긴 것뿐이다.
POST 요청도 똑같이 흉내 낼 수 있다. 보낼 JSON 은 json= 인자에 딕셔너리로 넘기면 된다.
3. pytest — 테스트를 굴리는 엔진과 fixture
위에서 쓴 test_ 로 시작하는 함수들, 누가 찾아서 실행해 줄까? 그 일을 하는 도구가 pytest 다. 파이썬 테스트의 사실상 표준이고, FastAPI 공식 문서도 이걸 쓴다. 설치는 한 줄이다.
httpx 도 함께 까는 이유는 TestClient 가 내부적으로 이 라이브러리로 요청을 만들기 때문이다. 이제 터미널에서 pytest 만 치면 끝이다. pytest 는 약속된 이름을 보고 테스트를 알아서 모은다 — 파일은 test_*.py, 함수는 test_*. 이 규칙만 지키면 등록 절차 없이 자동으로 수집된다.
초록색 점 하나가 통과한 테스트 하나다. 실패하면 그 자리에 F 가 찍히고, 어느 줄의 어떤 단언이 왜 깨졌는지까지 친절하게 출력된다. 손으로 눌러 보며 "어... 아까는 됐는데" 하던 막연함이 사라진다.
fixture — 반복되는 준비를 한곳에
테스트가 늘면 같은 준비 코드가 자꾸 반복된다. 매번 클라이언트를 만들거나, 테스트용 데이터를 미리 넣어 두는 일 말이다. pytest 의 fixture 는 이 공통 준비물을 함수로 묶어 두고, 필요한 테스트가 매개변수로 받아 쓰게 해 준다.
마법처럼 보이지만 규칙은 단순하다 — 테스트 함수의 매개변수 이름이 fixture 함수 이름과 같으면, pytest 가 그 fixture 를 먼저 실행해 결과를 끼워 넣어 준다. 이제 클라이언트 만드는 코드를 테스트마다 베껴 쓸 필요가 없다. 다음 절에서 이 fixture 가 진짜 위력을 발휘한다.
4. 의존성 오버라이드 — 진짜 DB 는 건드리지 않는다
여기 큰 함정이 하나 있다. 위의 test_create_user 를 그냥 돌리면 어떻게 될까 — 운영 중인 진짜 DB 에 "지민" 이 실제로 저장된다. 테스트를 돌릴 때마다 가짜 데이터가 쌓이고, 운이 나쁘면 진짜 데이터를 덮어쓴다. 테스트는 절대 진짜 DB 를 건드리면 안 된다.
해법은 7~9편에서 줄곧 써 온 Depends(get_db) 구조에 숨어 있다. 우리 라우트는 DB 세션을 직접 만들지 않고 get_db 라는 의존성에서 받아 썼다. FastAPI 는 테스트할 때 이 get_db 를 통째로 가짜로 바꿔치기하는 문을 열어 둔다. 그게 app.dependency_overrides 다.
중요한 건 override_get_db 가 진짜 get_db 와 똑같은 모양(세션을 yield 하고 끝나면 닫는다)이라는 점이다. 라우트 입장에서는 누가 세션을 주든 상관없다 — 세션이기만 하면 된다. 이제 fixture 에서 그 바꿔치기를 건다.
이 fixture 하나가 테스트의 안전을 통째로 책임진다. 흐름을 그림으로 보면 이렇다 — 테스트가 /users 에 POST 를 보내면, 라우트는 평소처럼 Depends(get_db) 로 세션을 요청한다. 그런데 우리가 dependency_overrides 로 길을 틀어 놨으니, FastAPI 는 진짜 get_db 대신 메모리 SQLite 를 주는 override_get_db 를 부른다. 라우트 코드는 한 줄도 안 고쳤는데, 데이터는 운영 DB 가 아니라 휘발성 메모리에만 들어간다.
app.dependency_overrides.clear() 를 빠뜨리면 오버라이드가 계속 남아 다음 테스트, 심하면 운영 코드에까지 가짜 DB 가 흘러든다. 위 fixture 처럼 yield 뒤에 정리(clear·drop)를 두면, 테스트마다 깨끗한 새 DB 로 시작하고 끝나면 자동으로 청소된다. 테스트끼리 데이터가 새는 일을 원천 차단하는 핵심 습관이다.
5. 정리 — 그리고 다음 편
한눈에 다시 보면 이 세 가지가 전부다.
| 도구 | 하는 일 | 핵심 한 줄 |
|---|---|---|
| TestClient | 서버 없이 요청을 흉내 | client.get("/") 로 응답 받기 |
| pytest | test_* 를 모아 실행 | 터미널에 pytest 한 줄 |
| fixture | 공통 준비물을 주입 | @pytest.fixture + 같은 이름 매개변수 |
| 오버라이드 | 진짜 DB → 테스트 DB | app.dependency_overrides[get_db] |
요약
이번 편에서 수동 클릭을 자동 테스트로 바꿨다. TestClient(app) 로 서버 없이 요청을 흉내 내고, assert 로 상태 코드와 본문을 단언한다. pytest 는 test_*.py 안의 test_* 함수를 자동으로 모아 한 줄 명령으로 실행하며, @pytest.fixture 는 클라이언트 생성 같은 공통 준비를 한곳에 모아 매개변수로 주입한다. 가장 중요한 건 app.dependency_overrides[get_db] = override_get_db — 7~9편의 get_db 를 메모리 SQLite 로 바꿔치기해 진짜 DB 를 절대 건드리지 않고, 매 테스트를 깨끗한 상태에서 시작하게 만든다. 이제 코드를 고쳐도 회귀가 두렵지 않다.
코드가 검증됐으니, 마지막으로 남은 일은 이걸 세상에 내보내는 것이다.
다음 편 예고 — 11편: 배포, Docker 로 패키징
내 노트북에서만 돌던 FastAPI 를 Dockerfile 로 묶어 어디서든 똑같이 띄운다. 컨테이너 한 줄로 배포까지.