비동기 테스트

비동기 테스트 (Async Tests)

FastAPI 애플리케이션을 제공되는 TestClient로 테스트하는 방법은 이미 봤어요. 지금까지는 async 함수를 쓰지 않는 동기(synchronous) 테스트만 봤죠.

테스트에서 비동기 함수를 쓸 수 있으면 유용할 때가 있어요. 예를 들어 데이터베이스를 비동기로 조회할 때 말이에요. FastAPI 애플리케이션에 요청을 보내고, 그다음에 백엔드가 데이터베이스에 올바른 데이터를 성공적으로 썼는지 확인하고 싶다고 상상해 보세요. 비동기 데이터베이스 라이브러리를 쓰면서요.

이걸 어떻게 동작시키는지 살펴볼게요.

출처: 공식문서

pytest.mark.anyio

테스트에서 비동기 함수를 호출하려면, 테스트 함수 자체가 비동기여야 해요. AnyIO가 이걸 위한 깔끔한 플러그인을 제공해요. 이 플러그인으로 특정 테스트 함수들을 비동기로 호출하도록 지정할 수 있어요.

HTTPX

FastAPI 애플리케이션이 async def 대신 일반 def 함수를 사용하더라도, 이 애플리케이션은 밑에서는 여전히 async 애플리케이션이에요.

TestClient는 내부적으로 마법을 부려서, 표준 pytest를 쓰는 일반 def 테스트 함수 안에서 비동기 FastAPI 애플리케이션을 호출해 줘요. 하지만 그 마법은 비동기 함수 안에서 사용할 때는 더 이상 동작하지 않아요. 테스트를 비동기로 실행하면, 테스트 함수 안에서 더 이상 TestClient를 사용할 수 없는 거예요.

TestClientHTTPX를 기반으로 해요. 다행히 HTTPX를 직접 사용해서 API를 테스트할 수 있어요.

예시

간단한 예시로, Bigger ApplicationsTesting에서 설명한 것과 비슷한 파일 구조를 생각해 볼게요.

.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py

main.py 파일은 이렇게 생겼을 거예요.

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Tomato"}

test_main.py 파일은 main.py의 테스트를 담고 있어요. 이제 이렇게 생겼을 수 있죠.

import pytest
from httpx import ASGITransport, AsyncClient

from .main import app


@pytest.mark.anyio
async def test_root():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        response = await ac.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Tomato"}

실행하기

평소처럼 테스트를 실행하면 돼요.

$ uv run pytest
---> 100%

자세히 보기

@pytest.mark.anyio 마커는 pytest에게 이 테스트 함수를 비동기로 호출하라고 알려줘요.

import pytest
from httpx import ASGITransport, AsyncClient

from .main import app


@pytest.mark.anyio
async def test_root():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        response = await ac.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Tomato"}

테스트 함수가 TestClient를 쓸 때처럼 그냥 def가 아니라 이제 async def라는 점에 주목하세요.

그다음에 AsyncClient를 앱과 함께 만들고, await를 사용해서 비동기 요청을 보낼 수 있어요.

import pytest
from httpx import ASGITransport, AsyncClient

from .main import app


@pytest.mark.anyio
async def test_root():
    async with AsyncClient(
        transport=ASGITransport(app=app), base_url="http://test"
    ) as ac:
        response = await ac.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Tomato"}

이건 우리가 TestClient로 요청할 때 쓰던 것과 같은 의미예요.

response = client.get('/')

AsyncClient로 async/await를 쓰고 있다는 점에 주목하세요. 요청이 비동기예요.

경고

애플리케이션이 lifespan 이벤트에 의존한다면, AsyncClient는 그 이벤트들을 트리거하지 않아요. 이벤트들이 확실히 실행되게 하려면 florimondmanca/asgi-lifespanLifespanManager를 사용하세요.

다른 비동기 함수 호출하기

테스트 함수가 이제 비동기이므로, FastAPI 애플리케이션에 요청을 보내는 것 외에 다른 async 함수들도 테스트 안에서 호출하고(await) 할 수 있어요. 코드의 다른 곳에서 호출하는 것과 똑같이요.

테스트에 비동기 함수 호출을 통합할 때 RuntimeError: Task attached to a different loop를 만나면(예: MongoDB의 MotorClient를 쓸 때), 이벤트 루프가 필요한 객체들은 비동기 함수 안에서만 생성해야 한다는 점을 기억하세요. 예를 들어 @app.on_event("startup") 콜백 안에서요.

더 알아보기 (Learn more)

이 문서는 FastAPI 공식 문서 - Async Tests를 한국어로 정리한 번역이에요. 원문에서 최신 내용과 더 다양한 예시를 확인하세요.