Pytest 파라미터화로 테스트 케이스 수 줄이기

Pytest 파라미터화로 테스트 케이스 수 줄이기

Pytest 파라미터화로 테스트 케이스 수 줄이기

같은 코드를 100번 복붙하던 날

월요일 아침이다. 커피를 들고 자리에 앉았다. 어제 작성한 테스트 코드를 열었다.

def test_login_with_valid_email():
    result = login("test@email.com", "password123")
    assert result == True

def test_login_with_invalid_email():
    result = login("invalid", "password123")
    assert result == False

def test_login_with_empty_email():
    result = login("", "password123")
    assert result == False

50개가 넘는다. 로직은 똑같다. 입력값만 다르다.

스크롤을 내리다가 한숨이 나왔다. 이게 맞나 싶었다. 개발자 후배가 옆에서 말했다. “누나 이거 반복문 아닌가요?”

맞다. 반복문이다. 근데 테스트 코드에서 반복문 쓰면 실패 케이스 찾기 어렵다. 어떤 입력값에서 터졌는지 모른다.

“그럼 파라미터화 쓰세요.”

파라미터화가 뭔데

Pytest의 @pytest.mark.parametrize다. 같은 테스트 로직에 여러 데이터를 넣어서 돌린다. 각 케이스가 독립적으로 실행된다.

점심 먹고 문서를 찾아봤다. 예제를 따라 쳐봤다.

import pytest

@pytest.mark.parametrize("email,password,expected", [
    ("test@email.com", "password123", True),
    ("invalid", "password123", False),
    ("", "password123", False),
    ("test@email.com", "", False),
    ("admin@test.com", "admin123", True),
])
def test_login(email, password, expected):
    result = login(email, password)
    assert result == expected

50개 함수가 5줄로 줄었다.

터미널에서 pytest를 돌렸다. 결과가 이렇게 나왔다.

test_login.py::test_login[test@email.com-password123-True] PASSED
test_login.py::test_login[invalid-password123-False] PASSED
test_login.py::test_login[-password123-False] PASSED
test_login.py::test_login[test@email.com--False] FAILED
test_login.py::test_login[admin@test.com-admin123-True] PASSED

어떤 파라미터에서 실패했는지 바로 보인다. 이거다.

실전에 적용하기

오후 3시. 회원가입 API 테스트를 다시 짰다. 기존 코드는 30개 함수였다. 이메일 형식, 비밀번호 길이, 닉네임 검증 다 따로였다.

@pytest.mark.parametrize("email,password,nickname,expected_status", [
    # 정상 케이스
    ("user1@test.com", "Pass1234!", "테스터", 201),
    ("user2@test.com", "Valid123!", "QA엔지니어", 201),
    
    # 이메일 검증
    ("invalid-email", "Pass1234!", "테스터", 400),
    ("", "Pass1234!", "테스터", 400),
    ("test@", "Pass1234!", "테스터", 400),
    
    # 비밀번호 검증
    ("user@test.com", "short", "테스터", 400),
    ("user@test.com", "12345678", "테스터", 400),
    ("user@test.com", "", "테스터", 400),
    
    # 닉네임 검증
    ("user@test.com", "Pass1234!", "", 400),
    ("user@test.com", "Pass1234!", "a", 400),
    ("user@test.com", "Pass1234!", "a"*21, 400),
])
def test_signup(email, password, nickname, expected_status):
    response = api_client.post("/signup", {
        "email": email,
        "password": password,
        "nickname": nickname
    })
    assert response.status_code == expected_status

30개에서 12개 케이스로 정리됐다. 코드는 15줄이다.

팀장이 코드리뷰에서 물었다. “이거 실패하면 어떤 케이스인지 알기 쉬워요?”

pytest를 -v 옵션으로 돌렸다.

test_signup[user1@test.com-Pass1234!-테스터-201] PASSED
test_signup[invalid-email-Pass1234!-테스터-400] PASSED
test_signup[user@test.com-short-테스터-400] FAILED

바로 보인다. password="short" 케이스가 터졌다.

ids로 테스트 이름 붙이기

문제가 하나 있었다. 파라미터가 길면 터미널 출력이 난잡하다.

test_api[https://api.test.com/v1/users-POST-{"name":"test","age":25}-200] PASSED

읽기 어렵다. 무슨 테스트인지 모르겠다.

ids 파라미터를 추가했다.

@pytest.mark.parametrize("url,method,data,expected", [
    ("https://api.test.com/v1/users", "POST", {"name":"test"}, 200),
    ("https://api.test.com/v1/users", "GET", None, 200),
    ("https://api.test.com/v1/orders", "POST", {"item":"A"}, 201),
], ids=[
    "create_user",
    "get_users",
    "create_order",
])
def test_api_calls(url, method, data, expected):
    response = api_request(url, method, data)
    assert response.status_code == expected

출력이 깔끔해졌다.

test_api[create_user] PASSED
test_api[get_users] PASSED
test_api[create_order] PASSED

리포트도 보기 좋다. 실패해도 한눈에 파악된다.

여러 파라미터 조합하기

다음 날이다. 로그인 테스트에 브라우저별 검증이 추가됐다. Chrome, Firefox, Safari 각각 테스트해야 한다.

기존 방식이면 케이스가 3배다. 파라미터화를 중첩했다.

@pytest.mark.parametrize("browser", ["chrome", "firefox", "safari"])
@pytest.mark.parametrize("email,password,expected", [
    ("test@email.com", "password123", True),
    ("invalid", "password123", False),
    ("", "password123", False),
])
def test_login_multi_browser(browser, email, password, expected):
    driver = get_driver(browser)
    result = login_with_driver(driver, email, password)
    assert result == expected
    driver.quit()

3개 브라우저 × 3개 케이스 = 9개 테스트가 자동으로 생성된다.

test_login_multi_browser[chrome-test@email.com-password123-True] PASSED
test_login_multi_browser[chrome-invalid-password123-False] PASSED
test_login_multi_browser[chrome--password123-False] PASSED
test_login_multi_browser[firefox-test@email.com-password123-True] PASSED
...

코드는 10줄이다. 수동으로 짜면 27개 함수였다.

fixture와 같이 쓰기

금요일 오후다. 결제 테스트를 작성 중이다. 각 테스트마다 사용자 데이터가 필요하다.

fixture와 파라미터화를 조합했다.

@pytest.fixture
def user_with_balance(request):
    balance = request.param
    user = create_test_user()
    user.add_balance(balance)
    yield user
    user.delete()

@pytest.mark.parametrize("user_with_balance,amount,expected", [
    (10000, 5000, "success"),
    (10000, 10000, "success"),
    (10000, 15000, "insufficient"),
    (0, 1000, "insufficient"),
], indirect=["user_with_balance"])
def test_payment(user_with_balance, amount, expected):
    result = process_payment(user_with_balance, amount)
    assert result.status == expected

indirect 옵션이 핵심이다. user_with_balance 값이 fixture의 request.param으로 들어간다.

각 테스트마다 잔액이 다른 사용자가 생성된다. 테스트 종료 후 자동으로 삭제된다.

CSV 파일로 테스트 데이터 관리

다음 주 월요일이다. PM이 엑셀로 테스트 케이스를 보내왔다. 50개다.

하나씩 옮기기 싫었다. CSV로 저장하고 파일을 읽었다.

import csv
import pytest

def load_test_data(filename):
    with open(filename, 'r', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        return [(row['email'], row['password'], row['expected']) 
                for row in reader]

@pytest.mark.parametrize("email,password,expected", 
                         load_test_data('login_cases.csv'))
def test_login_from_csv(email, password, expected):
    result = login(email, password)
    assert result == (expected == 'True')

CSV 파일:

email,password,expected
test@email.com,password123,True
invalid,password123,False
admin@test.com,admin123,True

케이스 추가는 CSV만 수정하면 된다. 코드는 안 건드린다.

PM이 케이스를 추가해도 나는 커밋만 하면 끝이다. 비개발자도 테스트 케이스를 관리할 수 있다.

xfail과 skip 활용

수요일 오전이다. 개발자가 말했다. “이 케이스는 알려진 버그예요. 다음 주에 고칩니다.”

그냥 실패하게 놔두면 CI가 빨개진다. xfail을 썼다.

@pytest.mark.parametrize("input,expected", [
    ("valid", "success"),
    pytest.param("bug_case", "success", marks=pytest.mark.xfail(reason="Known bug #123")),
    ("another", "success"),
])
def test_with_known_bug(input, expected):
    result = process(input)
    assert result == expected

bug_case는 실패해도 빌드가 깨지지 않는다. 리포트에 XFAIL로 표시된다.

모바일 테스트도 비슷하게 했다.

@pytest.mark.parametrize("device,action,expected", [
    ("iOS", "swipe", "success"),
    pytest.param("Android", "swipe", "success", marks=pytest.mark.skipif(
        condition=True, reason="Android swipe not implemented")),
    ("iOS", "tap", "success"),
])
def test_mobile_actions(device, action, expected):
    result = perform_action(device, action)
    assert result == expected

Android 케이스는 아예 건너뛴다. 구현되면 조건만 바꾸면 된다.

유지보수 고민

이제 팀 전체가 파라미터화를 쓴다. 후배가 물었다. “파라미터 너무 많으면 어떡해요?”

맞는 말이다. 파라미터가 7개 넘어가면 읽기 어렵다.

이럴 땐 데이터클래스를 쓴다.

from dataclasses import dataclass

@dataclass
class LoginTestCase:
    email: str
    password: str
    remember_me: bool
    user_agent: str
    ip_address: str
    expected_status: int
    expected_token: bool

test_cases = [
    LoginTestCase("test@email.com", "pass123", True, "Chrome", "1.1.1.1", 200, True),
    LoginTestCase("invalid", "pass123", False, "Firefox", "1.1.1.2", 400, False),
]

@pytest.mark.parametrize("case", test_cases, 
                         ids=lambda c: f"{c.email[:10]}")
def test_login_complex(case):
    result = login(
        case.email, 
        case.password, 
        case.remember_me,
        case.user_agent,
        case.ip_address
    )
    assert result.status_code == case.expected_status
    assert result.has_token == case.expected_token

타입 힌트도 있고 읽기도 쉽다. IDE에서 자동완성도 된다.

성능 문제

금요일 저녁이다. CI가 느려졌다. 파라미터화한 테스트가 100개가 넘는다.

하나하나 DB setup/teardown 하느라 20분 걸린다.

scope를 조정했다.

@pytest.fixture(scope="module")
def db_connection():
    conn = create_db()
    yield conn
    conn.close()

@pytest.mark.parametrize("data,expected", test_data)
def test_with_shared_db(db_connection, data, expected):
    result = query(db_connection, data)
    assert result == expected

fixture를 module 단위로 공유한다. DB 연결은 한 번만 한다.

실행 시간이 20분에서 5분으로 줄었다.

병렬 실행도 추가했다.

pytest -n 4 tests/

pytest-xdist로 4개 프로세스가 돈다. 3분으로 더 줄었다.

실패 분석이 쉬워짐

월요일 아침이다. 밤새 돌린 테스트 결과를 봤다. 12개가 실패했다.

예전 같으면 하나씩 함수를 찾아야 했다. 지금은 파라미터만 보면 된다.

test_api[POST-/users-invalid_email] FAILED
test_api[POST-/users-empty_password] FAILED
test_api[PUT-/users/123-unauthorized] FAILED

패턴이 보인다. POST 요청 validation이 전부 깨졌다.

백엔드에서 validation 로직을 수정했다. 한 곳만 고치면 된다.

def validate_user_input(data):
    # 이 부분이 버그였다
    if not data.get("email"):
        raise ValidationError("Email required")

코드 한 줄 수정하고 다시 돌렸다. 12개 전부 통과했다.

파라미터화가 없었으면 12개 함수를 다 열어봐야 했다. 지금은 3분 만에 끝났다.

통계도 명확해짐

주간 회의다. 테스트 현황을 보고했다.

“로그인 테스트 45개 케이스, 회원가입 32개, 결제 28개 전부 통과했습니다.”

팀장이 물었다. “각 기능당 몇 개씩인가요?”

파라미터화 덕분에 바로 답했다.

# 로그인: 3개 브라우저 × 15개 케이스 = 45개
# 회원가입: 4개 필드 × 8개 검증 = 32개
# 결제: 7개 상태 × 4개 금액 = 28개

코드 구조와 실제 테스트 수가 일치한다. 커버리지 계산도 정확하다.

리팩토링도 쉬움

다음 날이다. API 스펙이 바뀌었다. user_iduserId로 변경됐다.

기존 방식이었으면 50개 함수를 다 수정해야 했다. 지금은 한 곳만 고친다.

@pytest.mark.parametrize("user_data,expected", test_data)
def test_user_api(user_data, expected):
    # 여기만 수정
    payload = {
        "userId": user_data["user_id"],  # 키 변경
        "userName": user_data["user_name"]
    }
    response = api_client.post("/users", payload)
    assert response.status_code == expected

3분 만에 끝났다. 테스트를 다시 돌렸다. 전부 통과했다.

문서화 효과

금요일 오후다. 신입이 왔다. 온보딩 중이다.

“로그인 테스트 케이스가 뭐예요?”

코드를 보여줬다.

@pytest.mark.parametrize("email,password,expected", [
    ("valid@test.com", "Pass123!", True),      # 정상 로그인
    ("invalid", "Pass123!", False),            # 이메일 형식 오류
    ("valid@test.com", "short", False),        # 비밀번호 길이 부족
    ("valid@test.com", "12345678", False),     # 특수문자 없음
    ("", "Pass123!", False),                   # 빈 이메일
    ("valid@test.com", "", False),             # 빈 비밀번호
], ids=[
    "valid_login",
    "invalid_email_format",
    "short_password",
    "no_special_char",
    "empty_email",
    "empty_password",
])
def test_login(email, password, expected):
    result = login(email, password)
    assert result == expected

“여기 다 있어요. 주석이 곧 문서입니다.”

신입이 바로 이해했다. 어떤 케이스를 테스트하는지 코드만 봐도 안다.

별도 문서가 필요 없다. 코드와 문서가 동기화된다.


파라미터화는 단순히 코드를 줄이는 게 아니다. 테스트를 데이터로 관리하는 사고방식이다. 유지보수가 쉽고 확장이 편하다. 한 번 익히면 돌아갈 수 없다.