Testing with Fixtures
좋은 테스트는 assert 문 몇 줄이 아니라 fixture 경계 설계에서 갈린다. DB, FastAPI app, dependency override, TestClient, 외부 API stub을 어떻게 열고 닫는지 흐려지면 테스트는 금방 느리고 flaky하며 서로 간섭하기 시작한다.
빠른 요약: pytest에서는 `yield fixture`를 기본으로 삼고, teardown은 fixture 바로 아래에 둔다. app, DB, client, override를 분리해 fixture graph를 명확히 만들고, 테스트는 그 조합을 소비만 하게 만드는 편이 가장 오래 간다.
fixture graph를 먼저 본다
기본 규칙
- fixture는 자원을 만든다.
- 테스트 함수는 자원을 조합해 동작을 검증한다.
- teardown은
yield바로 아래에 둔다. - global mutable state를 테스트끼리 공유하지 않는다.
가장 읽기 좋은 패턴
from collections.abc import Generator
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def app() -> Generator[FastAPI, None, None]:
application = create_app()
application.dependency_overrides[get_settings] = lambda: TestSettings()
try:
yield application
finally:
application.dependency_overrides.clear()
@pytest.fixture
def client(app: FastAPI) -> Generator[TestClient, None, None]:
with TestClient(app) as test_client:
yield test_client중요한 점은 teardown이 fixture 정의 바로 옆에 있다는 것이다. `dependency_overrides.clear()` 같은 정리 코드를 test body 밖으로 빼두면 누가 자원을 닫는지 읽기가 쉬워진다.
DB fixture는 격리 전략을 명시해야 한다
작은 서비스 / 시작 단계
- function-scoped SQLite
- 테스트마다 schema 생성/삭제
- 느리더라도 단순해서 읽기 좋다
더 큰 서비스
- connection + transaction + rollback
- 필요하면 nested SAVEPOINT
- 테스트 속도와 격리를 동시에 노릴 수 있다
testcontainers를 쓸 때는 "container 수명"과 "테스트 격리"를 분리한다
testcontainers를 쓰기 시작하면 흔히 두 가지를 한 fixture에 섞는다.
- PostgreSQL container를 띄우는 일
- 각 테스트가 서로 안 섞이게 DB 상태를 초기화하는 일
이 둘은 성격이 다르다.
- container lifecycle은 보통 바깥 fixture다.
- schema 준비, seed, rollback, truncate는 안쪽 fixture다.
권장 기본형
- container는
sessionscope 또는modulescope - engine / session factory는 그 안쪽 fixture
- 테스트 격리는 function-scoped transaction rollback 또는 truncate/seed
컨테이너를 매 테스트마다 띄우면 격리는 단순하지만 대체로 너무 느리다. 반대로 container를 길게 공유하면서 DB reset 전략이 없으면 flaky test가 된다.
읽기 좋은 예
from collections.abc import Generator
import pytest
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_container() -> Generator[PostgresContainer, None, None]:
with PostgresContainer("postgres:17") as container:
yield container
@pytest.fixture(scope="session")
def engine(postgres_container: PostgresContainer) -> Generator[Engine, None, None]:
test_engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(test_engine)
try:
yield test_engine
finally:
Base.metadata.drop_all(test_engine)
test_engine.dispose()
@pytest.fixture
def db_session(engine: Engine) -> Generator[Session, None, None]:
connection = engine.connect()
transaction = connection.begin()
testing_session = sessionmaker(
bind=connection,
class_=Session,
autoflush=False,
expire_on_commit=False,
)()
try:
yield testing_session
finally:
testing_session.close()
transaction.rollback()
connection.close()포인트는 container가 가장 바깥 수명주기를 소유하고, function-scoped `db_session`이 각 테스트의 격리를 책임진다는 점이다. startup cost와 isolation concern을 한 fixture에 섞지 않는다.
FastAPI까지 붙이면 보통 이렇게 간다
postgres_container가 실제 Postgres를 띄운다.enginefixture가 schema 또는 migration을 준비한다.db_sessionfixture가 테스트 단위 rollback 경계를 잡는다.appfixture가dependency_overrides로 그 세션 또는 session factory를 주입한다.clientfixture가TestClient를 열고 닫는다.
언제 migration을 실제로 태우나
- repository/ORM query shape만 검증하면
metadata.create_all()로도 충분할 수 있다. - Alembic revision, index, constraint, DB-specific DDL까지 확인하고 싶다면 container 위에서 실제 migration을 올리는 편이 맞다.
즉, "실제 Postgres를 쓴다"와 "실제 migration 경로를 검증한다"도 별도 결정이다.
실무 팁
- container는 너무 안쪽 scope로 두지 않는다.
- 대신 테스트 격리는 transaction rollback, truncate, seed fixture에서 만든다.
- app override cleanup은 여전히
yield아래에서 정리한다. - startup이 무거운 container는 테스트 세트 전체에서 공유하고, 함수 단위 자원은 그 안에서 짧게 만든다.
- 비동기 스택이라면 async engine/session fixture를 따로 두고, container fixture만 공용으로 두는 편이 읽기 쉽다.
yield fixture가 기본인 이유
pytest 공식 문서도 teardown/finalization 기본 패턴으로 yield fixture를 먼저 권장한다. addfinalizer()는 teardown 대상을 동적으로 등록해야 할 때 유용하지만, 일상적인 서비스 테스트에서는 yield가 더 읽기 쉽고 안전하다.
ABC + Fake UoW 단위 테스트 패턴은 ABC + Fake UoW Testing에서 별도로 더 깊게 다룬다.
하지 않는 편이 좋은 것
- giant
autousefixture 하나에 모든 setup을 몰아넣는다. - 테스트 함수 안에서
dependency_overrides를 직접 만지고 정리하지 않는다. - session이나 client를 모듈 전역으로 재사용한다.
- fixture가 무엇을 cleanup하는지 숨긴다.
- 외부 API stub과 DB seed를 하나의 fixture에 섞는다.
- container startup과 test-level DB reset을 하나의 giant fixture에 욱여넣는다.
실전 테스트 레이아웃 예
tests/
conftest.py
integration/
test_users_api.py
unit/
test_user_service.pyconftest.py: 공용 fixtureintegration/: HTTP contract, DB, serializationunit/: 순수 service/domain logic
이 저장소의 예제
이 저장소에는 tests/test_fastapi_fixtures_and_teardown.py 파일을 추가했다. 다음 패턴을 보여준다.
- engine fixture 생성과 teardown
- seed fixture
dependency_overrides설치와 cleanupTestClientcontext manager teardown
실전 체크리스트
fixture가 자원 소유권을 가진다
누가 열고 누가 닫는지 fixture만 봐도 읽혀야 한다.
override cleanup이 있다
FastAPI dependency override는 반드시 fixture teardown에서 정리한다.
DB 격리 전략이 명시적이다
schema recreate인지 rollback인지 팀 기준이 있어야 flaky test가 줄어든다.
container scope와 reset 전략이 분리돼 있다
`testcontainers`를 쓴다면 container lifetime과 test-level isolation을 별도 fixture로 나눈다.
autouse는 최소화한다
숨은 fixture는 테스트 읽기를 어렵게 하므로 공통 인프라에만 제한적으로 쓴다.