명시적인 것이 암시적인 것보다 낫다: Pytest Fixtures와 Async Testing 마스터링
Source: Dev.to
문제: 이벤트 루프가 혼란스럽다
async Python에 대해 알아야 할 점은 async/await 코드를 실행하려면 이벤트 루프가 필요하다는 것입니다. 선택 사항이 아닙니다. 그리고 사용 가능한 이벤트 루프 구현이 여러 개 있습니다:
- asyncio – 파이썬 내장 표준 라이브러리 솔루션
- trio – 구조화된 동시성을 제공하는 서드파티 라이브러리
- curio – 또 다른 서드파티 옵션(요즘은 덜 사용됨)
pytest로 async 코드를 테스트할 때는 이러한 이벤트 루프를 관리해줄 무언가가 필요합니다. 여기서 등장하는 것이 pytest-anyio – 여러분의 삶을 편하게 해줄 것이라 기대되는 플러그인입니다. 스포일러: 항상 그렇지는 않습니다.
실제로 Pytest Fixtures란?
async 혼란에 뛰어들기 전에, fixtures에 대해 이야기해봅시다. pytest를 사용해본 적이 있다면 아마 보았을 겁니다. 아직 이해하지 못했다면, 혼자가 아닙니다.
Fixtures는 테스트를 위해 필요한 것을 설정해 주는 함수입니다. 테스트 전에 실행되고, 테스트에 필요한 것을 제공하며, 이후에 정리합니다. 방 안의 책임감 있는 어른이라고 생각하면 됩니다.
의존성 주입 (좋은 의미의)
def test_something(database_connection):
# pytest가 자동으로 연결을 제공해 줍니다
result = database_connection.query("SELECT * FROM users")
assert result is not None
수동 설정이 없습니다. 전역 변수도 없습니다. 필요한 것을 선언하면 pytest가 처리합니다.
보일러플레이트 없이 Setup과 Teardown
import pytest
@pytest.fixture
async def create_test_tables():
# Setup: 테스트 전에 실행됩니다
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield # 여기서 테스트가 실행됩니다
# Teardown: 테스트 후에 실행됩니다
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
yield 이전은 모두 setup, 이후는 모두 cleanup. 매번 깨끗한 데이터베이스를 얻습니다.
Scope: 모든 것이 매번 실행될 필요는 없으니까
function(기본) – 각 테스트마다 실행class– 테스트 클래스당 한 번 실행module– 테스트 파일당 한 번 실행session– 전체 테스트 스위트당 한 번 실행
올바른 scope를 선택하면 성능에 큰 차이가 납니다.
anyio Backend 문제
pytest-anyio를 사용해 async 코드를 테스트하면 어떤 이벤트 루프를 사용할지 알아야 합니다. 기본적으로는 사용 가능한 모든 백엔드에 대해 테스트하려고 합니다.
실제로 일어나는 일:
- FastAPI가 사용하듯
asyncio를 사용해 테스트를 작성합니다. - async 코드를 테스트하기 위해
pytest-anyio를 설치합니다. - 다른 프로젝트에서
trio가 설치돼 있으면pytest-anyio가 이를 감지합니다. trio로 테스트를 실행하려고 시도합니다.aiosqlite드라이버가asyncio전용이라 폭발합니다.
ModuleNotFoundError: No module named 'trio' 혹은 RuntimeError: no running event loop 같은 오류가 발생하고, 커리어에 회의를 품게 됩니다.
해결책: 명시적으로 지정하기
pytest-anyio에게 추측하게 두지 마세요. 정확히 원하는 것을 알려줍니다.
1단계: anyio_backend Fixture 만들기
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def anyio_backend():
"""
모든 async 테스트에 asyncio를 사용합니다. 그게 전부입니다.
"""
return "asyncio"
이 fixture는 테스트 세션당 한 번 실행되며 pytest-anyio에 “asyncio를 사용해라. 영리하게 굴지 마라”고 알려줍니다.
2단계: trio가 방해되지 않도록 하기
trio가 필요 없으면 제거합니다:
pip uninstall trio
3단계: Async 테스트에 올바르게 마크하기
import pytest
@pytest.mark.anyio(backend='asyncio')
async def test_create_and_get_example(client, create_test_tables):
# 테스트 코드 작성
pass
backend='asyncio' 파라미터는 fixture가 있으면 중복이지만, 의도를 명확히 하는 데 도움이 됩니다.
실제 예시: FastAPI와 SQLModel 테스트
데이터베이스 설정 Fixture
# tests/conftest.py
import pytest
from sqlmodel import SQLModel
from tests.test_db import engine
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
@pytest.fixture
async def create_test_tables():
"""
각 테스트 전 테이블을 만들고, 후에 삭제합니다.
각 테스트는 깨끗한 데이터베이스를 사용합니다.
"""
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
테스트 클라이언트 Fixture
# tests/features/test_examples_api.py
import pytest
from fastapi.testclient import TestClient
from src.app import app
from src.features.examples.api import get_example_service
from src.features.examples.service import ExampleService
from tests.test_db import TestingSessionLocal
async def override_get_example_service():
"""테스트 데이터베이스를 사용하도록 의존성을 오버라이드"""
async with TestingSessionLocal() as session:
yield ExampleService(session=session)
app.dependency_overrides[get_example_service] = override_get_example_service
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
yield c
실제 테스트
import pytest
from fastapi.testclient import TestClient
@pytest.mark.anyio(backend='asyncio')
async def test_create_and_get_example(client: TestClient, create_test_tables):
# 예제 생성
create_response = client.post(
"/api/v1/examples/",
json={"name": "Test Example", "description": "A test description"},
)
assert create_response.status_code == 200
created_example = create_response.json()
assert created_example["name"] == "Test Example"
assert "id" in created_example
# 다시 조회
example_id = created_example["id"]
get_response = client.get(f"/api/v1/examples/{example_id}")
assert get_response.status_code == 200
fetched_example = get_response.json()
assert fetched_example["id"] == example_id
assert fetched_example["name"] == "Test Example"
테스트가 client와 create_test_tables라는 필요 요소만 선언하면, pytest가 나머지를 자동으로 처리합니다. 깨끗한 데이터베이스, 테스트 클라이언트, 모두 자동으로 설정·해제됩니다.
우리가 해결한 내용 (문제 해결 스토리)
처음 설정했을 때, 테스트가 trio 오류로 실패했습니다. 문제와 해결 방법은 다음과 같습니다:
- 문제 1:
pytest-anyio가 trio를 사용하려고 했음. - 해결:
"asyncio"를 반환하는anyio_backendfixture를 추가하고, 불필요한trio의존성을 제거했습니다.
(필요에 따라 추가적인 문제와 해결 방법을 여기에 적을 수 있습니다.)