업무용 파이썬 스크립트는 처음 만들 때 몇 번 돌려 보고 “잘 되네” 하고 넘어가는 경우가 많습니다. 문제는 나중에 코드를 고칠 때입니다. 함수 하나를 짧게 다듬었는데 예전에 되던 입력이 조용히 틀린 값을 내도, 직접 다시 확인하기 전까지는 알 수 없습니다. 이 글은 pytest 사용법을 처음 익히는 분을 위해, 작은 유틸 함수 3개에 테스트 13개를 붙여 실제로 돌려 본 기록입니다. 가격 문자열을 숫자로 바꾸는 함수를 한 줄로 “리팩터링”했더니 -2,000원이 2000으로 바뀌었고, pytest가 0.07초 만에 그 한 건을 집어냈습니다. 환경은 윈도우 11, Python 3.12.10, pytest 9.2.0입니다.
설치와 폴더 구조
이 노트북에는 pytest가 깔려 있지 않았습니다. pip install 한 줄이면 되고, pytest 9.2.0과 함께 의존 패키지 5개가 설치됐습니다.
$ python -m pytest --version
python.exe: No module named pytest
$ python -m pip install pytest
Successfully installed colorama-0.4.6 iniconfig-2.3.1 packaging-26.3 pluggy-1.7.0 pygments-2.21.0 pytest-9.2.0
명령을 pytest가 아니라 python -m pytest로 쓴 데는 이유가 있습니다. 이 노트북은 윈도우 애플리케이션 제어 정책이 켜져 있어서, 파이썬 Scripts 폴더의 pytest.EXE를 실행하자 OSError: [WinError 4551]로 차단됐습니다(pip.exe도 같은 이유로 막혀 있습니다). python -m pytest는 python.exe가 pytest 모듈을 실행하는 방식이라 이런 환경에서도 동작합니다. 회사 PC처럼 보안 정책이 걸린 곳이라면 기억해 둘 만한 차이입니다.
폴더는 아래처럼 잡았습니다. 테스트 대상 코드와 tests 폴더를 나란히 두는, 가장 흔한 구조입니다.
pytest-intro/
├── pytest.ini
├── textutil.py
└── tests/
└── test_textutil.py
테스트할 함수 — 실무 스크립트에 흔한 세 가지
엑셀이나 웹에서 긁어 온 가격 문자열을 정수로 바꾸는 parse_price, 파일 이름에서 윈도우 금지 문자를 지우는 safe_filename, 텍스트 파일의 빈 줄을 뺀 줄 수를 세는 count_lines입니다. 아래는 테스트를 모두 통과한 최종 버전입니다.
"""업무 스크립트에서 자주 쓰는 작은 함수 모음 (테스트 대상)."""
import re
def parse_price(text):
"""'12,000원', '₩3,500', ' 700 ' 같은 문자열을 정수로 바꾼다."""
digits = text.replace(",", "").replace("원", "").replace("₩", "").strip()
return int(digits)
def safe_filename(name):
"""윈도우 파일 이름에 못 쓰는 문자를 _로 바꾼다."""
return re.sub(r'[\\/:*?"<>|]', "_", name).strip()
def count_lines(path):
"""텍스트 파일의 빈 줄을 뺀 줄 수."""
with open(path, encoding="utf-8") as f:
return sum(1 for line in f if line.strip())
테스트 파일 작성 — assert 한 줄이면 됩니다
pytest는 test_로 시작하는 파일 안에서 test_로 시작하는 함수를 찾아 실행합니다. 검사는 파이썬 기본 assert를 그대로 씁니다. unittest처럼 클래스를 만들거나 assertEqual 같은 메서드를 외울 필요가 없습니다. 실제로 돌린 테스트 파일 전체입니다.
import pytest
from textutil import parse_price, safe_filename, count_lines
def test_parse_price_basic():
assert parse_price("12,000원") == 12000
@pytest.mark.parametrize("text, expected", [
("₩3,500", 3500),
(" 700 ", 700),
("1,234,567원", 1234567),
("-2,000원", -2000),
("12,000 원", 12000),
("0원", 0),
])
def test_parse_price_cases(text, expected):
assert parse_price(text) == expected
def test_parse_price_rejects_text():
with pytest.raises(ValueError):
parse_price("가격 미정")
@pytest.mark.parametrize("name, expected", [
("보고서:최종.txt", "보고서_최종.txt"),
('a/b\\c*d?e"f<g>h|i', "a_b_c_d_e_f_g_h_i"),
("정상 파일.xlsx", "정상 파일.xlsx"),
(" 앞뒤 공백.txt ", "앞뒤 공백.txt"),
])
def test_safe_filename(name, expected):
assert safe_filename(name) == expected
def test_count_lines(tmp_path):
f = tmp_path / "memo.txt"
f.write_text("첫 줄\n\n둘째 줄\n \n셋째 줄\n", encoding="utf-8")
assert count_lines(f) == 3
이 파일에서 쓴 기능 네 가지를 정리하면 다음과 같습니다.
| 기능 | 쓰는 법 | 이 글에서 한 일 |
|---|---|---|
| 기본 테스트 | def test_…(): assert 결과 == 기대값 | 12,000원 → 12000 확인 |
| @pytest.mark.parametrize | 입력·기대값 목록을 한 함수로 반복 | 가격 6개, 파일 이름 4개 케이스 |
| pytest.raises | with 블록 안에서 예외가 나야 통과 | “가격 미정” → ValueError 확인 |
| tmp_path 픽스처 | 인자로 받으면 임시 폴더 경로가 들어옴 | 메모 파일을 만들어 줄 수 세기 |
함수 5개지만 parametrize 덕분에 실행되는 테스트는 13개입니다. tmp_path는 테스트마다 새 임시 폴더를 만들어 주기 때문에, 실제 작업 폴더에 시험용 파일을 흘리고 다닐 일이 없습니다.
첫 실행 — 13개 중 1개 실패
처음 테스트를 쓸 때 safe_filename은 금지 문자를 _로 바꾸기만 했고, 테스트에는 앞뒤에 공백이 붙은 파일 이름 케이스를 넣어 두었습니다. 결과는 12개 통과, 1개 실패였습니다.
$ python -X utf8 -m pytest -q
...........F. [100%]
================================== FAILURES ===================================
name = ' 앞뒤 공백.txt ', expected = '앞뒤 공백.txt'
def test_safe_filename(name, expected):
> assert safe_filename(name) == expected
E AssertionError: assert ' 앞뒤 공백.txt ' == '앞뒤 공백.txt'
E
E - 앞뒤 공백.txt
E + 앞뒤 공백.txt
E ? ++ +
tests\test_textutil.py:33: AssertionError
1 failed, 12 passed in 0.07s
pytest의 장점이 여기서 보입니다. 단순히 “틀렸다”가 아니라 어떤 입력(name)이 들어갔고 기대값과 실제값이 어디서 다른지(++, + 표시가 앞뒤 공백 위치)를 보여 줍니다. 윈도우는 파일 이름 끝의 공백을 허용하지 않거나 잘라 버리기 때문에, 함수 끝에 .strip()을 붙여 고쳤습니다.
def safe_filename(name):
return re.sub(r'[\\/:*?"<>|]', "_", name) # 처음 버전
def safe_filename(name):
return re.sub(r'[\\/:*?"<>|]', "_", name).strip() # 테스트 실패 후 고친 버전
참고로 같은 파일의 -2,000원, 12,000 원(숫자와 원 사이 공백) 케이스는 처음부터 통과했습니다. replace로 쉼표와 “원”을 지운 뒤 strip하고, int()가 마이너스 부호를 그대로 처리했기 때문입니다.
윈도우에서 만난 함정 두 가지
1) 한글이 깨져 나옵니다. 맨 처음에는 python -m pytest로만 실행했는데, 실패 메시지의 한글과 경로가 아래처럼 전부 깨졌습니다.
rootdir: C:\���α�\12. �ֵ弾��\drafts\evidence\run\pytest-intro
name = ' �յ� ����.txt ', expected = '�յ� ����.txt'
파이썬을 UTF-8 모드로 띄우는 -X utf8 옵션을 붙이자 위의 “첫 실행” 출력처럼 한글이 제대로 나왔습니다. 매번 붙이기 번거롭다면 환경 변수 PYTHONUTF8=1을 설정하는 방법도 있습니다(이 방법은 이번에 따로 시험하지는 않았습니다).
2) ModuleNotFoundError: No module named … python -m pytest는 현재 폴더를 import 경로에 넣어 주기 때문에 from textutil import ...가 문제없이 됐습니다. 하지만 pytest 명령으로 실행하거나 다른 폴더에서 실행하면 이 혜택이 없습니다. 현재 폴더를 경로에서 빼는 격리 모드(-I)로 그 상황을 재현해 봤더니 테스트 수집 단계에서 바로 멈췄습니다.
$ python -X utf8 -I -m pytest -q -p no:cacheprovider
___________________ ERROR collecting tests/test_textutil.py ___________________
ImportError while importing test module '...\pytest-intro\tests\test_textutil.py'.
tests\test_textutil.py:2: in <module>
from textutil import parse_price, safe_filename, count_lines
E ModuleNotFoundError: No module named 'textutil'
!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.10s
해결은 프로젝트 루트에 pytest.ini를 두고 pythonpath를 지정하는 것입니다. testpaths까지 적어 두면 어디서 실행하든 tests 폴더만 찾습니다.
[pytest]
pythonpath = .
testpaths = tests
같은 -I 조건으로 다시 돌리자 출력에 configfile: pytest.ini가 찍히고 13개가 모두 통과했습니다. pytest가 보고한 시간은 0.02초, 프로세스 시작까지 포함한 전체 시간은 0.27초였습니다.
$ python -X utf8 -I -m pytest -v -p no:cacheprovider
configfile: pytest.ini
testpaths: tests
collecting ... collected 13 items
tests/test_textutil.py::test_parse_price_basic PASSED [ 7%]
tests/test_textutil.py::test_parse_price_cases[\u20a93,500-3500] PASSED [ 15%]
tests/test_textutil.py::test_parse_price_cases[ 700 -700] PASSED [ 23%]
tests/test_textutil.py::test_parse_price_cases[1,234,567\uc6d0-1234567] PASSED [ 30%]
tests/test_textutil.py::test_parse_price_cases[-2,000\uc6d0--2000] PASSED [ 38%]
...
tests/test_textutil.py::test_count_lines PASSED [100%]
============================= 13 passed in 0.02s ==============================
exit: 0, 소요 0.27초
테스트가 실제로 쓸모 있었던 순간 — 리팩터링 실수
테스트를 붙이는 진짜 이유는 다음입니다. parse_price의 replace 세 번이 길어 보여서, “숫자가 아닌 문자는 전부 지운다”는 한 줄로 바꿔 봤습니다. 원화 기호, 쉼표, “원”, 공백이 한 번에 사라지니 그럴듯해 보입니다.
def parse_price(text):
return int(re.sub(r"\D", "", text)) # 숫자 아닌 건 전부 지우기
바로 테스트를 돌렸습니다.
$ python -X utf8 -m pytest -q -p no:cacheprovider
....F........ [100%]
_________________ test_parse_price_cases[-2,000\uc6d0--2000] __________________
text = '-2,000원', expected = -2000
> assert parse_price(text) == expected
E AssertionError: assert 2000 == -2000
E + where 2000 = parse_price('-2,000원')
FAILED tests/test_textutil.py::test_parse_price_cases[-2,000\uc6d0--2000] - A...
1 failed, 12 passed in 0.07s
\D(숫자가 아닌 문자)에는 마이너스 부호도 포함돼서 -2,000원이 2000이 됐습니다. 나머지 12개는 전부 통과했으니, 환불·차감 금액 같은 음수 케이스를 테스트에 넣어 두지 않았다면 이 변경은 그대로 들어갔을 겁니다. 오류도 나지 않고 숫자만 조용히 틀리는 종류라 눈으로 찾기 가장 어려운 버그입니다. 원래 코드로 되돌리고 다시 13개 통과를 확인했습니다.
반대로 “가격 미정” 케이스는 리팩터링 후에도 통과했습니다. 숫자를 다 지우면 빈 문자열이 남고 int('')가 ValueError를 내기 때문입니다. 결과가 우연히 같았던 경우인데, 이렇게 의도를 테스트로 적어 두면 다음에 누가 고쳐도 같은 기준으로 확인할 수 있습니다.
자주 쓰는 실행 옵션
테스트가 늘어나면 일부만 골라 돌리고 싶어집니다. -k는 테스트 이름에 들어간 단어로 고르며, and·or·not을 조합할 수 있습니다.
$ python -X utf8 -m pytest -q -p no:cacheprovider -k "price and not rejects"
....... [100%]
7 passed, 6 deselected in 0.01s
$ python -X utf8 -m pytest -v -p no:cacheprovider -k safe \
-o disable_test_id_escaping_and_forfeit_all_rights_to_community_support=True
tests/test_textutil.py::test_safe_filename[보고서:최종.txt-보고서_최종.txt] PASSED [ 25%]
tests/test_textutil.py::test_safe_filename[정상 파일.xlsx-정상 파일.xlsx] PASSED [ 75%]
4 passed, 9 deselected in 0.01s
parametrize 케이스의 이름(대괄호 안)은 기본적으로 한글이 \uc6d0 같은 이스케이프로 표시됩니다. 위 두 번째 명령처럼 긴 이름의 옵션으로 끌 수 있지만, 옵션 이름 자체가 “커뮤니티 지원을 포기한다”는 뜻이라 저는 화면 확인용으로만 썼습니다. 이 글에서 실제로 쓴 옵션을 정리하면 다음과 같습니다.
| 옵션 | 하는 일 | 실측 예 |
|---|---|---|
| -q | 점(.) 한 줄로 짧게 출력 | 13 passed in 0.02s |
| -v | 테스트마다 PASSED/FAILED 한 줄 | 13줄 출력 |
| -k “식” | 이름으로 골라 실행 | 7 passed, 6 deselected |
| –durations=3 | 오래 걸린 테스트 3개 표시 | 전부 0.005초 미만이라 숨김 |
| -p no:cacheprovider | .pytest_cache 폴더를 만들지 않음 | 작업 폴더 깔끔하게 유지 |
주의할 점
- 테스트 파일 이름과 함수 이름은
test_로 시작해야 수집됩니다.check_price()처럼 이름을 지으면 pytest는 조용히 건너뜁니다. - 테스트가 통과했다는 건 “적어 둔 케이스에서는 맞다”는 뜻입니다. 이번 리팩터링 실수도 음수 케이스가 목록에 있었기 때문에 잡혔습니다. 실제 데이터에서 본 이상한 값(공백, 음수, 빈 칸)을 만날 때마다 parametrize 목록에 한 줄씩 추가하는 습관이 가장 효과적입니다.
- 실행하면
__pycache__와.pytest_cache폴더가 생깁니다. Git을 쓴다면 .gitignore에 넣어 두세요. - 윈도우 콘솔에서 한글이 깨지면 코드가 아니라 출력 인코딩 문제일 가능성이 큽니다.
-X utf8부터 붙여 보세요.
마무리 — 첫걸음은 함수 하나, assert 하나
이번 실습은 함수 3개, 테스트 13개, 실행 한 번에 0.02초였습니다. 처음부터 모든 코드에 테스트를 붙일 필요는 없습니다. 지금 쓰고 있는 스크립트에서 가장 자주 고치는 함수 하나를 골라 tests/test_이름.py에 assert 한 줄을 적고, python -m pytest를 돌려 보는 것으로 충분합니다. 다음에 그 함수를 고칠 때 테스트가 대신 확인해 줍니다.
다음 글에서는 AI 답변에서 코드 블록만 뽑아 파일로 저장하는 스크립트를 만들어 봅니다. AI가 준 코드를 복사해 붙이는 반복 작업을 줄이는 방법입니다.