pytest로 강력하고 빈틈없는 단위 테스트 작성하기
pytest 프레임워크로 깔끔하고 유지보수하기 좋으며 효과적인 파이썬 단위 테스트를 작성하는 실용 가이드.
파이썬 테스트 프레임워크 중에서 pytest는 단순하면서도 놀랍도록 강력하다는 점에서 눈에 띈다. 하지만 pytest의 진짜 강점은 “그냥 assert를 쓸 수 있다”가 아니다. 올바른 테스트 사고방식, 구조, 모범 사례를 받아들일 때 빛난다.
이 글은 코드라기보다 살아 있는 문서처럼 읽히는 깔끔하고 읽기 쉽고 유지보수 가능한 단위 테스트를 작성하는 방법을 정리한 것이다.
프로젝트 구조: 테스트를 정돈되고 확장 가능하게
깔끔한 프로젝트 구조는 테스트를 예측 가능하고 유지보수하기 좋게 만든다:
project/
src/
myapp/
service.py
utils.py
tests/
test_service.py
test_utils.py
conftest.py # 공유 fixture
지킬 규칙:
- 테스트 파일은
test_*.py또는*_test.py - 테스트 함수는
test_로 시작 - 각 테스트 파일은 하나의 모듈 또는 하나의 기능에 집중
아름다운 pytest 테스트 쓰기 (AAA 형식)
최소한의 문법 + 명확한 의도 = 좋은 테스트.
# test_utils.py
from myapp.utils import add
def test_add_two_positive_numbers():
# Arrange
a = 2
b = 3
# Act
result = add(a, b)
# Assert
assert result == 5
이름이 중요하다
좋은 테스트 이름은 시나리오를 설명한다:
test_login_with_invalid_password_raises_errortest_add_when_negative_returns_negative_sum
테스트 파일이 명세서를 읽는 느낌이어야 한다.
fixture 제대로 쓰기 (비밀 병기)
pytest fixture는 중복된 준비 코드를 없애고 테스트를 깔끔하게 만든다.
# conftest.py
import pytest
from myapp.service import UserService
from myapp.repository import InMemoryUserRepo
@pytest.fixture
def user_service():
repo = InMemoryUserRepo()
return UserService(repo)
# test_service.py
def test_create_user_success(user_service):
user = user_service.create_user("will", "password123")
assert user.username == "will"
fixture 스코프
-
function– 테스트마다 새 인스턴스 (기본값) -
module– 같은 파일 안에서 공유 -
session– 테스트 스위트 전체에서 공유 (신중하게!)
세션 fixture가 다른 파일에 정의돼 있어도 어느 테스트에서든 쓸 수 있다:
@pytest.fixture(scope="session")
def cache():
return {}
parametrize: 테스트 하나로 여러 케이스
비슷한 테스트를 반복하는 대신 @pytest.mark.parametrize를 쓴다:
import pytest
from myapp.utils import is_even
@pytest.mark.parametrize(
"value, expected",
[
(2, True),
(4, True),
(3, False),
(0, True),
(-2, True),
],
)
def test_is_even(value, expected):
assert is_even(value) is expected
테스트가 간결하고, 읽기 좋고, 빠짐없어진다.
외부 의존성 mocking (monkeypatch/mocker)
테스트는 빠르고, 고립돼 있고, 결정적이어야 한다. 실제 API, 데이터베이스, 시간, OS 등을 호출하지 말자.
monkeypatch 사용
def test_get_current_utc_hour(monkeypatch):
class DummyDatetime:
@classmethod
def now(cls, tz=None):
from datetime import datetime, timezone
return datetime(2025, 1, 1, 10, 30, tzinfo=timezone.utc)
import myapp.time_utils as tu
monkeypatch.setattr(tu, "datetime", DummyDatetime)
assert get_current_utc_hour() == 10
pytest-mock 사용 (권장)
def test_calls_external_api(mocker, user_service):
fake_response = {"status": "ok"}
mock_post = mocker.patch("myapp.service.requests.post", return_value=fake_response)
result = user_service.call_external()
mock_post.assert_called_once()
assert result == "ok"
mark 활용: slow, integration, db 등
mark는 테스트를 정리하는 데 도움이 된다.
import pytest
@pytest.mark.slow
def test_heavy_computation():
...
느린 테스트만 실행:
pytest -m slow
느린 테스트를 제외하고 전부 실행:
pytest -m "not slow"
경고를 피하려면 mark를 등록하라
pytest.ini에 추가:
[pytest]
markers =
slow: tests that are slow and excluded from normal runs
커버리지: 실제로 테스트된 부분 측정하기
설치:
pip install pytest-cov
실행:
pytest --cov=src/myapp --cov-report=term-missing
커버리지가 기준 아래로 떨어지면 실패시키기:
pytest --cov=src/myapp --cov-fail-under=80
속성 기반 테스트 (Hypothesis)
항상 참이어야 하는 규칙(불변식)에는 Hypothesis를 쓴다.
pip install hypothesis
from hypothesis import given, strategies as st
from myapp.utils import add
@given(st.integers(), st.integers())
def test_add_commutative(a, b):
assert add(a, b) == add(b, a)
Hypothesis는 생각지도 못한 엣지 케이스를 자동으로 찾아낸다.
서비스와 리포지토리를 깔끔하게 테스트하기
“가짜 리포지토리”를 써서 비즈니스 로직을 실제 DB로부터 분리할 수 있다:
class FakeUserRepo:
def __init__(self):
self.users = {}
def exists(self, username):
return username in self.users
def create(self, username, password):
user = {"username": username, "password": password}
self.users[username] = user
return user
@pytest.fixture
def user_service():
from myapp.service import UserService
return UserService(FakeUserRepo())
이 패턴은 테스트를 이렇게 유지해준다:
- 빠르고
- 예측 가능하며
- 외부 시스템에 독립적이다
유용한 명령어
# 기본
pytest
# 상세 출력
pytest -v
# 특정 테스트만 실행
pytest tests/test_user_service.py::test_register
# 느린 테스트 건너뛰기
pytest -m "not slow"
# 커버리지 누락 줄 표시
pytest --cov=src/myapp --cov-report=term-missing
# 마지막에 실패한 테스트만 재실행
pytest --last-failed