Showing Posts From
Pytest
- 16 Dec, 2025
Pytest 파라미터화로 테스트 케이스 수 줄이기
Pytest 파라미터화로 테스트 케이스 수 줄이기 같은 코드를 100번 복붙하던 날 월요일 아침이다. 커피를 들고 자리에 앉았다. 어제 작성한 테스트 코드를 열었다. def test_login_with_valid_email(): result = login("test@email.com", "password123") assert result == Truedef test_login_with_invalid_email(): result = login("invalid", "password123") assert result == Falsedef test_login_with_empty_email(): result = login("", "password123") assert result == False50개가 넘는다. 로직은 똑같다. 입력값만 다르다. 스크롤을 내리다가 한숨이 나왔다. 이게 맞나 싶었다. 개발자 후배가 옆에서 말했다. "누나 이거 반복문 아닌가요?" 맞다. 반복문이다. 근데 테스트 코드에서 반복문 쓰면 실패 케이스 찾기 어렵다. 어떤 입력값에서 터졌는지 모른다. "그럼 파라미터화 쓰세요."파라미터화가 뭔데 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 == expected50개 함수가 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_status30개에서 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 == expectedindirect 옵션이 핵심이다. user_with_balance 값이 fixture의 request.param으로 들어간다. 각 테스트마다 잔액이 다른 사용자가 생성된다. 테스트 종료 후 자동으로 삭제된다.CSV 파일로 테스트 데이터 관리 다음 주 월요일이다. PM이 엑셀로 테스트 케이스를 보내왔다. 50개다. 하나씩 옮기기 싫었다. CSV로 저장하고 파일을 읽었다. import csv import pytestdef 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 == expectedbug_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 == expectedAndroid 케이스는 아예 건너뛴다. 구현되면 조건만 바꾸면 된다. 유지보수 고민 이제 팀 전체가 파라미터화를 쓴다. 후배가 물었다. "파라미터 너무 많으면 어떡해요?" 맞는 말이다. 파라미터가 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: booltest_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 == expectedfixture를 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_id가 userId로 변경됐다. 기존 방식이었으면 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 == expected3분 만에 끝났다. 테스트를 다시 돌렸다. 전부 통과했다. 문서화 효과 금요일 오후다. 신입이 왔다. 온보딩 중이다. "로그인 테스트 케이스가 뭐예요?" 코드를 보여줬다. @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"여기 다 있어요. 주석이 곧 문서입니다." 신입이 바로 이해했다. 어떤 케이스를 테스트하는지 코드만 봐도 안다. 별도 문서가 필요 없다. 코드와 문서가 동기화된다.파라미터화는 단순히 코드를 줄이는 게 아니다. 테스트를 데이터로 관리하는 사고방식이다. 유지보수가 쉽고 확장이 편하다. 한 번 익히면 돌아갈 수 없다.
- 08 Dec, 2025
Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법
Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법 야근의 시작 금요일 오후 6시. 퇴근 30분 전이다. "J님, 테스트 스위트 돌리는 데 왜 이렇게 오래 걸려요?" 개발팀장 질문이다. 답은 알고 있다. 매번 디비 초기화하느라 30분씩 날린다. "최적화 좀 해보겠습니다." 그렇게 주말을 픽스처 공부로 보냈다.문제는 반복이었다 월요일 출근. 테스트 코드를 다시 봤다. def test_user_login(): db = create_db_connection() db.clean_all_tables() db.insert_test_user() # 실제 테스트 result = login("test@test.com") assert result.success db.close()def test_user_logout(): db = create_db_connection() db.clean_all_tables() db.insert_test_user() # 실제 테스트 result = logout() assert result.success db.close()똑같은 셋업이 50개 테스트마다 반복된다. 디비 초기화가 30초씩 걸린다. 50개 × 30초 = 25분. 순수 셋업 시간만. "이거 미친 짓이었네." 픽스처 공부한 보람이 있다. 바로 리팩토링 시작했다. fixture 기본부터 conftest.py 파일을 만들었다. import pytest@pytest.fixture def db_connection(): db = create_db_connection() yield db db.close()yield가 핵심이다. 앞은 셋업, 뒤는 티어다운. 자동으로 실행된다. 테스트 코드가 간단해졌다. def test_user_login(db_connection): db = db_connection result = login("test@test.com") assert result.successclose()를 신경 쓸 필요가 없다. 픽스처가 알아서 정리한다. 첫 번째 개선. 5분 절약.scope로 시간 줄이기 문제는 여전했다. 매 테스트마다 디비 연결을 새로 만든다. scope를 알게 됐다. @pytest.fixture(scope="session") def db_connection(): db = create_db_connection() yield db db.close()session scope. 전체 테스트 스위트에서 한 번만 실행된다. 하지만 문제가 생겼다. 테스트끼리 데이터가 꼬인다. "아, 연결은 유지하되 데이터는 초기화해야 하는구나." 다시 수정했다. @pytest.fixture(scope="session") def db_connection(): db = create_db_connection() yield db db.close()@pytest.fixture(scope="function") def clean_db(db_connection): db_connection.clean_all_tables() return db_connection연결은 세션당 한 번. 테이블 초기화는 테스트마다. 실행 시간이 25분에서 8분으로 줄었다. 17분 절약. "이제 좀 사람 사는 거 같네." 테스트 데이터 픽스처 다음 문제. 테스트 데이터 준비가 중복됐다. def test_admin_access(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) result = access_admin_page(admin) assert result.successdef test_admin_delete(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) result = delete_user(admin) assert result.successadmin 유저 생성 코드가 계속 반복된다. 픽스처로 뺐다. @pytest.fixture def admin_user(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) return admindef test_admin_access(admin_user): result = access_admin_page(admin_user) assert result.success테스트 코드가 의도만 남았다. 셋업 코드가 사라졌다. 가독성이 올라갔다. 유지보수도 쉬워졌다. 파라미터로 여러 케이스 일반 유저, 관리자, 게스트. 세 가지 권한 테스트가 필요했다. 처음엔 테스트를 세 개 만들려고 했다. 비효율적이다. @pytest.fixture(params=[ {"email": "user@test.com", "role": "user"}, {"email": "admin@test.com", "role": "admin"}, {"email": "guest@test.com", "role": "guest"} ]) def test_user(request, clean_db): user = User(**request.param) clean_db.insert(user) return userdef test_user_access(test_user): result = access_page(test_user) assert result.success테스트 하나가 자동으로 세 번 실행된다. 각각 다른 유저로. pytest 출력도 깔끔하다. test_user_access[user] PASSED test_user_access[admin] PASSED test_user_access[guest] PASSED코드는 한 번 작성. 케이스는 무한 확장.autouse로 자동 실행 로그 관리가 필요했다. 모든 테스트마다. @pytest.fixture(autouse=True) def setup_logging(): logger = setup_test_logger() yield logger.save_results()autouse=True. 명시 안 해도 자동으로 실행된다. 모든 테스트 함수에 로깅이 적용됐다. 코드 수정 없이. "이건 진짜 마법 같네." 실전 구조 conftest.py를 계층화했다. tests/ conftest.py # 전역 픽스처 api/ conftest.py # API 테스트용 test_user.py ui/ conftest.py # UI 테스트용 test_login.py전역 conftest.py: @pytest.fixture(scope="session") def db_connection(): # 디비 연결 pass@pytest.fixture def clean_db(db_connection): # 테이블 초기화 passapi/conftest.py: @pytest.fixture def api_client(): # API 클라이언트 pass@pytest.fixture def auth_header(api_client): # 인증 헤더 pass필요한 픽스처만 불러온다. 테스트가 가벼워졌다. 픽스처 조합 픽스처끼리 조합할 수 있다. @pytest.fixture def user(clean_db): user = User(email="test@test.com") clean_db.insert(user) return user@pytest.fixture def logged_in_user(user, api_client): token = api_client.login(user) user.token = token return user@pytest.fixture def user_with_data(logged_in_user, clean_db): data = create_test_data() clean_db.insert_for_user(logged_in_user, data) return logged_in_user세 단계 픽스처다. 로그인까지, 데이터까지. 선택 가능하다. def test_simple(user): # 유저만 필요 passdef test_with_login(logged_in_user): # 로그인된 유저 passdef test_full_scenario(user_with_data): # 전부 준비된 상태 pass필요한 만큼만 셋업한다. 시간 절약이다. 실제 성과 리팩토링 전후 비교했다. 전:테스트 50개 실행: 32분 셋업 코드 중복: 200줄 디비 연결: 50번 티어다운 누락: 가끔후:테스트 50개 실행: 8분 픽스처 재사용: 10개 디비 연결: 1번 티어다운: 자동24분 절약. 하루에 테스트 3번 돌린다. 72분 단축. 주 5일이면 6시간. 거의 야근 하루가 사라졌다. 팀원 반응 후배 QA한테 픽스처 가르쳤다. "이거 진짜 편하네요." 개발팀장도 만족했다. "CI 파이프라인이 30분 빨라졌어요." 테크 리드가 물었다. "다른 팀도 적용 가능할까요?" "conftest.py 공유하면 됩니다." 지금은 전사 표준이 됐다. 모든 테스트가 픽스처 기반이다. 주의할 점 픽스처 남용하지 말 것. 간단한 테스트에 복잡한 픽스처는 과하다. 테스트 3줄인데 픽스처 20줄이면 문제다. scope 실수 조심. session scope에 function 데이터 넣으면 망한다. 픽스처 이름 명확하게. data보다 user_test_data가 낫다. 의존성 순환 주의. 픽스처가 서로 부르면 안 된다. 다음 단계 factory_boy 도입 검토 중이다. 픽스처 + 팩토리 패턴. @pytest.fixture def user_factory(clean_db): return UserFactorydef test_multiple_users(user_factory): users = user_factory.create_batch(10) # 10명 유저로 테스트더 유연해진다. 랜덤 데이터도 가능하다. Faker 라이브러리도 붙이면 좋겠다. 실제 같은 데이터로. 지금 시작하기 픽스처 리팩토링 순서:중복 셋업 코드 찾기 (Ctrl+F "setup") conftest.py 만들고 기본 픽스처 작성 yield로 티어다운 자동화 scope 최적화 (function → class → module → session) 파라미터로 케이스 확장 팀 공유하루면 충분하다. 효과는 즉시 나타난다. 마무리 금요일 6시. 테스트 스위트 실행했다. 8분 만에 완료. 초록불. "퇴근 가능." 야근이 사라졌다. 픽스처 덕분이다. 테스트 코드도 코드다. 리팩토링이 필요하다. 반복을 제거하고 재사용하라. 시간은 돌아온다. 야근 대신 정시 퇴근으로.이제 pytest.ini 설정도 정리해야겠다. 커버리지 리포트 경로가 엉망이다.