파이썬 테스트 #1 pytest 시작: assert 하나로 충분한 이유

6 분 소요

코드를 고친 뒤에 “다른 데가 깨지지 않았을까” 하는 불안을 느껴 본 적이 있다면, 이 시리즈가 그 불안을 없애는 방법을 다룹니다. 모던 파이썬 기초를 마친 분을 기준으로, 파이썬 테스트의 표준 도구인 pytest를 바닥부터 다지는 7편입니다.

  • #1 pytest 시작 ← 이번 글
  • #2 픽스처 (fixture)
  • #3 parametrize와 마커
  • #4 mock과 monkeypatch
  • #5 외부 세계 테스트 (HTTP, DB, 웹 프레임워크)
  • #6 테스트 설계와 커버리지
  • #7 CI 연동 (마무리)

FastAPI 엔드포인트 테스트는 모던 파이썬 실전 #6에서 이미 한 편으로 다뤘습니다. 그 글이 웹 프레임워크 위에서 테스트를 돌리는 법이었다면, 이 시리즈는 그 토대가 되는 pytest 자체를 처음부터 차근차근 쌓아 올리겠습니다.

테스트가 없는 코드의 진짜 비용 #

테스트가 없는 프로젝트에서 일어나는 일은 대체로 비슷합니다. 처음에는 속도가 빠릅니다. 테스트를 적을 시간에 기능을 하나 더 만들 수 있기 때문입니다. 문제는 코드가 쌓인 뒤에 시작됩니다.

유틸 함수 하나를 고쳤는데 그 함수를 쓰는 다른 화면이 조용히 깨집니다. 배포 후에야 사용자 제보로 알게 됩니다. 그런 일이 두세 번 반복되면 팀 전체가 같은 결론에 도달합니다. “되도록 건드리지 말자.” 이 순간부터 코드는 늙기 시작합니다. 구조가 나빠도 고치지 못하고, 중복이 보여도 정리하지 못합니다. 수정이 무서워지는 순간이 테스트가 없는 코드의 진짜 비용입니다.

테스트는 “이 코드는 이렇게 동작한다"는 실행 가능한 증거입니다. 증거가 있으면 코드를 고친 뒤 명령 한 번으로 전부 다시 확인할 수 있고, 수정이 더 이상 무섭지 않습니다.

설치: uv add –dev pytest #

pytest는 외부 패키지라서 설치가 필요합니다. uv 프로젝트라면 한 줄입니다.

pytest 설치
uv add --dev pytest

--dev는 “프로덕션 배포에는 필요 없고 개발할 때만 쓰는 의존성"이라는 표시입니다. 테스트 도구는 운영 서버에 올라갈 이유가 없으니 dev 그룹이 정확한 위치입니다. uv를 안 쓴다면 pip install pytest로도 동일하게 설치됩니다.

첫 테스트: assert 하나면 충분합니다 #

테스트할 함수를 하나 만들겠습니다.

calc.py
def add(a: int, b: int) -> int:
    return a + b

테스트는 이렇게 적습니다.

test_calc.py
from calc import add


def test_add():
    assert add(2, 3) == 5

이게 전부입니다. 규칙은 두 개뿐입니다.

  • 테스트 함수의 이름은 test_로 시작합니다
  • 확인하고 싶은 조건을 파이썬 내장 assert 문으로 적습니다

별도의 클래스도, 전용 검증 메서드도 필요 없습니다. 실행도 명령 하나입니다.

실행
uv run pytest
출력
========================= test session starts =========================
collected 1 item

test_calc.py .                                                   [100%]

========================== 1 passed in 0.01s ==========================

. 하나가 통과한 테스트 하나입니다. 테스트가 늘어나면 점이 늘어나고, 실패하면 그 위치에 F가 찍힙니다.

unittest와 비교: 클래스도 assertEqual도 필요 없습니다 #

표준 라이브러리에도 unittest라는 테스트 프레임워크가 있습니다. 같은 테스트를 unittest로 적으면 이렇게 됩니다.

🚫 unittest 방식
import unittest

from calc import add


class TestAdd(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)


if __name__ == "__main__":
    unittest.main()

클래스를 만들고, TestCase를 상속하고, self.assertEqual을 호출합니다. 검증 종류마다 메서드 이름도 다릅니다. assertEqual, assertTrue, assertIn, assertIsNone, assertRaises처럼 수십 개를 구분해서 써야 합니다.

pytest는 이 전부를 assert 하나로 대체합니다.

✅ pytest 방식: 전부 assert
def test_various():
    assert add(2, 3) == 5            # assertEqual
    assert add(1, 1) > 0             # assertGreater
    assert 3 in [1, 2, 3]            # assertIn
    assert add(0, 0) is not None     # assertIsNotNone

이게 가능한 이유는 pytest의 assert 재작성(assert rewriting) 때문입니다. pytest는 테스트 파일을 import 하는 시점에 assert 문을 내부적으로 다시 작성해서, 실패할 때 표현식의 중간 값까지 전부 보여주도록 바꿉니다. 일반 파이썬의 assert는 실패해도 AssertionError 한 줄만 던지지만, pytest 안에서는 “무엇이 어떻게 달랐는지"가 그대로 출력됩니다. 전용 메서드를 외울 필요가 없는 이유가 이것입니다.

예외 테스트만 전용 도구를 씁니다. unittest의 assertRaises에 해당하는 pytest.raises입니다.

예외 테스트
import pytest


def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

실패 출력 읽는 법 #

테스트는 실패할 때 가치가 드러납니다. 일부러 틀린 테스트를 돌려 보겠습니다.

실패하는 테스트
def test_add_wrong():
    assert add(2, 3) == 6
실패 출력
________________________ test_add_wrong ________________________

    def test_add_wrong():
>       assert add(2, 3) == 6
E       assert 5 == 6
E        +  where 5 = add(2, 3)

test_calc.py:8: AssertionError

읽는 법은 단순합니다.

  • > 줄이 실패한 코드의 위치입니다
  • E 줄이 실제 값입니다. add(2, 3)5를 반환했고, 기대값 6과 다르다는 사실이 그대로 보입니다

딕셔너리나 리스트를 비교하면 어디가 다른지도 짚어 줍니다.

컬렉션 비교 실패 출력 (일부)
E       AssertionError: assert {'id': 1, 'role': 'admin'} == {'id': 1, 'role': 'member'}
E         Differing items:
E         {'role': 'admin'} != {'role': 'member'}

출력이 생략되어 답답하면 uv run pytest -vv로 전체 비교 결과를 볼 수 있습니다. 실패 출력만 제대로 읽어도 디버깅 시간의 절반이 줄어듭니다.

테스트 디스커버리: pytest가 테스트를 찾는 규칙 #

uv run pytest만 입력했는데 테스트 파일이 실행된 이유는, pytest가 정해진 규칙으로 테스트를 자동 수집하기 때문입니다. 이를 테스트 디스커버리(test discovery)라고 합니다.

  • 파일: test_*.py 또는 *_test.py
  • 함수: test_로 시작하는 이름
  • 클래스: Test로 시작하고 __init__이 없는 클래스 안의 test_ 메서드

이 규칙에 맞지 않으면 테스트는 조용히 무시됩니다. tset_calc.py처럼 오타가 난 파일은 실행되지 않으니, “통과했다"가 아니라 “수집조차 안 됐다"일 가능성을 늘 의심해야 합니다. 출력 첫 줄의 collected N items 숫자를 확인하는 습관이 그래서 중요합니다.

프로젝트가 커지면 테스트를 tests/ 디렉터리로 분리하는 구조가 표준입니다.

권장 구조
myproject/
├── src/
│   └── calc.py
├── tests/
│   └── test_calc.py
└── pyproject.toml

pytest가 tests/만 보도록 설정도 적어 두겠습니다.

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]

src 레이아웃에서 tests/의 import가 동작하려면 프로젝트 자신이 패키지로 설치되어 있어야 하는데, uv 프로젝트에서 uv run pytest를 쓰면 이 부분이 자동으로 처리됩니다.

무엇을 테스트할까 #

첫 글이니 원칙 두 개만 가볍게 짚겠습니다.

첫째, 공개 동작을 테스트합니다. 함수에 무엇을 넣으면 무엇이 나오는지를 검증하고, 내부에서 어떤 변수를 거치는지는 검증하지 않습니다. 내부 구현은 리팩터링하면 바뀌지만 공개 동작은 유지되어야 하고, 테스트는 그 유지를 지키는 장치이기 때문입니다.

둘째, 경계값을 테스트합니다. 버그는 평범한 입력보다 가장자리에서 숨어 있는 경우가 많습니다.

src/textutil.py
def truncate(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    return text[:limit] + "..."
tests/test_textutil.py
from textutil import truncate


def test_short_text_unchanged():
    assert truncate("hi", 10) == "hi"


def test_long_text_truncated():
    assert truncate("hello world", 5) == "hello..."


def test_exact_limit():
    assert truncate("hello", 5) == "hello"


def test_empty_string():
    assert truncate("", 5) == ""

길이가 limit와 정확히 같은 경우, 빈 문자열 같은 경계가 핵심입니다. “이 함수가 받을 수 있는 가장 이상한 입력은 무엇일까"를 한 번 생각해 보고 그 입력을 테스트로 적어 두면, 테스트 네 개로도 함수의 동작 명세가 완성됩니다.

정리 #

이번 글에서 다룬 내용입니다.

  • 테스트가 없는 코드의 진짜 비용은 수정이 무서워지는 순간에 드러납니다
  • 설치는 uv add --dev pytest, 실행은 uv run pytest입니다
  • 테스트는 test_로 시작하는 함수에 assert 하나를 적으면 끝입니다
  • unittest의 클래스와 assertEqual 계열 메서드는 필요 없습니다. assert 재작성 덕분에 내장 assert만으로 풍부한 실패 정보가 나옵니다
  • 실패 출력의 > 줄은 위치, E 줄은 실제 값입니다
  • 디스커버리 규칙은 test_*.py 파일과 test_ 함수이고, collected N items 숫자를 확인하는 습관이 중요합니다
  • 테스트 대상은 내부 구현이 아니라 공개 동작과 경계값입니다

다음 글(#2 픽스처)에서는 테스트마다 반복되는 준비와 정리를 한곳에 모으는 픽스처(fixture)를 다루겠습니다. pytest를 pytest답게 만드는 기능의 시작입니다.

X