FastAPI 교재 · 10편 · 테스트

FastAPI 테스트 — TestClient와 pytest

브라우저로 /docs 버튼을 일일이 눌러 확인하던 시대를 끝낸다. 코드 한 줄로 API 전체가 멀쩡한지 자동으로 검사한다.

초록색 체크 표시가 API 엔드포인트를 검증하고 자동 테스트 러너가 통과 표시를 확인하는 FastAPI 테스트 아이소메트릭 일러스트

8편에서 CRUD 한 벌을, 9편에서 JWT 인증까지 붙였다. 코드가 늘어나면서 슬슬 무서워지는 순간이 온다 — "이 한 줄 고쳤는데, 다른 데가 망가지진 않았을까?" 지금까지 우리가 확인하던 방법은 하나뿐이었다. 서버를 띄우고, 브라우저로 /docs 에 들어가, 엔드포인트마다 직접 버튼을 눌러 보는 것.

이번 편에서 그 수동 노동을 코드로 대체한다. TestClient 로 첫 테스트를 쓰고, pytest 로 깔끔하게 정리한 뒤, 실제 DB 를 건드리지 않고 테스트용 DB 를 끼워 넣는 의존성 오버라이드까지 간다. 끝나면 명령어 한 줄로 API 전체의 건강 검진이 돈다.

1. 왜 자동 테스트인가 — 손가락의 한계

엔드포인트가 셋일 때는 수동 확인이 그럭저럭 견딜 만하다. /docs 에 들어가 GET 한 번, POST 한 번 눌러 보면 끝이니까. 문제는 API 가 자라면서 시작된다. 엔드포인트가 스무 개가 되면, 작은 수정 하나에 스무 곳을 전부 다시 눌러 봐야 진짜 안전한지 알 수 있다. 그걸 매번 할 사람은 없다.

그래서 현실에서는 어떻게 되느냐 — 방금 고친 곳만 확인하고 나머지는 "괜찮겠지" 하고 넘긴다. 그러다 한 달 전에 잘 돌던 회원가입이 어느 순간 깨져 있는 걸 사용자가 먼저 발견한다. 이렇게 예전엔 멀쩡했는데 새 코드 때문에 망가지는 현상회귀(regression) 라 부른다. 자동 테스트의 첫 번째 존재 이유가 바로 이 회귀를 막는 것이다.

테스트는 "두 번째 사용자" 다 — 한 번 잘 적어 둔 테스트는 코드를 고칠 때마다 0.1초 만에 전체를 다시 눌러 보는 지치지 않는 검사원이 된다. 처음 테스트를 쓰는 데 5분이 들어도, 그 5분은 앞으로 수십 번 반복될 수동 클릭을 통째로 없애 준다. 테스트는 비용이 아니라 미래의 시간을 미리 사 두는 일이다.

2. TestClient — 첫 테스트 쓰기

FastAPI 는 테스트 도구를 기본으로 들고 있다. TestClient 는 진짜 서버를 띄우거나 포트를 열지 않고도, 마치 브라우저가 요청을 보낸 것처럼 앱을 직접 호출해 응답을 받아 주는 가짜 클라이언트다. 1편에서 만든 그 루트 엔드포인트를 테스트해 보자.

# main.py — 테스트할 대상 (1편 그대로) from fastapi import FastAPI app = FastAPI() @app.get("/") def read_root(): return {"message": "안녕, FastAPI!"}

이제 같은 폴더에 test_main.py 를 만들고 첫 테스트를 적는다. 핵심은 세 줄 — 클라이언트를 만들고, 요청을 보내고, 응답이 기대대로인지 assert 로 단언한다.

# test_main.py — 첫 테스트 from fastapi.testclient import TestClient from main import app client = TestClient(app) # app 을 감싼 가짜 클라이언트 def test_root(): response = client.get("/") # GET / 요청을 흉내 낸다 assert response.status_code == 200 # 200 OK 가 왔는가 assert response.json() == {"message": "안녕, FastAPI!"} # 본문이 맞는가

읽어 보면 거의 한국어다. client.get("/") 는 "/ 주소로 GET 요청을 보내라", assert 는 "이게 사실이어야 한다, 아니면 실패로 쳐라" 는 뜻이다. response.status_code 로 상태 코드를, response.json() 으로 본문을 꺼내 비교한다. /docs 에서 손으로 하던 일을 글로 옮긴 것뿐이다.

POST 요청도 똑같이 흉내 낼 수 있다. 보낼 JSON 은 json= 인자에 딕셔너리로 넘기면 된다.

def test_create_user(): response = client.post("/users", json={"name": "지민", "email": "[email protected]"}) assert response.status_code == 201 # 8편에서 정한 생성 코드 data = response.json() assert data["name"] == "지민" assert "id" in data # DB 가 id 를 부여했는가

3. pytest — 테스트를 굴리는 엔진과 fixture

위에서 쓴 test_ 로 시작하는 함수들, 누가 찾아서 실행해 줄까? 그 일을 하는 도구가 pytest 다. 파이썬 테스트의 사실상 표준이고, FastAPI 공식 문서도 이걸 쓴다. 설치는 한 줄이다.

$ pip install pytest httpx

httpx 도 함께 까는 이유는 TestClient 가 내부적으로 이 라이브러리로 요청을 만들기 때문이다. 이제 터미널에서 pytest 만 치면 끝이다. pytest 는 약속된 이름을 보고 테스트를 알아서 모은다 — 파일은 test_*.py, 함수는 test_*. 이 규칙만 지키면 등록 절차 없이 자동으로 수집된다.

$ pytest ========================= test session starts ========================= collected 2 items test_main.py .. [100%] ========================== 2 passed in 0.18s ==========================

초록색 점 하나가 통과한 테스트 하나다. 실패하면 그 자리에 F 가 찍히고, 어느 줄의 어떤 단언이 왜 깨졌는지까지 친절하게 출력된다. 손으로 눌러 보며 "어... 아까는 됐는데" 하던 막연함이 사라진다.

fixture — 반복되는 준비를 한곳에

테스트가 늘면 같은 준비 코드가 자꾸 반복된다. 매번 클라이언트를 만들거나, 테스트용 데이터를 미리 넣어 두는 일 말이다. pytest 의 fixture 는 이 공통 준비물을 함수로 묶어 두고, 필요한 테스트가 매개변수로 받아 쓰게 해 준다.

import pytest from fastapi.testclient import TestClient from main import app @pytest.fixture def client(): return TestClient(app) # 준비물을 만들어 돌려준다 def test_root(client): # 매개변수 이름 = fixture 이름 response = client.get("/") assert response.status_code == 200

마법처럼 보이지만 규칙은 단순하다 — 테스트 함수의 매개변수 이름이 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 다.

# conftest.py — 테스트용 SQLite 를 준비한다 import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient from main import app from database import get_db, Base # 파일이 아니라 메모리 위에 도는 테스트 전용 DB engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) TestSession = sessionmaker(bind=engine) def override_get_db(): db = TestSession() try: yield db # 진짜 get_db 와 모양이 똑같다 finally: db.close()

중요한 건 override_get_db 가 진짜 get_db똑같은 모양(세션을 yield 하고 끝나면 닫는다)이라는 점이다. 라우트 입장에서는 누가 세션을 주든 상관없다 — 세션이기만 하면 된다. 이제 fixture 에서 그 바꿔치기를 건다.

@pytest.fixture def client(): Base.metadata.create_all(bind=engine) # 빈 테이블을 새로 만들고 app.dependency_overrides[get_db] = override_get_db # get_db 를 가짜로 교체 yield TestClient(app) # 이 클라이언트로 테스트가 돈다 app.dependency_overrides.clear() # 끝나면 원래대로 되돌린다 Base.metadata.drop_all(bind=engine) # 테이블을 깨끗이 비운다

이 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("/") 로 응답 받기
pytesttest_* 를 모아 실행터미널에 pytest 한 줄
fixture공통 준비물을 주입@pytest.fixture + 같은 이름 매개변수
오버라이드진짜 DB → 테스트 DBapp.dependency_overrides[get_db]

요약

이번 편에서 수동 클릭을 자동 테스트로 바꿨다. TestClient(app) 로 서버 없이 요청을 흉내 내고, assert 로 상태 코드와 본문을 단언한다. pytesttest_*.py 안의 test_* 함수를 자동으로 모아 한 줄 명령으로 실행하며, @pytest.fixture 는 클라이언트 생성 같은 공통 준비를 한곳에 모아 매개변수로 주입한다. 가장 중요한 건 app.dependency_overrides[get_db] = override_get_db — 7~9편의 get_db 를 메모리 SQLite 로 바꿔치기해 진짜 DB 를 절대 건드리지 않고, 매 테스트를 깨끗한 상태에서 시작하게 만든다. 이제 코드를 고쳐도 회귀가 두렵지 않다.

코드가 검증됐으니, 마지막으로 남은 일은 이걸 세상에 내보내는 것이다.

다음 편 예고 — 11편: 배포, Docker 로 패키징

내 노트북에서만 돌던 FastAPI 를 Dockerfile 로 묶어 어디서든 똑같이 띄운다. 컨테이너 한 줄로 배포까지.

© 2026 주나이테크(주) @JUNAITECH