파이썬 테스트 #2 픽스처: 준비와 정리를 주입받기

6 분 소요

1편에서 pytest를 설치하고 첫 테스트를 돌렸습니다. 그런데 테스트가 몇 개만 늘어나도 새로운 불편이 생깁니다. 모든 테스트가 시작 부분에서 똑같은 준비 코드를 반복하는 문제입니다. 이번 글에서는 그 반복을 없애는 pytest의 핵심 도구인 픽스처(fixture)를 다루겠습니다.

  • #1 pytest 시작
  • #2 픽스처 ← 이번 글
  • #3 parametrize와 마커
  • #4 mock과 monkeypatch
  • #5 외부 세계 테스트
  • #6 테스트 설계와 커버리지
  • #7 CI 연동

반복되는 준비 코드 #

1편의 장바구니 예제에 테스트를 몇 개 더 붙이면 이런 모양이 됩니다.

🚫 모든 테스트가 같은 준비를 반복
def test_remove_item():
    cart = Cart()
    cart.add("사과", 1000)
    cart.remove("사과")
    assert cart.total() == 0

def test_clear():
    cart = Cart()
    cart.add("사과", 1000)
    cart.clear()
    assert cart.total() == 0

def test_total():
    cart = Cart()
    cart.add("사과", 1000)
    cart.add("배", 2000)
    assert cart.total() == 3000

세 테스트 모두 “사과가 담긴 장바구니"를 직접 만들고 시작합니다. Cart의 생성 방법이 바뀌면 모든 테스트를 고쳐야 하고, 준비 코드가 길어질수록 정작 검증하려는 한 줄이 묻힙니다.

@pytest.fixture: 함수 인자로 주입받기 #

픽스처는 “준비된 객체를 만드는 함수"입니다. @pytest.fixture를 붙여 정의하고, 테스트는 같은 이름의 인자를 선언하기만 하면 됩니다.

✅ 픽스처로 추출
import pytest

@pytest.fixture
def cart():
    c = Cart()
    c.add("사과", 1000)
    return c

def test_remove_item(cart):
    cart.remove("사과")
    assert cart.total() == 0

def test_clear(cart):
    cart.clear()
    assert cart.total() == 0

테스트 함수가 cart라는 인자를 선언하면 pytest가 같은 이름의 픽스처 함수를 찾아 실행하고, 반환값을 인자로 넣어줍니다. 이 구조를 의존성 주입이라는 관점으로 보면 이해가 빠릅니다. 테스트는 “사과가 담긴 장바구니가 필요하다"고 선언만 하고, 그걸 만드는 방법은 픽스처가 담당합니다. 기본적으로 픽스처는 테스트마다 새로 실행되므로, 한 테스트가 장바구니를 비워도 다음 테스트는 깨끗한 장바구니를 받습니다.

yield 픽스처: 준비와 정리를 한곳에 #

파일이나 DB 연결처럼 쓰고 나서 정리해야 하는 자원return 대신 yield를 씁니다.

셋업과 티어다운
import sqlite3
import pytest

@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")   # 셋업
    conn.execute("CREATE TABLE users (name TEXT)")
    yield conn                           # 여기서 테스트 실행
    conn.close()                         # 티어다운

def test_insert(db):
    db.execute("INSERT INTO users VALUES ('커티스')")
    rows = db.execute("SELECT count(*) FROM users").fetchone()
    assert rows[0] == 1

yield 앞이 준비, 뒤가 정리입니다. 중요한 건 테스트가 실패해도 정리 코드는 반드시 실행된다는 점입니다. try/finally를 테스트마다 적는 대신, 자원의 생명주기를 픽스처 한곳에 모아둘 수 있습니다.

scope: 격리와 비용의 트레이드오프 #

픽스처가 테스트마다 새로 실행되는 건 격리에는 좋지만, 준비 비용이 큰 자원이라면 부담입니다. DB 엔진을 띄우는 데 2초가 걸리는데 테스트가 100개라면 어떨까요? scope 옵션으로 생성 주기를 늘릴 수 있습니다.

scope 지정
@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine()   # 비싼 준비를 한 번만
    yield engine
    engine.dispose()
scope생성 주기어울리는 자원
function테스트마다 (기본값)가변 상태를 가진 객체 전부
module파일마다 한 번파일 단위로 공유해도 안전한 자원
session전체 실행에서 한 번DB 엔진, 컨테이너처럼 비싼 자원

scope를 넓힐수록 빨라지지만 테스트 사이에 객체가 공유됩니다. 한 테스트가 남긴 변경이 다음 테스트에 보이는 순간 격리가 깨지므로, 넓은 scope는 읽기 전용에 가까운 자원에만 쓰는 게 안전합니다.

conftest.py: 픽스처 공유 #

픽스처를 테스트 파일마다 복사할 필요는 없습니다. conftest.py라는 이름의 파일에 정의하면 import 없이 같은 디렉터리와 하위 디렉터리의 모든 테스트가 쓸 수 있습니다.

디렉터리 계층
tests/
├── conftest.py        # cart 픽스처: 모든 테스트에서 사용 가능
├── test_cart.py
└── api/
    ├── conftest.py    # client 픽스처: api/ 아래에서만 사용 가능
    └── test_orders.py

pytest가 테스트를 실행할 때 해당 파일의 위치에서 상위 디렉터리 방향으로 conftest.py를 모두 읽어 픽스처를 모읍니다. 그래서 공용 픽스처는 위쪽에, 특정 영역 전용 픽스처는 그 디렉터리의 conftest.py에 두는 계층 구조가 자연스럽게 만들어집니다.

픽스처가 픽스처를 쓰는 합성 #

픽스처도 다른 픽스처를 인자로 받을 수 있습니다.

픽스처 합성
@pytest.fixture
def user():
    return User(name="커티스")

@pytest.fixture
def cart(user):
    return Cart(owner=user)

def test_owner(cart):
    assert cart.owner.name == "커티스"

cartuser에 의존한다고 선언하면 pytest가 의존성 순서대로 알아서 실행합니다. 작은 픽스처를 조립해 복잡한 상황을 만드는 이 패턴이, 뒤에서 볼 만능 픽스처보다 훨씬 유지보수하기 좋습니다.

내장 픽스처 맛보기: tmp_path와 capsys #

pytest에는 정의 없이 바로 쓰는 내장 픽스처가 여럿 있습니다. 가장 자주 쓰는 둘만 소개하겠습니다.

tmp_path와 capsys
def test_save_report(tmp_path):
    file = tmp_path / "report.txt"     # 테스트 전용 임시 디렉터리
    save_report(file, "매출 1000")
    assert file.read_text() == "매출 1000"

def test_greet(capsys):
    greet("커티스")
    captured = capsys.readouterr()     # print 출력을 캡처
    assert "커티스" in captured.out

tmp_path는 테스트마다 고유한 임시 디렉터리를 pathlib.Path로 주고, 정리도 알아서 합니다. 파일을 다루는 테스트에서 실제 디렉터리를 어지럽힐 일이 사라집니다. capsys는 표준 출력을 캡처해서 print 결과를 검증할 수 있게 합니다. 전체 목록은 pytest --fixtures로 확인할 수 있습니다.

안티패턴 두 가지 #

만능 픽스처부터 경계해야 합니다. 사용자, 장바구니, DB, 설정까지 전부 준비하는 거대한 픽스처 하나를 모든 테스트가 받는 구조입니다. 테스트가 실제로 무엇에 의존하는지 보이지 않고, 픽스처를 고칠 때마다 무관한 테스트가 깨집니다. 위에서 본 합성 패턴으로 작게 나누고, 테스트는 필요한 것만 선언하게 하세요.

scope 남용도 흔합니다. 느리다는 이유로 가변 객체를 session scope로 올리면, 한 테스트가 남긴 상태 때문에 실행 순서에 따라 결과가 달라지는 테스트가 생깁니다. 단독으로는 통과하는데 전체 실행에서는 실패하는 테스트가 보이면 대부분 이 경우입니다. 넓은 scope는 비싸고 읽기 전용인 자원에만 허용하는 원칙을 지키는 게 좋습니다.

정리 #

이번 글에서 다룬 내용입니다.

  • @pytest.fixture로 준비 코드를 추출하고, 테스트는 인자 이름으로 주입받습니다
  • yield 픽스처는 셋업과 티어다운을 한곳에 모으고, 테스트가 실패해도 정리를 보장합니다
  • scope는 격리와 비용의 트레이드오프이며, 넓은 scope는 읽기 전용 자원에만 쓰는 게 안전합니다
  • conftest.py에 두면 import 없이 디렉터리 계층 전체에서 픽스처를 공유합니다
  • 픽스처가 픽스처를 받는 합성으로 작은 조각을 조립합니다
  • tmp_path, capsys 같은 내장 픽스처는 정의 없이 바로 씁니다

다음 글(#3 parametrize와 마커)에서는 같은 테스트 로직을 여러 입력으로 반복 실행하는 @pytest.mark.parametrize와, 테스트를 분류하고 선택 실행하는 마커를 다루겠습니다.

X