데이터 정제 함수를 고쳤는데, 결과가 여전히 맞는지 어떻게 확인할까? 매번 노트북을 열어 눈으로 값을 훑어보는 방식은 규모가 커질수록 무너진다. pytest는 함수 이름 규칙만으로 테스트를 자동으로 찾아 실행하는 Python 테스트 프레임워크로, assert 문 하나로 기댓값을 검증한다.
한 줄 정의
pytest는 test_ 로 시작하는 파일과 함수를 자동으로 찾아 실행하고, assert 문이 거짓이면 그 지점의 값을 그대로 보여주며 실패로 표시하는 테스트 프레임워크다.
왜 필요한가
데이터 분석 코드에서 결측치를 제거하거나 점수를 정규화하는 함수는 입력과 출력이 명확한 순수 함수인 경우가 많다. 이런 함수는 print로 값을 찍어 눈으로 확인하는 방식으로도 한 번은 검증할 수 있다. 문제는 그 다음이다.
코드를 리팩터링하거나 라이브러리 버전을 올릴 때마다 같은 확인을 손으로 반복해야 한다.
확인한 사람이 아니면 무엇을 확인했는지, 어떤 입력에서 통과했는지 알 수 없다.
출력을 눈으로 훑는 방식은 값이 조금 달라져도 알아채지 못하는 경우가 많다.
pytest로 한 번 작성한 테스트는 코드가 바뀔 때마다 같은 조건으로 자동 재실행된다. 실패하면 어떤 입력에서 어떤 값이 기대와 달랐는지 즉시 보여준다. 이 반복 검증이 회귀(regression) — 고친 코드가 이전에 되던 것을 다시 망가뜨리는 상황 — 를 막는다.
테스트 탐색 규칙
pytest는 설정 없이 실행해도 현재 디렉터리 아래를 재귀적으로 훑어 아래 이름 규칙에 맞는 파일과 함수를 테스트로 인식한다.
대상 | 인식 규칙 |
|---|---|
파일 |
|
함수 |
|
클래스 |
|
conftest.py | 이름 규칙과 무관하게 항상 자동으로 읽는다. 여러 테스트 파일이 공유할 fixture를 모아두는 곳이다. |
이 규칙을 벗어난 이름은 조용히 무시된다.
check_login.py처럼test_접두어가 없는 파일은 실행하려는 의도와 무관하게 pytest 목록에서 빠진다.
테스트할 함수 준비하기
검증 대상은 데이터 파이프라인에서 흔히 쓰는 두 함수다. 하나는 지정한 열에 결측치가 있는 행을 제거하고, 다른 하나는 0~100 점수를 0~1 비율로 바꾼다.
"""분석 파이프라인에서 재사용하는 정제 함수 모음."""
from __future__ import annotations
import pandas as pd
def clean_nulls(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
"""cols에 지정한 열 중 하나라도 결측인 행을 제거한다."""
return df.dropna(subset=cols).reset_index(drop=True)
def normalize_score(raw: float) -> float:
"""0~100 점수를 0~1 사이 비율로 변환한다."""
if not 0 <= raw <= 100:
raise ValueError(f"raw는 0~100 사이여야 합니다: {raw}")
return round(raw / 100, 4)
normalize_score 가 하는 일은 수식으로 쓰면 다음과 같다.
두 함수 모두 외부 상태를 읽거나 바꾸지 않는 순수 함수(pure function) 다. 같은 입력에는 항상 같은 출력이 나오므로 테스트하기 가장 쉬운 형태다.
fixture로 테스트 데이터 공유하기
여러 테스트가 같은 샘플 데이터를 쓴다면 함수마다 새로 만들지 않고 @pytest.fixture 로 한 번만 정의한다. 테스트 함수는 fixture와 이름이 같은 매개변수를 받기만 하면 pytest가 자동으로 그 값을 넘겨준다.
import pandas as pd
import pytest
from src.clean import clean_nulls, normalize_score
@pytest.fixture
def sample_df():
return pd.DataFrame({
"a": [1, None, 3],
"b": ["x", "y", None],
})
def test_clean_removes_nulls(sample_df):
result = clean_nulls(sample_df, cols=["a"])
assert result["a"].isna().sum() == 0
def test_shape_preserved_when_no_nulls(sample_df):
result = clean_nulls(sample_df, cols=[])
assert result.shape[0] == 3 # 검사 대상 열이 없으면 행이 그대로 남는다
함수 인자 이름이 곧 의존성 선언이다. sample_df 라는 이름만 맞으면 pytest가 위에서 정의한 fixture 함수를 실행하고 그 반환값을 테스트에 주입한다. 두 테스트를 실행하면 다음과 같이 통과한다.
$ pytest tests/test_clean.py -v
tests/test_clean.py::test_clean_removes_nulls PASSED [ 50%]
tests/test_clean.py::test_shape_preserved_when_no_nulls PASSED [100%]
======================== 2 passed in 0.30s ========================
예외가 발생하는 경우 테스트하기
정상 입력만 확인하면 절반이다. normalize_score 는 범위를 벗어난 값에서 ValueError 를 던지도록 만들었으므로, 그 예외가 실제로 발생하는지도 테스트 대상이다. pytest.raises 컨텍스트 매니저 안에서 예외가 나야 테스트가 통과한다.
def test_normalize_score_out_of_range_raises():
with pytest.raises(ValueError, match="0~100 사이"):
normalize_score(150)
match 인자는 정규식으로 예외 메시지를 확인한다. 단순히 예외가 났는지만 보지 않고 어떤 예외가 왜 났는지까지 검증할 수 있다. with 블록 안에서 예외가 나지 않으면 이 테스트 자체가 실패한다 — 예외가 나야 정상인 상황을 뒤집어 검증하는 방식이다.
매개변수화로 여러 입력을 한 번에
같은 함수를 입력만 바꿔가며 여러 번 테스트할 때 함수를 복사하지 않는다. @pytest.mark.parametrize 에 입력과 기댓값 쌍을 나열하면 pytest가 각 쌍마다 독립된 테스트를 만든다.
@pytest.mark.parametrize(
"raw, expected",
[
(0, 0.0),
(50, 0.5),
(100, 1.0),
(87.5, 0.875),
],
)
def test_normalize_score_valid(raw, expected):
assert normalize_score(raw) == expected
실행하면 각 케이스가 함수이름[입력값-기댓값] 형태의 개별 테스트 ID로 나뉜다. 어느 입력에서 실패했는지 이름만 보고 바로 알 수 있다.
$ pytest tests/test_clean.py::test_normalize_score_valid -v
tests/test_clean.py::test_normalize_score_valid[0-0.0] PASSED [ 25%]
tests/test_clean.py::test_normalize_score_valid[50-0.5] PASSED [ 50%]
tests/test_clean.py::test_normalize_score_valid[100-1.0] PASSED [ 75%]
tests/test_clean.py::test_normalize_score_valid[87.5-0.875] PASSED [100%]
======================== 4 passed in 0.18s ========================
커버리지로 빈틈 확인하기
테스트가 몇 개 통과했는지보다 중요한 질문은 코드의 어느 줄이 한 번도 실행되지 않았는가 다. pytest-cov 를 설치하면 테스트 실행 중 각 줄의 실행 여부를 함께 집계한다.
pip install pytest-cov
pytest tests/ --cov=src --cov-report=term-missing
Name Stmts Miss Cover Missing
-----------------------------------------------
src/__init__.py 0 0 100%
src/clean.py 8 0 100%
-----------------------------------------------
TOTAL 8 0 100%
Stmts 는 실행 가능한 코드 줄 수, Miss 는 테스트가 한 번도 지나가지 않은 줄 수, Missing 은 그 줄 번호다. 지금은 모든 분기를 테스트가 지나가 100%가 나왔지만, 실무에서는 Missing 열에 찍힌 줄 번호를 보고 빠진 테스트 케이스를 채워 넣는다.
커버리지 100%는 모든 줄이 실행됐다 는 뜻이지 로직이 옳다 는 뜻이 아니다. 잘못된 기댓값으로 통과하는 테스트도 커버리지는 채운다. 팀 목표로는 80% 안팎을 흔히 쓰지만, 숫자 자체보다 핵심 로직이 빠짐없이 걸렸는지가 더 중요하다.
실패하면 어떻게 보이는가
기댓값을 일부러 틀리게 적으면 pytest는 실패 지점의 실제 값을 함께 보여준다.
def test_normalize_score_wrong_expectation():
assert normalize_score(87.5) == 0.88 # 일부러 틀린 기댓값
$ pytest tests/test_fail_demo.py -v --tb=short
tests/test_fail_demo.py::test_normalize_score_wrong_expectation FAILED [100%]
=================================== FAILURES ===================================
____________________ test_normalize_score_wrong_expectation ____________________
tests/test_fail_demo.py:5: in test_normalize_score_wrong_expectation
assert normalize_score(87.5) == 0.88
E assert 0.875 == 0.88
E + where 0.875 = normalize_score(87.5)
=========================== short test summary info ============================
FAILED tests/test_fail_demo.py::test_normalize_score_wrong_expectation
============================== 1 failed in 0.24s ===============================
assert 뒤의 식을 다시 계산해 실제 값(0.875)과 그 값이 나온 표현식까지 보여준다. print 로 값을 하나씩 찍어보지 않아도 실패 원인이 한 화면에 드러나는 이유다.
실행 범위 좁히기
파일이 늘어나면 매번 전체를 돌리지 않고 필요한 범위만 지정해서 실행한다.
명령 | 실행 범위 |
|---|---|
| 현재 위치 아래 모든 테스트 |
| 파일 하나 |
| 함수 하나 |
| 이름에 score가 들어간 테스트만 |
| 첫 실패에서 즉시 중단 |
| 테스트 이름을 한 줄씩 자세히 출력 |
반복 실행할 옵션이 늘어나면 매번 타이핑하지 않고 프로젝트 루트의 pytest.ini 에 기본값으로 고정해 둘 수 있다.
[pytest]
minversion = 6.0
addopts = -ra -q --tb=short
testpaths =
tests
testpaths 를 지정하면 프로젝트 루트에 남아있는 실험용 .py 파일까지 테스트로 오인해 훑는 일을 막는다.
장점과 한계
구분 | 내용 |
|---|---|
장점 | 코드를 고칠 때마다 이전 동작이 깨졌는지 자동으로 확인한다. 테스트 코드 자체가 함수의 사용 예시 겸 문서 역할을 한다. |
한계 | 테스트를 작성하고 유지하는 데도 시간이 든다. 외부 API·DB에 의존하는 함수는 그대로 테스트하면 네트워크 상태에 따라 결과가 흔들리므로 별도의 대체 객체(mock)가 필요하다. |
자주 발생하는 문제
fixture 이름 오타. 테스트 함수의 매개변수 이름과 fixture 함수 이름이 다르면 pytest는 그 이름을 fixture가 아니라 알 수 없는 값으로 보고 실행 자체를 막는다.
E fixture 'sample_dfx' not found
> available fixtures: cache, capfd, ... (내장 fixture 목록)
> use 'pytest --fixtures [testpath]' for help on them.
여러 파일이 같은 fixture를 써야 하면 그 파일 안에서만 정의하지 말고 conftest.py 로 옮긴다. 이름 규칙과 무관하게 pytest가 항상 먼저 읽는 파일이라 같은 디렉터리 아래 모든 테스트 파일이 공유한다.
부동소수점을 == 로 비교. 부동소수점 연산은 오차가 남을 수 있어 값이 같아 보여도 비교가 거짓이 되는 경우가 있다.
>>> 0.1 + 0.2 == 0.3
False
>>> 0.1 + 0.2
0.30000000000000004
이런 값은 == 대신 오차 범위를 허용하는 pytest.approx 로 비교한다.
>>> 0.1 + 0.2 == pytest.approx(0.3)
True
관련 문서
이 문서는 값을 검증하는 테스트를 다룬다. 값이 들어오는 시점의 형태·범위 검증은 Pydantic으로 외부 데이터를 검증하기 를, 실패를 알리는 예외 설계는 예외 처리와 사용자 정의 예외 를 함께 본다.
참고 자료
pytest 공식 문서 — How to invoke pytest (2026-08-10 확인)
pytest 공식 문서 — Fixtures (2026-08-10 확인)
pytest 공식 문서 — Parametrize (2026-08-10 확인)
pytest-cov 공식 문서 (2026-08-10 확인)
댓글 0
댓글을 불러오는 중…