목차
- 커버리지 80%를 달성했는데 버그는 왜 안 줄었나
- unittest에서 pytest로 전환한 진짜 이유
- fixture scope를 잘못 쓰면 오히려 독이다
- FastAPI 비동기 테스트와 DB 격리
- Django에 pytest-django 얹기
- mock은 최후의 수단이다
- Python pytest 테스트 자동화를 CI에 올리기
FAILED tests/test_user.py::TestUserAPI::test_create_user
sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) UNIQUE constraint failed: users.email
로컬에서는 38개 테스트가 전부 통과하는데 GitHub Actions에서만 랜덤하게 3~4개가 실패하는 현상이 있었다. unittest의 tearDown에서 DB 롤백을 빠뜨린 테스트 클래스 하나가 다른 테스트의 데이터를 오염시키고 있었고, 실행 순서에 따라 증상이 달라졌다. 이걸 계기로 unittest에서 pytest로 전환했다.
이 글은 "pytest 쓰면 테스트가 편해진다"는 단순한 이야기가 아니다. 커버리지 숫자를 올리는 것과 실제로 버그를 잡는 테스트를 만드는 건 전혀 다른 문제이고, 그 차이를 인식하기까지 팀에서 겪은 시행착오를 기록한 글이다.
커버리지 80%를 달성했는데 버그는 왜 안 줄었나
"테스트 커버리지 80% 이상 유지"라는 팀 목표가 정해진 건 올해 1월이었다. pytest-cov를 붙이고 리포트를 처음 뽑았을 때 수치는 이미 72%가 나왔다. __init__.py, config.py, models.py 같은 파일들이 단순 import만으로도 "실행된 코드"로 잡히고 있었기 때문이다. 나머지 8%를 채우기 위해 팀원들이 작성한 테스트를 열어보면 이런 코드가 상당수였다:
def test_user_model_exists():
# User 모델 클래스가 import 가능한지 확인
from app.models import User
assert User is not None
이런 테스트가 100개 있어도 버그를 잡아주진 않는다. 커버리지 수치만 올라갈 뿐이다. 실제로 2월에 장애가 터진 건 결제 로직에서 동시 요청이 들어왔을 때 재고 차감이 이중으로 발생하는 문제, 토큰 만료 후 리프레시 실패로 사용자가 로그아웃되는 문제였다. 해당 코드의 테스트는 둘 다 "나중에 추가" 상태로 남아 있었다.
커버리지는 "어디를 안 테스트했는지" 보여주는 도구이지, "테스트를 잘 하고 있는지" 측정하는 지표가 아니다. 이 구분을 못 하면 숫자를 채우는 데만 에너지를 쏟게 된다. pytest-cov v5.0.0(출처: pytest-cov PyPI)의 --cov-report=term-missing 옵션을 쓰면 커버되지 않은 라인 번호가 터미널에 출력되는데, 그 라인 목록을 보고 "여기는 테스트 안 해도 괜찮은 코드인가, 아니면 반드시 검증해야 하는 비즈니스 로직인가"를 판단하는 게 핵심이다.
unittest에서 pytest로 전환한 진짜 이유
pytest를 소개하는 글 대부분이 이런 비교를 보여준다. "unittest는 클래스와 self.assertEqual 필요, pytest는 함수와 assert만으로 OK." 틀린 말은 아닌데, 솔직히 이건 핵심이 아니다. assert가 편한 건 사실이지만, 그것만으로 테스트 프레임워크를 바꿀 이유는 안 된다.
unittest의 진짜 문제는 테스트 격리 메커니즘이 전적으로 개발자의 기억력에 의존한다는 점이다. setUp에서 DB 연결하고 tearDown에서 정리하는 코드를 모든 테스트 클래스에 반복 작성해야 한다. 테스트 클래스가 30개면 30곳에 같은 정리 로직이 들어간다. 하나라도 빠지면 테스트 간 상태 오염이 발생하고, 글 서두에서 언급한 IntegrityError가 정확히 이 케이스였다.
pytest의 fixture는 이 문제를 구조적으로 해결한다. yield 기반으로 setup과 teardown이 한 함수 안에 묶이니 정리 로직을 빠뜨릴 여지가 줄어든다. scope 파라미터로 생명주기를 선언하면 pytest가 알아서 관리한다. "매 테스트마다 새로 만들어라" 또는 "이 모듈 안에서 한 번만 만들어라" 같은 지시를 코드로 표현할 수 있는 거다.
하나 더 중요한 점이 있다. unittest에서 pytest로 전환할 때 기존 테스트를 전부 다시 쓸 필요가 없다. pytest는 unittest.TestCase 기반 테스트를 그대로 수집해서 실행한다. 우리 팀도 신규 테스트만 pytest 스타일로 작성하고, 기존 코드는 CI에서 문제가 생길 때 하나씩 옮기는 방식을 택했다. 3개월 정도 지나니 기존 unittest 비율이 자연스럽게 30% 아래로 내려갔다.
pytest 8.x(출처: pytest 공식 문서, 2026년 4월 기준)에서는 fixture의 타입 힌트 지원이 개선되어 PyCharm이나 VS Code에서 자동완성이 잘 동작한다. IDE 지원이 좋아지면 fixture를 찾기 위해 conftest.py를 뒤지는 시간이 줄어드는데, 이건 체감 차이가 꽤 크다.
fixture scope를 잘못 쓰면 오히려 독이다
fixture가 좋다는 건 알겠는데, scope 설계를 잘못하면 unittest보다 더 골치 아픈 상황을 만든다. 직접 겪어본 이야기다.
conftest.py를 처음 구성했을 때 DB 세션 fixture를 scope="session"으로 설정했다. 전체 테스트를 실행하는 동안 DB 연결을 한 번만 만들면 속도가 빠를 거라는 판단이었다. 결과는 참담했다. 테스트 A가 삽입한 데이터가 테스트 B에서 보이기 시작했고, 실행할 때마다 다른 테스트가 실패했다. pytest-randomly로 순서를 섞으면 재현 자체가 안 되는 유령 실패(flaky test)가 하루에 두세 번씩 CI를 중단시켰다. 이 문제를 추적하는 데 이틀을 썼다.
DB 세션 fixture는 scope="function"이 기본이어야 한다. 매 테스트마다 트랜잭션을 열고, 테스트 종료 시 롤백하는 패턴이 가장 안전하다:
# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
@pytest.fixture(scope="function")
def db_session():
# 매 테스트마다 새 엔진 + 테이블 생성
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
session.begin_nested() # SAVEPOINT 시작
yield session
session.rollback() # 테스트 끝나면 SAVEPOINT로 복구
session.close()
Base.metadata.drop_all(engine)
scope="session"이 적절한 경우도 있다. Redis 연결 풀이나 Elasticsearch 클라이언트처럼 읽기 전용이거나 상태를 공유해도 부작용이 없는 외부 리소스가 그렇다. 규칙은 단순하다: 쓰기 작업이 발생하는 리소스는 function scope, 읽기 전용 리소스는 session scope. 이 원칙만 지켜도 flaky test의 80%는 예방된다.
fixture 간 의존성 체인도 주의할 부분이다. fixture A가 fixture B를 주입받고, B가 다시 C를 주입받는 3단 체인이 만들어지면 어느 단계에서 문제가 생겼는지 추적이 어렵다. pytest --fixtures로 현재 사용 가능한 fixture 목록을 출력할 수 있는데, conftest.py가 200줄을 넘기면 구조를 재검토해야 한다는 신호로 보면 된다. 디렉토리별로 conftest.py를 분리하는 편이 낫다.
parametrize와 fixture를 섞을 때
@pytest.mark.parametrize로 입력값을 바꿔가며 같은 로직을 테스트하는 건 강력한 기능이다. 근데 fixture와 조합할 때 한 가지 함정이 있다. parametrize된 테스트에서 function-scope fixture를 쓰면, 파라미터 조합 수만큼 fixture가 생성·소멸된다. 파라미터가 20개이고 fixture에서 DB 테이블을 매번 새로 만든다면 테스트 실행 시간이 급격히 늘어난다. 이런 경우에는 fixture 내부에서 테이블 생성을 scope="module"로 올리고, 데이터 삽입/삭제만 function scope로 분리하는 구조가 현실적이다.
FastAPI 비동기 테스트와 DB 격리
FastAPI 테스트에서 가장 흔한 실수는 동기 TestClient로만 검증하는 거다. 실제 서비스가 async def 엔드포인트로 동작하면 테스트도 async여야 의미가 있다. 동기 TestClient에서는 통과했는데, 운영에서 await를 빠뜨린 코루틴이 coroutine object를 반환하며 500 에러를 내는 버그를 못 잡은 적이 있다.
httpx의 AsyncClient와 pytest-asyncio를 조합하면 async 엔드포인트를 제대로 테스트할 수 있다:
# tests/test_users_api.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.mark.asyncio
async def test_create_user(db_session):
# ASGITransport로 FastAPI 앱을 직접 연결
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/users", json={
"email": "test@example.com",
"name": "테스트유저"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
pytest-asyncio 0.24 이상(출처: pytest-asyncio GitHub)에서는 pyproject.toml에 mode = "auto"를 설정하면 @pytest.mark.asyncio 데코레이터를 생략할 수 있다. 다만 auto 모드는 동기 테스트 함수도 코루틴인지 검사하려는 동작이 있어서, 동기·비동기 테스트가 섞여 있는 프로젝트에서는 mode = "strict"가 더 예측 가능하다. 우리 팀은 strict 모드를 쓰고, async 테스트에만 데코레이터를 명시적으로 붙이는 쪽을 택했다.
DB 격리는 앞서 설명한 function-scope fixture 패턴을 그대로 쓰면 된다. FastAPI의 Depends로 주입되는 DB 세션을 테스트에서 오버라이드하는 게 포인트인데, app.dependency_overrides[get_db] = lambda: db_session 한 줄이면 된다. 이건 FastAPI 공식 문서에도 잘 나와 있어서 별도로 다루지 않겠다.
Django에 pytest-django 얹기
Django 프로젝트에 pytest를 도입한다면 pytest-django 플러그인은 거의 필수다. @pytest.mark.django_db 데코레이터 하나로 테스트에서 DB 접근이 가능해지고, Django의 TestCase가 제공하던 트랜잭션 롤백도 자동으로 처리된다.
근데 pytest-django가 안 해주는 게 있다. Django 시그널(post_save, pre_delete 등)은 테스트 중에도 발동한다. 예를 들어 User 생성 테스트에서 post_save 시그널이 이메일 발송 함수를 트리거하면, 테스트할 때마다 실제 이메일 로직이 실행된다. 이건 pytest-django가 막아주지 않으니 직접 처리해야 한다. factory_boy 같은 팩토리 라이브러리에서 @factory.django.mute_signals(post_save) 데코레이터를 제공하는데, 이걸 쓰거나 해당 시그널 핸들러를 테스트 시점에 disconnect하는 fixture를 만드는 방법이 있다.
pytest-django 4.9 이상(출처: pytest-django 공식 문서)에서 제공하는 django_capture_on_commit_callbacks fixture도 알아두면 좋다. Django의 transaction.on_commit()에 등록된 콜백은 트랜잭션이 실제로 커밋되어야 실행되는데, 테스트에서는 트랜잭션이 롤백되니 콜백이 호출되지 않는다. 이걸 모르면 "분명 on_commit에 등록했는데 테스트에서 안 돌아간다"는 문제에 한참 헤맨다.
설정은 pyproject.toml에 한 줄이면 되는데, 의외로 빠뜨리는 사람이 많다:
# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
이거 없으면 django.core.exceptions.ImproperlyConfigured: Requested setting DEFAULT_INDEX_TABLESPACE, but settings are not configured. 에러가 나온다. 공식 문서 첫 페이지에 있는 내용이다.
mock은 최후의 수단이다
"단위 테스트니까 외부 의존성은 전부 mock하자"는 흔한 조언이다. 원칙적으로는 맞는 것처럼 들리는데, 이걸 맹목적으로 따르면 테스트가 거짓말을 하기 시작한다.
결제 API를 호출하는 함수를 테스트한다고 하자. 외부 API를 mock하면 네트워크 지연이나 타임아웃 같은 시나리오를 시뮬레이션할 수 있다. 여기까진 합리적이다. 문제는 mock 객체의 반환값을 "내가 기대하는 응답 형태"로 하드코딩하는 순간, 테스트가 검증하는 건 "이 함수가 올바르게 동작하는가"가 아니라 "내 시나리오가 맞는가"가 된다는 점이다.
이걸 체감한 사건이 있다. 3월 초에 외부 결제 API의 응답 필드명이 transaction_id에서 txn_id로 바뀌었다. 우리 mock에는 여전히 transaction_id가 들어 있었고, 테스트는 당연히 통과했다. 운영 배포 후 KeyError: 'transaction_id'가 터지면서 결제 확인이 실패했다. 테스트가 있었는데도 버그를 못 잡은 거다. mock이 현실을 반영하지 못한 전형적인 사례였다.
그렇다면 mock을 아예 쓰지 말라는 건가? 아니다. 써야 하는 경우는 명확하다:
- 외부 API 호출: 결제, SMS 발송 등 실제로 호출하면 안 되는 서비스
- 시간 의존 로직:
datetime.now()를 고정해야 할 때 - 비결정적 값:
uuid4(),random등 매번 달라지는 값
이 범위를 벗어나면 가능한 한 실제 객체를 쓰는 게 낫다. DB를 mock하지 말고 인메모리 SQLite를 띄우고, Redis를 mock하지 말고 fakeredis 패키지를 쓰는 식이다. unittest.mock.patch 데코레이터가 한 테스트 함수에 5개 이상 쌓여 있다면, 그건 테스트 대상의 의존성이 너무 많다는 설계 문제의 신호다. mock을 더 추가하는 게 아니라 코드를 리팩토링해야 한다.
pytest에서는 monkeypatch fixture가 mock.patch보다 깔끔한 경우가 많다. function scope로 자동 정리되니 with patch(...): 블록을 중첩할 필요가 없고, 환경변수 변경(monkeypatch.setenv)이나 딕셔너리 값 변경(monkeypatch.setitem)도 지원한다. 다만 monkeypatch는 return value를 세밀하게 제어하는 용도로는 mock.patch가 더 적합하니, 상황에 따라 골라 쓰면 된다.
Python pytest 테스트 자동화를 CI에 올리기
커버리지 리포트와 CI 파이프라인 설정을 한꺼번에 다루겠다. 먼저 커버리지 측정 대상을 제대로 설정하는 게 선행되어야 한다. 측정 범위를 제한하지 않으면 migrations 폴더, 설정 파일 같은 코드가 수치를 왜곡하기 때문이다.
# pyproject.toml — 커버리지 설정
[tool.coverage.run]
source = ["app"]
omit = [
"app/migrations/*",
"app/config.py",
"app/__init__.py",
"app/admin.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.",
]
이렇게 제외하면 커버리지 수치가 떨어지는데, 그게 정상이다. 측정할 가치가 없는 코드를 빼고 비즈니스 로직의 커버리지만 보는 거니까. --cov-fail-under 값은 70%로 잡았다. 80이나 90을 강제하면 의미 없는 테스트가 양산되는 걸 직접 겪었기 때문이다. 차라리 기준을 낮추고, PR 리뷰에서 "이 로직은 테스트가 있나?"를 눈으로 확인하는 쪽이 실제로 버그를 줄이는 데 더 나았다.
GitHub Actions 설정은 아래 yaml 하나면 된다:
# .github/workflows/test.yml
name: pytest
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -r requirements-test.txt
- run: pytest --cov=app --cov-report=term-missing --cov-report=html --cov-fail-under=70 -v
- uses: actions/upload-artifact@v4
if: always() # 테스트 실패해도 리포트는 업로드
with:
name: coverage-report
path: htmlcov/
cache: "pip" 한 줄로 pip 캐싱이 적용된다. 직접 해보니 CI 실행 시간이 2분대에서 40초대로 줄었다. HTML 커버리지 리포트를 아티팩트로 업로드해두면 PR 리뷰어가 어떤 라인이 테스트되지 않았는지 시각적으로 확인할 수 있다. Slack 알림은 slackapi/slack-github-action으로 붙일 수 있는데, 그건 별도 토큰 설정이 필요해서 여기서는 다루지 않는다.
pytest 관련 설정을 한 파일에 모으려면 pyproject.toml을 쓰는 게 정답이다. pytest.ini, setup.cfg, tox.ini 등 여러 파일에 흩어져 있던 설정이 한곳에 들어온다:
# pyproject.toml — pytest 기본 설정
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short --strict-markers"
markers = [
"slow: 실행 시간이 긴 테스트",
"integration: 외부 서비스 의존성이 있는 통합 테스트",
]
--strict-markers는 등록되지 않은 마커를 쓰면 에러를 내는 옵션이다. @pytest.mark.slwo 같은 오타를 잡아준다. --tb=short는 실패 시 트레이스백을 짧게 출력하는 건데, CI 로그가 길어지는 걸 방지한다. 전체 트레이스백이 필요하면 로컬에서 -v --tb=long으로 다시 돌리면 된다.
다음에는 pytest-xdist로 테스트 병렬 실행을 실험해볼 생각이다.
관련 글
- FastAPI JWT 인증 구현 — 리프레시 토큰과 권한 분리까지 실무에서 겪은 것들 – FastAPI에서 JWT 인증을 직접 구현하면서 3시간을 날린 경험을 바탕으로, 토큰 설계부터 Depends 체이닝을 이용한 권한 분리까지…
- Python Docker 멀티스테이지 빌드로 이미지 크기 80% 줄이기 – FastAPI 프로젝트의 Docker 이미지가 1.2GB까지 불어났다. 멀티스테이지 빌드와 .dockerignore 설정으로 187MB까지…
- Python 비동기 크롤링 aiohttp — 단순 교체만으로는 빨라지지 않는다 – Python 비동기 크롤링 aiohttp 조합으로 requests 대비 10배 속도를 얻는 과정을 정리했다. 단순 교체가 아닌 asynci…