파이썬 테스트 #5 외부 세계 테스트: 파일, HTTP, DB, 웹 프레임워크
#4 mock과 monkeypatch에서 외부 의존성을 가짜로 바꾸는 법을 다뤘습니다. 그런데 mock을 익히고 나면 새로운 고민이 생깁니다. 전부 가짜로 막은 테스트는 빠르고 안정적이지만, 정작 진짜 파일이 써지는지, 진짜 DB에 저장되는지는 아무것도 보장하지 않습니다. mock이 전부 통과해도 배포하면 깨지는 코드는 얼마든지 만들 수 있습니다.
이번 글에서는 외부 세계의 네 영역인 파일, HTTP, DB, 웹 프레임워크를 놓고, 어디까지 진짜를 쓰고 어디부터 가짜로 막을지 영역별 기준을 정리하겠습니다.
파일: tmp_path로 진짜 파일 시스템을 씁니다 #
파일 I/O는 mock 대상이 아닙니다. 로컬 디스크는 빠르고 결정적이라, 가짜로 막아서 얻는 것보다 잃는 것이 많습니다. pytest 내장 픽스처 tmp_path가 테스트마다 비어 있는 임시 디렉터리를 만들어 줍니다.
import json
from pathlib import Path
def save_config(path: Path, config: dict) -> None:
path.write_text(json.dumps(config), encoding="utf-8")
def test_save_config(tmp_path):
config_file = tmp_path / "config.json"
save_config(config_file, {"debug": True, "port": 8000})
assert json.loads(config_file.read_text()) == {"debug": True, "port": 8000}tmp_path는 pathlib.Path 객체입니다. 테스트마다 서로 다른 디렉터리가 배정되고, 끝나면 pytest가 알아서 정리합니다. 진짜 파일을 쓰고 읽었으니 인코딩, 경로 처리, 직렬화까지 전부 실제로 검증된 것입니다. 전제 조건은 하나입니다. 위의 save_config처럼 코드가 경로를 인자로 받아야 tmp_path를 끼워 넣을 수 있습니다. 작업 디렉터리 기준으로 동작하는 코드라면 monkeypatch.chdir(tmp_path)로 현재 디렉터리 자체를 옮기는 방법도 있습니다.
HTTP: 네트워크 경계만 가짜로 막습니다 #
mock.patch로 fetch_user 함수 자체를 갈아 끼우면, URL 조립이나 에러 처리 같은 함수 내부 로직은 전혀 실행되지 않습니다. 더 나은 차단 위치는 네트워크 경계입니다. 내 코드는 끝까지 실행하고, 실제 소켓이 열리기 직전만 가로채는 방식입니다. httpx를 쓴다면 respx가 이 역할을 합니다. pip install respx로 설치합니다.
import httpx
import respx
def fetch_user(user_id: int) -> dict:
response = httpx.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
@respx.mock
def test_fetch_user():
respx.get("https://api.example.com/users/1").mock(
return_value=httpx.Response(200, json={"id": 1, "name": "curtis"})
)
assert fetch_user(1)["name"] == "curtis"fetch_user의 URL 조립, raise_for_status(), JSON 파싱이 전부 실제로 실행되고, 소켓만 가짜 응답으로 대체됩니다. httpx.Response(404)를 등록하면 에러 경로도 똑같이 재현할 수 있습니다. requests를 쓴다면 같은 역할의 responses가 있고, 사용법도 거의 같습니다. 모던 파이썬 실전 #6에서 쓴 pytest-httpx도 같은 계열입니다. 이런 라이브러리들은 등록하지 않은 URL로 요청이 나가면 에러를 내기 때문에, 테스트 도중 실수로 진짜 네트워크에 나가는 사고까지 차단해 줍니다.
실제 API를 부르는 테스트는 마커로 분리합니다 #
그런데 가짜 응답이 실제 API 스펙과 어긋나 있다면 어떻게 알 수 있을까요? 이건 진짜 호출로만 검증됩니다. #3에서 본 마커로 분리해 둡니다.
@pytest.mark.external
def test_real_api_contract():
response = httpx.get("https://api.example.com/users/1")
assert "name" in response.json()[tool.pytest.ini_options]
markers = ["external: 실제 외부 API를 호출하는 테스트"]
addopts = "-m 'not external'"평소 실행에서는 제외되고, pytest -m external로 명시할 때만 돌리는, 느리고 가끔 실패해도 되는 별도 그룹으로 분리한 것입니다.
DB: 테스트마다 깨끗한 상태를 만듭니다 #
DB 테스트의 핵심 문제는 상태입니다. 앞 테스트가 남긴 데이터가 뒤 테스트에 영향을 주면 실행 순서에 따라 결과가 달라집니다. 깨끗한 상태를 만드는 전략은 크게 셋입니다. 가장 빠른 SQLite 인메모리, 실제 스키마를 그대로 쓰는 트랜잭션 롤백, 가장 느리지만 프로덕션과 동일한 컨테이너 DB입니다.
sqlite:///:memory:는 디스크조차 거치지 않으니 가장 빠릅니다. 다만 프로덕션이 PostgreSQL이라면 한계를 알고 써야 합니다. JSON 연산자, 배열 타입, 타입 검사 엄격성, 동시성 동작이 다르기 때문에, SQLite에서 통과한 쿼리가 PostgreSQL에서 깨질 수 있고 그 반대도 있습니다.
트랜잭션 롤백 패턴 #
테스트 시작 때 트랜잭션을 열고, 끝나면 커밋 대신 롤백합니다. 데이터를 일일이 지울 필요 없이 처음 상태로 되돌아갑니다.
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models import Base
@pytest.fixture(scope="session")
def engine():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection, join_transaction_mode="create_savepoint")
yield session
session.close()
transaction.rollback()
connection.close()join_transaction_mode="create_savepoint" 덕분에 테스트 코드 안의 commit()은 SAVEPOINT까지만 반영되고, 픽스처 마지막의 rollback()이 전부 되돌립니다. 테스트는 db_session을 받아 평소처럼 add()와 commit()을 호출하면 되고, DB에는 아무것도 남지 않아 다음 테스트는 항상 빈 상태에서 시작합니다.
쿼리가 PostgreSQL 전용 기능에 의존한다면 testcontainers로 테스트 동안만 Docker 컨테이너를 띄우는 선택지가 있습니다. PostgresContainer("postgres:16")을 세션 스코프 픽스처로 한 번만 띄우고, 테스트별 격리는 위의 롤백 패턴을 그대로 얹습니다. 기동에 몇 초가 드는 대신 프로덕션과 같은 DB로 검증합니다.
웹 프레임워크: 서버 없이 요청을 보냅니다 #
FastAPI의 TestClient는 서버를 띄우지 않고 in-process로 라우트를 호출합니다.
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200요청 파싱, 의존성 주입, 응답 직렬화까지 프레임워크 전체가 실제로 실행됩니다. 진짜 DB 의존성을 테스트용으로 바꾸는 dependency_overrides와 비동기 클라이언트까지 포함한 상세 패턴은 모던 파이썬 실전 #6에서 다뤘습니다.
Django의 테스트 클라이언트도 같은 모양입니다. TestCase 안에서 self.client.get("/polls/")로 뷰를 호출하고, 테스트마다 트랜잭션 롤백으로 DB를 되돌리는 것까지 위에서 본 패턴이 프레임워크에 내장돼 있습니다. Django 자체는 장고 기초 시리즈에서 다룹니다.
단위와 통합의 경계: 느림과 신뢰의 거래 #
지금까지의 선택을 한 줄로 정리하면, 모든 경계에서 같은 거래를 하고 있습니다. 진짜에 가까울수록 신뢰가 올라가고 속도가 내려갑니다.
| 영역 | 빠른 쪽 | 진짜 쪽 |
|---|---|---|
| 파일 | tmp_path (이미 충분히 진짜) | 그대로 사용 |
| HTTP | respx / responses | external 마커로 실제 호출 |
| DB | SQLite 인메모리 | 롤백 패턴, testcontainers |
| 웹 | TestClient (in-process) | 스테이징 환경 E2E |
기본값은 빠른 쪽에 두고, 진짜 쪽 테스트를 소수 정예로 유지하는 구성이 실무 표준입니다. 매 커밋마다 도는 것은 빠른 테스트이고, 배포 전이나 하루 한 번 도는 것은 진짜 테스트입니다.
정리 #
- 파일은 mock하지 않고
tmp_path로 진짜 파일 시스템을 씁니다 - HTTP는 함수가 아니라 네트워크 경계를 막습니다. httpx에는 respx, requests에는 responses입니다
- 실제 API 호출 테스트는
external마커로 분리해 평소 실행에서 제외합니다 - DB는 트랜잭션 롤백 패턴으로 테스트마다 깨끗한 상태를 만들고, PostgreSQL 전용 기능은 testcontainers로 검증합니다
- FastAPI의
TestClient와 Django 테스트 클라이언트는 서버 없이 프레임워크 전체를 실행합니다 - 진짜에 가까울수록 느려지고 믿을 수 있어집니다. 기본은 빠르게, 진짜는 소수로 유지합니다
다음 글 #6에서는 테스트 설계와 커버리지를 다루겠습니다. 어떤 테스트를 얼마나 쓸지, 좋은 테스트와 나쁜 테스트를 가르는 기준, 커버리지 숫자를 읽는 법까지 정리하겠습니다.