테스팅 (Testing)
테스팅 (Testing)
Starlette 덕분에 FastAPI 앱을 테스트하는 건 쉽고 즐거운 일이에요.
이 테스트는 HTTPX를 기반으로 하는데, HTTPX는 다시 Requests를 바탕으로 설계됐어요. 그래서 뭔가 굉장히 익숙하고 직관적으로 느껴져요.
그리고 이걸 쓰면 FastAPI와 함께 pytest를 바로 사용할 수 있어요.
TestClient 사용하기
Note
TestClient를 쓰려면 먼저 httpx를 설치해야 해요.
프로젝트에 추가해 주세요:
$ uv add httpx
TestClient를 임포트해요.
FastAPI 앱을 넘겨서 TestClient를 만들어요.
이름이 test_로 시작하는 함수를 만들어요 (이건 표준 pytest 규칙이에요).
TestClient 객체를 httpx를 쓸 때와 똑같은 방식으로 사용해요.
확인하고 싶은 내용을 표준 파이썬 표현식으로 assert 문을 간단히 작성해요 (역시 표준 pytest 방식이에요).
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/")
async def read_main():
return {"msg": "Hello World"}
client = TestClient(app)
def test_read_main():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
Tip
테스트 함수는 async def가 아니라 일반 def라는 점에 주목해 주세요.
클라이언트 호출도 await를 쓰지 않는 일반 호출이에요.
덕분에 복잡한 설정 없이 pytest를 바로 사용할 수 있어요.
Technical Details
from starlette.testclient import TestClient로 쓸 수도 있어요.
FastAPI는 개발자인 여러분을 위해 같은 starlette.testclient를 fastapi.testclient로도 제공할 뿐이에요. 하지만 실제로는 Starlette에서 그대로 가져온 거예요.
Tip
FastAPI 앱에 요청을 보내는 것 말고도 테스트 안에서 async 함수를 호출하고 싶다면 (예: 비동기 데이터베이스 함수), 고급 튜토리얼의 Async Tests를 살펴보세요.
테스트 분리하기
실제 애플리케이션에서는 테스트를 별도의 파일에 두는 게 자연스러워요.
그리고 FastAPI 애플리케이션도 여러 파일/모듈로 구성돼 있을 수 있고요.
FastAPI 앱 파일
Bigger Applications에서 설명한 것 같은 파일 구조를 갖고 있다고 해볼게요:
.
├── app
│ ├── __init__.py
│ └── main.py
main.py 파일에 FastAPI 앱이 들어 있어요:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_main():
return {"msg": "Hello World"}
테스트 파일
그러면 테스트가 담긴 test_main.py 파일을 만들면 돼요. 이 파일은 같은 파이썬 패키지 안(__init__.py 파일이 있는 같은 디렉터리)에 두면 돼요:
.
├── app
│ ├── __init__.py
│ ├── main.py
│ └── test_main.py
이 파일이 같은 패키지에 있으니, 상대 임포트를 써서 main 모듈(main.py)에서 app 객체를 가져올 수 있어요:
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_read_main():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
...그리고 테스트 코드는 앞에서 봤던 것과 똑같이 작성하면 돼요.
테스팅: 확장 예제
이제 이 예제를 확장해서, 애플리케이션의 여러 부분을 어떻게 테스트하는지 조금 더 자세히 살펴볼게요.
확장된 FastAPI 앱 파일
앞에서와 같은 파일 구조를 이어간다고 해볼게요:
.
├── app
│ ├── __init__.py
│ ├── main.py
│ └── test_main.py
이제 FastAPI 앱이 들어 있는 main.py 파일에 몇 가지 다른 **경로 연산(path operations)**이 있다고 가정해 볼게요.
오류를 반환할 수 있는 GET 연산이 하나 있어요.
여러 오류를 반환할 수 있는 POST 연산도 하나 있고요.
두 경로 연산 모두 X-Token 헤더를 요구해요.
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
fake_secret_token = "coneofsilence"
fake_db = {
"foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
"bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}
app = FastAPI()
class Item(BaseModel):
id: str
title: str
description: str | None = None
@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item_id not in fake_db:
raise HTTPException(status_code=404, detail="Item not found")
return fake_db[item_id]
@app.post("/items/")
async def create_item(item: Item, x_token: Annotated[str, Header()]) -> Item:
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item.id in fake_db:
raise HTTPException(status_code=409, detail="Item already exists")
fake_db[item.id] = item.model_dump()
return item
🤓 다른 버전과 변형
Tip
가능하면 Annotated 버전을 쓰는 걸 권장해요.
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
fake_secret_token = "coneofsilence"
fake_db = {
"foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
"bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}
app = FastAPI()
class Item(BaseModel):
id: str
title: str
description: str | None = None
@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item_id not in fake_db:
raise HTTPException(status_code=404, detail="Item not found")
return fake_db[item_id]
@app.post("/items/")
async def create_item(item: Item, x_token: str = Header()) -> Item:
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item.id in fake_db:
raise HTTPException(status_code=409, detail="Item already exists")
fake_db[item.id] = item.model_dump()
return item
확장된 테스트 파일
그러면 test_main.py를 확장된 테스트로 업데이트할 수 있어요:
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_read_item():
response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
assert response.status_code == 200
assert response.json() == {
"id": "foo",
"title": "Foo",
"description": "There goes my hero",
}
def test_read_item_bad_token():
response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
assert response.status_code == 400
assert response.json() == {"detail": "Invalid X-Token header"}
def test_read_nonexistent_item():
response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
assert response.status_code == 404
assert response.json() == {"detail": "Item not found"}
def test_create_item():
response = client.post(
"/items/",
headers={"X-Token": "coneofsilence"},
json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
)
assert response.status_code == 200
assert response.json() == {
"id": "foobar",
"title": "Foo Bar",
"description": "The Foo Barters",
}
def test_create_item_bad_token():
response = client.post(
"/items/",
headers={"X-Token": "hailhydra"},
json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid X-Token header"}
def test_create_existing_item():
response = client.post(
"/items/",
headers={"X-Token": "coneofsilence"},
json={
"id": "foo",
"title": "The Foo ID Stealers",
"description": "There goes my stealer",
},
)
assert response.status_code == 409
assert response.json() == {"detail": "Item already exists"}
🤓 다른 버전과 변형
Tip
가능하면 Annotated 버전을 쓰는 걸 권장해요.
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_read_item():
response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
assert response.status_code == 200
assert response.json() == {
"id": "foo",
"title": "Foo",
"description": "There goes my hero",
}
def test_read_item_bad_token():
response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
assert response.status_code == 400
assert response.json() == {"detail": "Invalid X-Token header"}
def test_read_nonexistent_item():
response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
assert response.status_code == 404
assert response.json() == {"detail": "Item not found"}
def test_create_item():
response = client.post(
"/items/",
headers={"X-Token": "coneofsilence"},
json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
)
assert response.status_code == 200
assert response.json() == {
"id": "foobar",
"title": "Foo Bar",
"description": "The Foo Barters",
}
def test_create_item_bad_token():
response = client.post(
"/items/",
headers={"X-Token": "hailhydra"},
json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid X-Token header"}
def test_create_existing_item():
response = client.post(
"/items/",
headers={"X-Token": "coneofsilence"},
json={
"id": "foo",
"title": "The Foo ID Stealers",
"description": "There goes my stealer",
},
)
assert response.status_code == 409
assert response.json() == {"detail": "Item already exists"}
요청에 정보를 넘기는 방법을 모르겠을 때는, httpx에서 (아니면 requests에서) 어떻게 하는지 검색해 보면 돼요. HTTPX는 Requests의 설계를 바탕으로 만들어졌으니까요.
그러면 테스트에서도 똑같이 하면 돼요.
예를 들어:
- 경로(path) 또는 쿼리(query) 파라미터를 넘기려면 URL 자체에 추가해요.
- JSON 본문을 넘기려면
json파라미터에 파이썬 객체(예:dict)를 넘겨요. - JSON 대신 Form Data를 보내야 한다면,
data파라미터를 쓰면 돼요. - 헤더를 넘기려면
headers파라미터에dict를 써요. - 쿠키는
cookies파라미터에dict를 넣어요.
백엔드로 데이터를 넘기는 방법(httpx나 TestClient 사용)에 대한 자세한 내용은 HTTPX 문서를 확인해 주세요.
Note
TestClient는 Pydantic 모델이 아니라 JSON으로 변환 가능한 데이터를 받는다는 점을 기억해 주세요.
테스트에 Pydantic 모델이 있고 그 데이터를 테스트 중 애플리케이션에 보내고 싶다면, JSON 호환 인코더에서 설명하는 jsonable_encoder를 사용하면 돼요.
실행하기
그 다음에는 pytest만 설치하면 돼요.
프로젝트에 추가해 주세요:
$ uv add pytest
그러면 pytest가 파일과 테스트를 자동으로 찾아서 실행하고, 결과를 알려줘요.
다음 명령으로 테스트를 실행해요:
$ uv run pytest
================ test session starts ================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 6 items
test_main.py ...... [100%]
================= 1 passed in 0.03s =================