PHpullh

PYTHON · 심층 가이드

Python 테스트 완전 정리

pytest의 fixture와 parametrize로 테스트 구조를 잡고, mock으로 외부 의존성을 끊고, hypothesis와 커버리지·CI까지 실제로 유지되는 테스트 체계를 만듭니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

pytest가 표준처럼 자리 잡은 이유는 단순합니다. assert 한 줄이면 되고, 실패했을 때 어떤 값이 어떻게 달랐는지를 알아서 풀어 보여주기 때문입니다. 바이트코드 수준에서 assert 문을 다시 써 좌우 값을 캡처하는 덕분에 assertEqual 같은 전용 메서드를 외울 필요가 없습니다. 여기에 fixture라는 의존성 주입 장치가 붙으면 setUp 상속 계층 없이도 준비와 정리를 조합할 수 있습니다. 클래스로 테스트를 묶을 이유도 대부분 사라집니다.

pytest — 기초 & fixture & parametrize가 골격이고, fixture 활용에서 스코프와 의존 관계를, 파라미터화 테스트에서 케이스 증식을 익힙니다. 외부 세계를 끊는 일은 mock/patch가 맡고, 반대로 붙여서 검증하는 통합 테스트와 짝을 이룹니다. 예제 기반 테스트로는 못 찾는 엣지 케이스는 프로퍼티 기반 테스트 (hypothesis)가 랜덤 입력으로 훑고 실패 입력을 최소 형태로 줄여줍니다. 마지막으로 커버리지CI/CD 테스트가 이 모든 것을 매 커밋마다 자동으로 돌게 만듭니다.

mock에서 사람들이 가장 많이 틀리는 지점은 패치 대상의 위치입니다. patch는 객체가 정의된 곳이 아니라 참조되는 곳을 바꿔야 합니다. myapp.servicefrom requests import get으로 가져왔다면 requests.get이 아니라 myapp.service.get을 패치해야 합니다. 이걸 놓치면 테스트는 통과하는데 실제로는 진짜 네트워크를 때리는 상태가 됩니다. 그리고 커버리지 숫자는 실행된 줄의 비율일 뿐 검증의 품질이 아닙니다. assert 없는 테스트도 커버리지는 올라갑니다.

01pytest — 기초 & fixture & parametrize

Python 최고의 테스트 프레임워크 pytest로 효율적인 테스트를 작성합니다.

Python code

# test_calculator.py
import pytest
from calculator import add, divide

# 기본 테스트
def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

# 예외 테스트
def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError, match="division by zero"):
        divide(10, 0)

# @pytest.mark.parametrize — 여러 케이스 자동 실행
@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (100, -50, 50),
])
def test_add_cases(a, b, expected):
    assert add(a, b) == expected

# fixture — 테스트 전후 설정/정리
@pytest.fixture
def sample_data():
    data = {"users": ["Alice", "Bob"], "count": 2}
    yield data          # 여기서 테스트 실행됨
    # yield 이후는 teardown
    print("\n정리 완료")

def test_with_fixture(sample_data):
    assert sample_data["count"] == 2
    assert "Alice" in sample_data["users"]

# tmp_path fixture (pytest 내장)
def test_file_write(tmp_path):
    file = tmp_path / "test.txt"
    file.write_text("hello")
    assert file.read_text() == "hello"

# monkeypatch — 의존성 교체
def test_with_mock(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "42")
    # input()이 항상 "42"를 반환
알아두면 좋은 점

pytesttest_로 시작하는 파일과 함수를 자동 발견합니다. pytest -v로 상세 출력, pytest -k "add"로 이름 필터링이 가능합니다.

자주 하는 실수

assert add(2,3) == 5에서 실패 시 pytest는 자동으로 값을 출력해줍니다. assertEqual 같은 메서드 없이 일반 assert만으로 충분합니다.

02pytest 고급 — mock & 비동기 테스트

unittest.mock으로 외부 의존성을 격리하고 비동기 코드를 테스트합니다.

Python code

import pytest
from unittest.mock import AsyncMock, MagicMock, patch

# 비동기 함수 테스트 (pytest-asyncio 필요)
# pip install pytest-asyncio

# pytest.ini 또는 pyproject.toml에 추가:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"

async def fetch_user(api_client, user_id: int) -> dict:
    response = await api_client.get(f"/users/{user_id}")
    return response.json()

@pytest.mark.asyncio
async def test_fetch_user():
    mock_client = AsyncMock()
    mock_client.get.return_value.json.return_value = {
        "id": 1, "name": "Alice"
    }

    result = await fetch_user(mock_client, 1)

    mock_client.get.assert_called_once_with("/users/1")
    assert result["name"] == "Alice"

# patch 데코레이터로 의존성 교체
@patch("requests.get")
def test_with_patch(mock_get):
    mock_get.return_value.json.return_value = {"status": "ok"}
    import requests
    resp = requests.get("https://api.example.com")
    assert resp.json()["status"] == "ok"
    mock_get.assert_called_once_with("https://api.example.com")

# fixture + mock 조합
@pytest.fixture
def mock_db():
    db = MagicMock()
    db.find.return_value = [{"id": 1}, {"id": 2}]
    return db

def test_service(mock_db):
    results = mock_db.find({"active": True})
    assert len(results) == 2
    mock_db.find.assert_called_once_with({"active": True})
알아두면 좋은 점

AsyncMockawait가 가능한 Mock 객체입니다. Python 3.8+에서 기본 제공됩니다.

자주 하는 실수

@patch는 테스트 함수의 인수로 Mock 객체를 전달할 때 데코레이터 순서(아래→위)와 인수 순서(왼→오)가 일치해야 합니다.

03hypothesis 기반 속성 테스트

랜덤 입력으로 엣지 케이스를 자동 탐색하는 테스트

Python code

<span class="cm">// hypothesis 기반 속성 테스트 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("hypothesis 기반 속성 테스트") }
알아두면 좋은 점

PYTHON 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

04pytest 심화

pytest의 고급 기능: 마커, 플러그인, 설정, 실행 옵션을 다룹니다.

Python code

# pytest 테스트 예제 (실행: pytest test_example.py -v)
import pytest

# 기본 테스트
def test_addition():
    assert 1 + 1 == 2

def test_string():
    assert "hello".upper() == "HELLO"

# 예외 테스트
def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_value_error_message():
    with pytest.raises(ValueError, match="invalid"):
        raise ValueError("invalid input")

# 마커
@pytest.mark.slow
def test_slow_operation():
    import time
    time.sleep(0.01)
    assert True

@pytest.mark.skip(reason="아직 구현 안 됨")
def test_future_feature():
    pass

@pytest.mark.skipif(
    __import__("sys").version_info < (3, 11),
    reason="Python 3.11+ 필요"
)
def test_new_feature():
    assert True

# 근사값 비교
def test_floating_point():
    assert 0.1 + 0.2 == pytest.approx(0.3)
    assert [0.1, 0.2] == pytest.approx([0.1, 0.2], abs=1e-10)

# 실행: pytest -v --tb=short -k "not slow"
print("pytest 테스트 패턴 준비 완료")
알아두면 좋은 점

pytest -k "keyword"로 특정 테스트만 실행하고, -x로 첫 실패 시 중단할 수 있습니다.

자주 하는 실수

테스트 함수명이 test_로 시작하지 않으면 pytest가 자동 수집하지 않습니다.

05fixture 활용

pytest fixture로 테스트 전후 설정과 정리를 구조화합니다.

Python code

import pytest

# 기본 fixture
@pytest.fixture
def sample_users():
    return [
        {"name": "Alice", "age": 30},
        {"name": "Bob", "age": 25},
    ]

def test_user_count(sample_users):
    assert len(sample_users) == 2

def test_user_name(sample_users):
    assert sample_users[0]["name"] == "Alice"

# Setup / Teardown (yield fixture)
@pytest.fixture
def database():
    db = {"connected": True, "data": []}
    print("\n  DB 연결")
    yield db
    print("  DB 해제")
    db["connected"] = False

def test_insert(database):
    database["data"].append("item1")
    assert len(database["data"]) == 1

# fixture 스코프
@pytest.fixture(scope="module")
def expensive_resource():
    print("\n  [모듈] 비싼 리소스 생성")
    return {"ready": True}

# 파라미터화된 fixture
@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def db_engine(request):
    return {"engine": request.param}

def test_engine(db_engine):
    assert "engine" in db_engine

# conftest.py에 공유 fixture 정의
# 파일: conftest.py
# @pytest.fixture(autouse=True)
# def reset_state():
#     yield
#     # 테스트 후 상태 초기화
print("fixture 패턴 준비 완료")
알아두면 좋은 점

conftest.py에 fixture를 정의하면 같은 디렉터리와 하위 디렉터리의 모든 테스트에서 자동으로 사용 가능합니다.

자주 하는 실수

scope="session" fixture에서 mutable 데이터를 공유하면 테스트 간 간섭이 발생합니다.

06파라미터화 테스트

@pytest.mark.parametrize로 여러 입력값에 대해 같은 테스트를 반복 실행합니다.

Python code

import pytest

# 기본 파라미터화
@pytest.mark.parametrize("input,expected", [
    (1, 1),
    (2, 4),
    (3, 9),
    (-1, 1),
    (0, 0),
])
def test_square(input, expected):
    assert input ** 2 == expected

# 여러 파라미터 조합
@pytest.mark.parametrize("a,b,result", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
def test_add(a, b, result):
    assert a + b == result

# ID 지정으로 가독성 향상
@pytest.mark.parametrize("text,expected", [
    pytest.param("hello", "HELLO", id="basic"),
    pytest.param("", "", id="empty"),
    pytest.param("Hello World", "HELLO WORLD", id="mixed"),
], ids=str)
def test_upper(text, expected):
    assert text.upper() == expected

# 다중 parametrize (데카르트 곱)
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert x * y > 0  # 4개 조합 테스트

# 조건부 건너뛰기
@pytest.mark.parametrize("n", [
    1,
    pytest.param(2, marks=pytest.mark.skip(reason="알려진 버그")),
    3,
])
def test_with_skip(n):
    assert n > 0

print("파라미터화 테스트 패턴 준비 완료")
알아두면 좋은 점

pytest.param(..., id="설명")으로 각 테스트 케이스에 설명을 추가하면 실패 시 원인 파악이 쉽습니다.

자주 하는 실수

파라미터 조합이 너무 많으면 테스트 시간이 폭발적으로 증가합니다. 핵심 경계값과 대표값만 선택하세요.

07mock/patch

unittest.mock으로 외부 의존성을 대체하여 단위 테스트를 격리합니다.

Python code

from unittest.mock import Mock, patch, MagicMock

# 기본 Mock
mock_db = Mock()
mock_db.query.return_value = [{"id": 1, "name": "Alice"}]
result = mock_db.query("SELECT * FROM users")
print(f"Mock 결과: {result}")
mock_db.query.assert_called_once_with("SELECT * FROM users")

# side_effect: 동적 반환값
mock_api = Mock()
mock_api.fetch.side_effect = [
    {"status": 200},       # 첫 번째 호출
    {"status": 200},       # 두 번째 호출
    ConnectionError("실패"), # 세 번째 호출
]
print(mock_api.fetch())
print(mock_api.fetch())
try:
    mock_api.fetch()
except ConnectionError as e:
    print(f"예외: {e}")

# patch 데코레이터 패턴
def get_current_time():
    from datetime import datetime
    return datetime.now().isoformat()

# 테스트에서 시간 고정
with patch("datetime.datetime") as mock_dt:
    from datetime import datetime
    mock_dt.now.return_value = datetime(2024, 1, 1, 12, 0)
    # get_current_time() 이 항상 같은 시간 반환

# spec으로 인터페이스 강제
class UserService:
    def get_user(self, user_id: int) -> dict: ...
    def create_user(self, name: str) -> dict: ...

mock_service = Mock(spec=UserService)
mock_service.get_user.return_value = {"name": "Alice"}
print(mock_service.get_user(1))
# mock_service.non_existent()  # AttributeError (spec 위반)
알아두면 좋은 점

spec=True를 사용하면 Mock이 실제 객체의 인터페이스를 따르게 하여 오타로 인한 테스트 통과를 방지합니다.

자주 하는 실수

너무 많은 것을 Mock하면 테스트가 구현에 결합됩니다. 외부 의존성(DB, API, 파일)만 Mock하세요.

08통합 테스트

여러 컴포넌트의 상호작용을 검증하는 통합 테스트 패턴입니다.

Python code

import pytest

# 테스트할 시스템
class UserRepository:
    def __init__(self):
        self._store = {}
        self._next_id = 1
    def save(self, data):
        data["id"] = self._next_id
        self._store[self._next_id] = data
        self._next_id += 1
        return data
    def find(self, uid):
        return self._store.get(uid)

class EmailService:
    def __init__(self):
        self.sent = []
    def send(self, to, subject):
        self.sent.append({"to": to, "subject": subject})

class UserService:
    def __init__(self, repo, email):
        self.repo = repo
        self.email = email
    def register(self, name, email):
        user = self.repo.save({"name": name, "email": email})
        self.email.send(email, f"환영합니다 {name}님!")
        return user

# 통합 테스트 (실제 객체 사용)
@pytest.fixture
def user_system():
    repo = UserRepository()
    email = EmailService()
    service = UserService(repo, email)
    return {"service": service, "repo": repo, "email": email}

def test_registration_flow(user_system):
    svc = user_system["service"]
    user = svc.register("Alice", "alice@test.com")

    # 저장 확인
    saved = user_system["repo"].find(user["id"])
    assert saved["name"] == "Alice"

    # 이메일 발송 확인
    emails = user_system["email"].sent
    assert len(emails) == 1
    assert emails[0]["to"] == "alice@test.com"
    print("통합 테스트 통과!")

# 직접 실행
system = {"service": UserService(UserRepository(), EmailService()),
          "repo": UserRepository(), "email": EmailService()}
system["service"] = UserService(system["repo"], system["email"])
test_registration_flow(system)
알아두면 좋은 점

통합 테스트는 단위 테스트보다 느리므로 CI에서 별도 스테이지로 분리하세요.

자주 하는 실수

통합 테스트에서 외부 서비스(실제 DB, API)에 의존하면 불안정해집니다. 테스트용 인프라를 사용하세요.

09프로퍼티 기반 테스트 (hypothesis)

hypothesis 라이브러리로 무작위 입력을 자동 생성하여 속성(property)을 검증합니다.

Python code

# hypothesis 패턴 시뮬레이션
import random

def property_test(func, generator, num_tests=100):
    """간단한 프로퍼티 기반 테스트 러너"""
    for i in range(num_tests):
        test_input = generator()
        try:
            func(test_input)
        except AssertionError as e:
            print(f"  실패! 입력: {test_input}")
            raise
    print(f"  {num_tests}개 테스트 통과!")

# 테스트할 함수
def my_sort(lst):
    return sorted(lst)

# 속성 1: 정렬 결과의 길이는 입력과 같다
def test_sort_preserves_length(data):
    result = my_sort(data)
    assert len(result) == len(data)

# 속성 2: 결과는 정렬되어 있다
def test_sort_is_ordered(data):
    result = my_sort(data)
    assert all(result[i] <= result[i+1] for i in range(len(result)-1))

# 속성 3: 모든 원소가 보존된다
def test_sort_preserves_elements(data):
    result = my_sort(data)
    assert sorted(result) == sorted(data)

# 무작위 입력 생성기
def random_int_list():
    return [random.randint(-100, 100)
            for _ in range(random.randint(0, 20))]

print("=== 길이 보존 ===")
property_test(test_sort_preserves_length, random_int_list)
print("=== 정렬 순서 ===")
property_test(test_sort_is_ordered, random_int_list)
print("=== 원소 보존 ===")
property_test(test_sort_preserves_elements, random_int_list)

# 실제 hypothesis 사용법:
# from hypothesis import given, strategies as st
# @given(st.lists(st.integers()))
# def test_sort(data):
#     assert sorted(data) == sorted(sorted(data))
알아두면 좋은 점

프로퍼티 기반 테스트는 개발자가 생각하지 못한 엣지 케이스를 자동으로 찾아줍니다.

자주 하는 실수

프로퍼티를 정의하기 어려운 경우가 있습니다. 먼저 "불변조건"과 "왕복 변환(roundtrip)" 속성을 시도하세요.

10BDD (behave)

행동 주도 개발(BDD)으로 비즈니스 요구사항을 실행 가능한 테스트로 작성합니다.

Python code

# BDD 스타일 테스트 프레임워크 시뮬레이션
class BDDTest:
    def __init__(self, feature: str):
        self.feature = feature
        self.context = {}
        print(f"Feature: {feature}")

    def scenario(self, name: str):
        print(f"\n  Scenario: {name}")
        self.context = {}
        return self

    def given(self, desc: str, action=None):
        print(f"    Given {desc}")
        if action:
            action(self.context)
        return self

    def when(self, desc: str, action=None):
        print(f"    When {desc}")
        if action:
            action(self.context)
        return self

    def then(self, desc: str, check=None):
        print(f"    Then {desc}")
        if check:
            check(self.context)
            print("      ✅ 통과")
        return self

# 테스트 대상
class Cart:
    def __init__(self):
        self.items = []
    def add(self, item, price):
        self.items.append({"item": item, "price": price})
    @property
    def total(self):
        return sum(i["price"] for i in self.items)

# BDD 테스트 실행
test = BDDTest("장바구니 기능")

(test.scenario("상품 추가")
    .given("빈 장바구니가 있다",
           lambda ctx: ctx.update({"cart": Cart()}))
    .when("노트북을 추가한다",
          lambda ctx: ctx["cart"].add("노트북", 1500000))
    .when("마우스를 추가한다",
          lambda ctx: ctx["cart"].add("마우스", 50000))
    .then("상품이 2개이다",
          lambda ctx: assert_(len(ctx["cart"].items) == 2))
    .then("총액은 1,550,000원이다",
          lambda ctx: assert_(ctx["cart"].total == 1550000)))

def assert_(condition):
    assert condition
알아두면 좋은 점

실제 BDD는 pip install behave로 Gherkin 문법(.feature 파일)을 사용합니다.

자주 하는 실수

BDD 시나리오가 구현 세부사항을 포함하면 리팩토링 시 테스트가 깨집니다. 비즈니스 관점에서 작성하세요.

11커버리지

테스트 커버리지를 측정하고 분석하여 테스트되지 않은 코드를 찾습니다.

Python code

# coverage 라이브러리 사용 패턴 시뮬레이션
# pip install coverage pytest-cov

# 테스트 대상 코드
def calculate_grade(score: int) -> str:
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    elif score >= 60:
        return "D"
    else:
        return "F"

def is_prime(n: int) -> bool:
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False
    for i in range(3, int(n**0.5) + 1, 2):
        if n % i == 0:
            return False
    return True

# 테스트
def test_grade():
    assert calculate_grade(95) == "A"
    assert calculate_grade(85) == "B"
    assert calculate_grade(75) == "C"
    assert calculate_grade(65) == "D"
    assert calculate_grade(50) == "F"
    print("test_grade 통과!")

def test_prime():
    assert not is_prime(1)
    assert is_prime(2)
    assert is_prime(3)
    assert not is_prime(4)
    assert is_prime(17)
    print("test_prime 통과!")

test_grade()
test_prime()

# 실행 방법:
# pytest --cov=src --cov-report=html
# coverage run -m pytest
# coverage report --show-missing
# coverage html  → htmlcov/index.html
print("\n커버리지 명령어:")
print("  pytest --cov=. --cov-report=term-missing")
알아두면 좋은 점

--cov-report=term-missing으로 커버되지 않은 줄 번호를 터미널에서 바로 확인할 수 있습니다.

자주 하는 실수

100% 커버리지가 버그 없음을 의미하지 않습니다. 커버리지는 테스트되지 않은 코드를 찾는 도구일 뿐입니다.

12벤치마크 (pytest-benchmark)

코드의 성능을 체계적으로 측정하고 회귀를 감지하는 벤치마크 테스트입니다.

Python code

import time
from functools import lru_cache

# 벤치마크 대상 함수들
def fib_recursive(n):
    if n < 2:
        return n
    return fib_recursive(n-1) + fib_recursive(n-2)

@lru_cache(maxsize=None)
def fib_cached(n):
    if n < 2:
        return n
    return fib_cached(n-1) + fib_cached(n-2)

def fib_iterative(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

# 간단한 벤치마크 러너
def benchmark(func, *args, iterations=1000):
    start = time.perf_counter()
    for _ in range(iterations):
        result = func(*args)
    elapsed = time.perf_counter() - start
    avg = elapsed / iterations * 1_000_000  # 마이크로초
    print(f"  {func.__name__:20s}: {avg:>8.2f} μs/호출 "
          f"({iterations}회, 총 {elapsed:.3f}초)")
    return result

n = 20
print(f"=== 피보나치({n}) 벤치마크 ===")
benchmark(fib_recursive, n, iterations=100)
fib_cached.cache_clear()
benchmark(fib_cached, n, iterations=10000)
benchmark(fib_iterative, n, iterations=10000)

# pytest-benchmark 사용법:
# def test_fib(benchmark):
#     result = benchmark(fib_iterative, 20)
#     assert result == 6765
# 실행: pytest --benchmark-only
print("\npytest 명령: pytest --benchmark-compare")
알아두면 좋은 점

pytest-benchmark는 통계적 분석(평균, 중간값, 표준편차)과 이전 결과와의 비교를 자동으로 제공합니다.

자주 하는 실수

벤치마크 중 다른 프로세스가 CPU를 사용하면 결과가 불안정합니다. --benchmark-disable-gc와 격리된 환경을 사용하세요.

13스냅샷 테스트

스냅샷 테스트로 복잡한 출력을 저장하고 변경 여부를 자동 감지합니다.

Python code

import json
import hashlib

class SnapshotTester:
    def __init__(self):
        self._snapshots: dict[str, str] = {}

    def assert_match(self, name: str, value) -> bool:
        serialized = json.dumps(value, sort_keys=True,
                                ensure_ascii=False, indent=2)
        checksum = hashlib.md5(serialized.encode()).hexdigest()[:8]

        if name not in self._snapshots:
            self._snapshots[name] = serialized
            print(f"  📸 스냅샷 저장: {name} [{checksum}]")
            return True

        if self._snapshots[name] == serialized:
            print(f"  ✅ 스냅샷 일치: {name} [{checksum}]")
            return True

        print(f"  ❌ 스냅샷 불일치: {name}")
        print(f"    기대: {self._snapshots[name][:50]}...")
        print(f"    실제: {serialized[:50]}...")
        return False

# 사용 예시
def generate_report(users):
    return {
        "total": len(users),
        "names": sorted(u["name"] for u in users),
        "avg_age": sum(u["age"] for u in users) / len(users),
    }

snap = SnapshotTester()

users = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 25},
]

report = generate_report(users)
snap.assert_match("user_report", report)
snap.assert_match("user_report", report)  # 일치

# 변경 감지
report["total"] = 999
snap.assert_match("user_report", report)  # 불일치!

# 실제 사용: pip install pytest-snapshot 또는 syrupy
# def test_report(snapshot):
#     assert generate_report(users) == snapshot
알아두면 좋은 점

스냅샷 파일은 버전 관리에 포함하여 코드 리뷰 시 출력 변경을 확인하세요.

자주 하는 실수

스냅샷을 무분별하게 업데이트(--snapshot-update)하면 의도하지 않은 변경이 승인될 수 있습니다. 항상 diff를 확인하세요.

14뮤테이션 테스트

뮤테이션 테스트로 테스트 스위트의 품질을 검증합니다. 코드를 의도적으로 변경하여 테스트가 실패하는지 확인합니다.

Python code

import copy
import ast

# 간단한 뮤테이션 테스트 시뮬레이션
def is_adult(age: int) -> bool:
    return age >= 18

def calculate_discount(price: float, rate: float) -> float:
    if rate < 0 or rate > 1:
        raise ValueError("할인율은 0~1 사이")
    return price * (1 - rate)

# 테스트 스위트
def test_is_adult():
    assert is_adult(18) == True
    assert is_adult(17) == False
    assert is_adult(0) == False
    assert is_adult(100) == True

def test_discount():
    assert calculate_discount(10000, 0.1) == 9000
    assert calculate_discount(10000, 0) == 10000

# 뮤턴트 생성 및 테스트
mutations = [
    ("age >= 18 → age > 18", lambda age: age > 18),
    ("age >= 18 → age >= 19", lambda age: age >= 19),
    ("age >= 18 → age <= 18", lambda age: age <= 18),
    ("age >= 18 → True", lambda age: True),
]

print("=== 뮤테이션 테스트 ===")
killed = 0
for desc, mutant in mutations:
    try:
        assert mutant(18) == True
        assert mutant(17) == False
        assert mutant(0) == False
        print(f"  ☠️ 살아남음: {desc}")  # 테스트 부족!
    except AssertionError:
        killed += 1
        print(f"  ✅ 잡힘: {desc}")

total = len(mutations)
score = killed / total * 100
print(f"\n뮤테이션 점수: {killed}/{total} ({score:.0f}%)")

# 실제 사용: pip install mutmut
# mutmut run --paths-to-mutate=src/
알아두면 좋은 점

뮤테이션 점수가 높을수록 테스트 스위트가 코드 변경을 잘 감지합니다. 80% 이상을 목표로 하세요.

자주 하는 실수

뮤테이션 테스트는 시간이 오래 걸립니다. CI에서는 변경된 파일에 대해서만 실행하세요.

15CI/CD 테스트

GitHub Actions 등 CI/CD 파이프라인에서 테스트를 자동화하는 설정입니다.

Python code

# .github/workflows/test.yml 내용
ci_config = """
name: Python Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

    steps:
    - uses: actions/checkout@v4
    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        pip install -e ".[dev]"
    - name: Lint
      run: ruff check src/
    - name: Type check
      run: mypy src/
    - name: Test
      run: pytest --cov=src --cov-report=xml -v
    - name: Upload coverage
      uses: codecov/codecov-action@v3
"""
print(ci_config)

# pyproject.toml 테스트 설정
pyproject_testing = """
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short --strict-markers"
markers = [
    "slow: 느린 테스트",
    "integration: 통합 테스트",
]

[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*"]

[tool.coverage.report]
fail_under = 80
show_missing = true
"""
print(pyproject_testing)
알아두면 좋은 점

Python 버전 매트릭스로 여러 버전에서 동시에 테스트하면 호환성 문제를 조기에 발견합니다.

자주 하는 실수

CI에서 pip install -r requirements.txt만 하면 dev 의존성(pytest 등)이 설치되지 않습니다. [dev] 그룹을 포함하세요.

정리하며

  • 테스트는 클래스 없이 함수와 fixture로 조합하고 assert 한 줄로 검증합니다.
  • patch는 정의된 모듈이 아니라 그 이름을 참조하는 모듈 경로에 겁니다.
  • 입력 조합이 많아지면 parametrize로 늘리고, 경계값 탐색은 hypothesis에 맡깁니다.
  • 커버리지는 실행 비율일 뿐입니다. 숫자보다 실패를 잡아내는 단언의 유무를 봅니다.

더 깊이 들어가고 싶다면 Python 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.