Python에서 pytest와 fixture를 이용한 테스트 작성

1. 개요

테스트 수가 늘어나면서 데이터 준비 부분(고객 생성, 주문, 데이터베이스 연결…)이 반복되는 문제가 생깁니다. pytest는 이를 fixture로 해결합니다. Fixture는 테스트에 데이터나 리소스를 제공하고 테스트 후 자동으로 정리하는 함수입니다.

이 글은 첫 pytest 테스트부터 yield를 사용하는 fixture, scope, conftest.py, 내장 fixture, 매개변수화까지 다룹니다. 읽고 나면 정리된, 읽기 쉽고 중복이 적은 Python 테스트 모음을 구성할 수 있습니다.

2. pytest 설치

virtual environment에 pytest를 설치하세요:

python -m venv .venv
source .venv/bin/activate
pip install pytest

이 글에서 사용하는 프로젝트 구조:

shop/
├── shop.py
└── tests/
    ├── conftest.py
    ├── test_order.py
    └── test_repository.py

pytest는 자동으로 test_*.py 또는 *_test.py라는 파일을 찾습니다. 각 파일에서 test_로 시작하는 함수를 실행합니다.

tests 폴더에서 shop을 임포트하려면 pyproject.toml에 다음을 추가하세요:

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

3. 첫 번째 테스트

shop.py의 비즈니스 로직입니다. 금액은 정수형(int)으로 float의 오차를 피합니다:

from dataclasses import dataclass, field


@dataclass
class Customer:
    name: str
    is_vip: bool = False


@dataclass
class Order:
    customer: Customer
    items: list[tuple[str, int, int]] = field(default_factory=list)

    def add_item(self, sku: str, quantity: int, price: int) -> None:
        if quantity <= 0:
            raise ValueError("quantity must be positive")
        self.items.append((sku, quantity, price))

    def total(self) -> int:
        subtotal = sum(qty * price for _, qty, price in self.items)
        return subtotal * 90 // 100 if self.customer.is_vip else subtotal

VIP 고객은 10% 할인받습니다. tests/test_order.py에서 이 로직을 테스트합니다:

import pytest

from shop import Customer, Order


def test_total_for_regular_customer():
    order = Order(Customer("An"))
    order.add_item("BOOK-01", 2, 50_000)

    assert order.total() == 100_000


def test_add_item_rejects_zero_quantity():
    order = Order(Customer("An"))

    with pytest.raises(ValueError, match="quantity must be positive"):
        order.add_item("BOOK-01", 0, 50_000)

두 가지만 알면 됩니다:

  • 일반 assert. assertEqual이나 별도의 assertion 라이브러리가 필요 없습니다. pytest가 assert를 다시 작성해서 상세한 오류를 보여줍니다.
  • **pytest.raises**는 코드 블록이 정확한 타입의 예외를 던지는지 확인합니다. match 매개변수는 예외 메시지와 정규표현식으로 비교합니다.

테스트를 실행하세요:

pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED              [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED          [100%]

============================== 2 passed in 0.01s ===============================

예상값을 의도적으로 90_000으로 바꾸면 pytest가 양쪽 값을 모두 표시합니다:

>       assert order.total() == 90_000
E       assert 100000 == 90000
E        +  where 100000 = total()

4. 기본 fixture

4.1. Fixture 선언과 사용

위 두 테스트 모두 Customer("An")과 Order를 만듭니다. 수십 개의 테스트가 있으면 이 코드가 반복됩니다. 이를 fixture로 옮겨봅시다:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)


def test_total_for_regular_customer(order):
    order.add_item("BOOK-01", 2, 50_000)
    assert order.total() == 100_000


def test_empty_order_has_zero_total(order):
    assert order.total() == 0

동작 원리:

  • @pytest.fixture는 함수를 fixture로 표시합니다.
  • 테스트가 fixture와 같은 이름의 매개변수를 선언합니다. pytest가 fixture를 호출해서 반환값을 테스트에 전달합니다. 이것이 이름으로 하는 의존성 주입입니다.
  • order fixture는 customer fixture를 받습니다. Fixture는 다른 fixture에 의존할 수 있고, pytest가 자동으로 순서를 결정합니다.

기본적으로 각 테스트는 새로운 인스턴스를 받습니다. 그래서 첫 번째 테스트의 add_item이 두 번째 테스트의 order에 영향을 주지 않습니다.

4.2. Factory fixture

한 테스트가 다른 매개변수를 가진 여러 주문이 필요할 수 있습니다. 객체를 반환하는 대신, fixture가 객체를 만드는 함수를 반환할 수 있습니다:

@pytest.fixture
def make_order():
    def _make(name="An", is_vip=False, items=()):
        order = Order(Customer(name, is_vip))
        for sku, qty, price in items:
            order.add_item(sku, qty, price)
        return order
    return _make


def test_vip_gets_ten_percent_discount(make_order):
    regular = make_order(items=[("BOOK-01", 1, 200_000)])
    vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])

    assert regular.total() == 200_000
    assert vip.total() == 180_000

이 패턴은 준비 부분을 간결하게 유지하면서도 테스트가 스스로 데이터를 결정하게 합니다.

5. yield를 이용한 설정과 정리

Fixture는 데이터베이스 연결, 파일, 모의 서버 같이 정리가 필요한 리소스를 다룰 때 진정한 가치를 발휘합니다. shop.py에 SQLite를 사용하는 repository를 추가합시다:

import sqlite3


class OrderRepository:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn

    def save(self, customer_name: str, total: int) -> int:
        cur = self.conn.execute(
            "INSERT INTO orders (customer, total) VALUES (?, ?)",
            (customer_name, total),
        )
        return cur.lastrowid

    def count(self) -> int:
        return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]

Fixture는 return 대신 yield를 사용합니다:

import sqlite3

import pytest

from shop import OrderRepository


@pytest.fixture
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    return OrderRepository(db_connection)


def test_save_order_returns_id(repository):
    order_id = repository.save("An", 100_000)

    assert order_id == 1
    assert repository.count() == 1

yield 앞부분은 **설정(setup)**입니다. yield 뒤의 값이 테스트에 전달됩니다. yield 뒤 부분은 **정리(teardown)**이며 테스트가 실패해도 항상 실행됩니다. try/finally를 쓸 필요가 없습니다.

--setup-show 플래그로 순서를 볼 수 있습니다:

pytest tests/test_repository.py --setup-show
tests/test_repository.py
        SETUP    F db_connection
        SETUP    F repository (fixtures used: db_connection)
        tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
        TEARDOWN F repository
        TEARDOWN F db_connection

F는 scope가 function임을 의미합니다. 정리는 설정의 역순으로 실행됩니다.

주의: yield 전에 예외가 발생하면 정리 부분이 실행되지 않습니다. 이유는 fixture 설정이 완료되지 않았기 때문입니다. 설정 부분은 짧게 유지하거나, 설정이 여러 단계이고 실패할 수 있으면 직접 try/finally로 감싸세요.

6. Fixture의 범위(scope)

비싼 리소스(실제 데이터베이스 연결, 컨테이너 시작 등)를 매 테스트마다 만들면 테스트가 느려집니다. scope 매개변수로 fixture의 수명을 결정합니다:

Scope 다시 생성 시점
function (기본값) 각 테스트마다
class 각 테스트 클래스마다
module 각 테스트 파일마다
package 각 패키지마다
session 전체 실행에서 한 번

파일 전체에서 하나의 연결을 공유하면서도 각 테스트 후 데이터를 정리하는 예:

@pytest.fixture(scope="module")
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    yield OrderRepository(db_connection)
    db_connection.execute("DELETE FROM orders")


def test_first_save(repository):
    repository.save("An", 100_000)
    assert repository.count() == 1


def test_second_save_starts_clean(repository):
    repository.save("Binh", 50_000)
    assert repository.count() == 1

db_connection은 한 번만 생성됩니다. repository는 scope이 function이므로 각 테스트 후 테이블을 정리합니다. 덕분에 두 번째 테스트는 여전히 빈 테이블을 봅니다.

중요한 규칙이 있습니다: fixture는 자신과 같거나 더 넓은 scope을 가진 fixture만 사용할 수 있습니다. 만약 session scope fixture가 function fixture를 사용하려 하면 pytest는 오류를 냅니다:

ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.

7. conftest.py를 통한 Fixture 공유

customer, make_order fixture는 여러 테스트 파일에서 유용합니다. 이들을 tests/conftest.py에 넣으세요:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)

pytest가 자동으로 conftest.py를 로드합니다. 해당 디렉토리와 하위 디렉토리의 모든 테스트는 이 fixture를 임포트 없이 사용할 수 있습니다. 여러 레벨의 conftest.py를 둘 수 있습니다. 테스트에 가까운 conftest.py의 fixture가 먼 것을 오버라이드합니다.

주의: 테스트 파일에서 conftest를 직접 임포트하지 마세요. 그러면 fixture가 두 번 등록되어 예상치 못한 동작이 발생할 수 있습니다.

어느 테스트가 어떤 fixture를 사용하고 그 fixture가 어디서 정의되었는지 보려면:

pytest --fixtures-per-test tests/test_order.py

8. 내장 fixture

pytest는 유용한 내장 fixture를 많이 제공합니다. 가장 자주 쓰는 세 가지는 tmp_path, monkeypatch, capsys입니다.

shop.py에 두 함수를 추가한다고 가정합시다:

import os
from pathlib import Path


def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
    lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
    path.write_text("\n".join(lines), encoding="utf-8")


class PaymentService:
    def __init__(self):
        self.api_key = os.environ["PAYMENT_API_KEY"]

    def charge(self, order: "Order") -> str:
        print(f"Charging {order.total()} VND")
        return "SUCCESS"

이들에 대한 테스트:

from shop import PaymentService, export_orders_csv


def test_export_orders_csv(tmp_path):
    output = tmp_path / "orders.csv"

    export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)

    assert output.read_text(encoding="utf-8").splitlines() == [
        "customer,total",
        "An,100000",
        "Binh,50000",
    ]


def test_payment_service_charges_order(monkeypatch, capsys, order):
    monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
    order.add_item("BOOK-01", 1, 75_000)

    result = PaymentService().charge(order)

    assert result == "SUCCESS"
    assert capsys.readouterr().out == "Charging 75000 VND\n"
  • **tmp_path**는 각 테스트마다 고유한 임시 디렉토리를 가리키는 pathlib.Path입니다. 테스트가 프로젝트 디렉토리를 오염시키지 않습니다.
  • **monkeypatch**는 환경변수, 속성, 함수를 테스트 범위 내에서 변경합니다. 모든 변경은 테스트 종료 시 자동으로 되돌려집니다. setenv 외에도 monkeypatch.setattr(obj, "name", value)를 자주 쓸 것입니다.
  • **capsys**는 stdout과 stderr로 출력한 내용을 캡처합니다. readouterr()은 테스트 시작 이후 또는 마지막 호출 이후 출력된 내용을 반환합니다.

9. 테스트 매개변수화

9.1. @pytest.mark.parametrize

같은 로직을 여러 데이터 셋으로 검사해야 할 때, 테스트를 복사하면 안 됩니다. parametrize를 사용하세요:

import pytest


@pytest.mark.parametrize(
    ("is_vip", "price", "expected"),
    [
        (False, 100_000, 100_000),
        (True, 100_000, 90_000),
        (True, 15, 13),
    ],
    ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
    order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
    assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED

각 매개변수 조합은 별개의 테스트입니다. ids 덕분에 ID가 읽기 쉽습니다. fixture(make_order)를 매개변수와 함께 쓸 수 있다는 점에 주목하세요. vip-rounding-down 케이스는 // 연산의 내림 동작을 명확히 기록합니다.

9.2. 매개변수화된 Fixture

만약 이 fixture를 사용하는 모든 테스트가 여러 변형으로 실행되길 원한다면, fixture 자체를 매개변수화하세요:

@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
    return Customer("An", is_vip=request.param)


def test_total_is_never_negative(any_customer):
    order = Order(any_customer)
    order.add_item("BOOK-01", 3, 10_000)
    assert order.total() >= 0

request는 특별한 fixture입니다. request.param은 현재 값을 가집니다. 위 테스트는 자동으로 두 번 실행됩니다.

10. 흔한 실수와 fixture를 피해야 할 때

  • Fixture 이름 오류. 매개변수 이름이 어떤 fixture와도 맞지 않으면 fixture 'oder' not found 오류가 나옵니다. pytest가 사용 가능한 fixture를 바로 아래 나열하니 읽고 수정하세요.
  • Fixture를 일반 함수처럼 호출. 테스트 내에서 customer()라고 쓰면 Fixture "customer" called directly 오류가 납니다. 매개변수로 선언하세요.
  • 변경 가능한 데이터와 넓은 scope. scope이 module인 Order에 한 테스트가 item을 추가하면 다른 테스트가 틀립니다. 결과가 실행 순서에 따라 달라져서 디버그가 매우 어렵습니다. 넓은 scope은 변하지 않는 비싼 리소스에만, 또는 6번 섹션처럼 function scope fixture에서 상태를 정리할 때만 사용하세요.
  • autouse=True 남용. Fixture가 모든 테스트에 자동으로 적용되니 선언할 필요가 없지만, 테스트를 읽는 사람은 그걸 어디서 발견할 수 없습니다. 진정 전역적인 작업(예: 네트워크 차단)에만 사용하세요.
  • 너무 깊은 fixture 체인. order가 customer에 의존하고, customer가 address에 의존하고, address가 country에 의존한다면…, 독자는 여러 파일을 넘나들어야 테스트를 이해합니다. 단순한 데이터는 테스트에서 직접 만들거나 factory fixture가 더 읽기 쉬울 수 있습니다.

팁: Fixture는 환경을 준비하는 도구이지, 검사 로직을 담는 곳이 아닙니다. 각 테스트가 무엇을 검사하는지 알 수 있도록 assert는 테스트 함수 안에 두세요.

11. 결론

pytest의 핵심을 다뤘습니다. 일반 assert와 pytest.raises로 결과를 검사합니다. 이름으로 하는 fixture 주입은 반복되는 준비 코드를 없앱니다. yield는 안전한 설정/정리를, scope는 속도와 독립성의 균형을 맞춥니다. conftest.py는 여러 파일 간 fixture를 공유합니다. tmp_path, monkeypatch, capsys 같은 내장 fixture는 파일, 환경, 출력을 다룹니다. 마지막으로 parametrize와 매개변수화된 fixture는 테스트를 복사하지 않고도 여러 경우를 다룹니다.

테스트 모음이 커질수록 fixture scope은 처음 function으로 시작하세요. 성능이 진짜 문제임을 측정한 뒤에만 scope을 넓히세요.

pythonpytesttestingfixtureunit-test