[파이썬 100강] 89강. doctest로 문서와 테스트를 한 번에 유지하기
함수를 설명하는 문서와 실제 동작이 자꾸 어긋나는 팀에서는, 오래된 예제가 오히려 버그를 만드는 경우가 많습니다. 이번 강의에서는 doctest를 이용해 "문서 예제 자체를 테스트"하는 방법을 바로 실습해 보겠습니다. 서론은 여기까지 하고, 바로 코드로 들어가겠습니다.
핵심 개념
doctest는 docstring 안에 작성한pycon스타일 예제를 실제로 실행해 정답과 비교하는 표준 라이브러리입니다.- 문서(설명)와 테스트(검증)를 분리하지 않고 한곳에 두기 때문에, "문서가 최신인지"를 자동으로 확인할 수 있습니다.
- 단위 테스트를 대체하는 도구라기보다, API 사용 예제를 깨끗하게 유지하는 보조 안전장치로 보는 게 맞습니다.
doctest의 가장 큰 장점은 진입 장벽이 낮다는 점입니다. 이미 함수 설명을 docstring으로 쓰고 있다면, 예제를 한두 줄 추가하는 것만으로 바로 실행 가능한 테스트가 됩니다. 예를 들어 문자열 정리 함수의 입력/출력을 docstring에 적어 두면, CI에서 python -m doctest -v 파일명.py 한 줄로 깨짐 여부를 확인할 수 있습니다. 특히 라이브러리성 코드나 내부 유틸 함수처럼 사용 예제가 곧 문서인 경우, doctest는 유지보수 비용 대비 효과가 큽니다. 다만 복잡한 상태 관리, 외부 I/O, 비결정적 출력(시간/랜덤)까지 무리해서 넣으면 오히려 테스트 신뢰도가 떨어지므로 범위를 명확히 잡는 게 중요합니다.
기본 사용
예제 1) 가장 단순한 함수에 doctest 붙이기
>>> def normalize_email(text):
... """이메일의 앞뒤 공백을 제거하고 소문자로 정규화한다.
...
... >>> normalize_email(" [email protected] ")
... '[email protected]'
... """
... return text.strip().lower()
...
>>> normalize_email(" [email protected] ")
'[email protected]'
해설:
- docstring 안의
>>>예제가 그대로 테스트 케이스가 됩니다. - 실제 함수 호출 예제와 테스트 데이터가 같은 위치에 있어, 문서-코드 불일치가 줄어듭니다.
예제 2) 여러 케이스를 한 docstring에 누적하기
>>> def slugify(title):
... """제목을 URL 슬러그로 바꾼다.
...
... >>> slugify("Python 100")
... 'python-100'
... >>> slugify(" Fast API Guide ")
... 'fast-api-guide'
... >>> slugify("Data Pipeline")
... 'data-pipeline'
... """
... words = title.strip().lower().split()
... return "-".join(words)
...
>>> slugify(" Hello World ")
'hello-world'
해설:
- 대표 케이스를 docstring에 나란히 넣으면 사용법 자체가 테스트 명세가 됩니다.
- 팀원이 함수 시그니처를 몰라도 예제만 읽고 바로 동작을 이해할 수 있습니다.
예제 3) 모듈 단위로 실행하기
>>> # 파일: text_utils.py 라고 가정
>>> # 터미널에서 실행
>>> # python -m doctest -v text_utils.py
>>> print("doctest는 파일의 모든 docstring 예제를 훑어 검사한다")
doctest는 파일의 모든 docstring 예제를 훑어 검사한다
해설:
- 함수별 수동 실행이 아니라 파일 단위로 자동 검증할 수 있습니다.
-v옵션을 쓰면 어떤 케이스가 통과/실패했는지 로그가 상세히 출력됩니다.
예제 4) __main__ 블록으로 자체 점검 스크립트 만들기
>>> import doctest
>>> def add(a, b):
... """두 수를 더한다.
...
... >>> add(2, 3)
... 5
... """
... return a + b
...
>>> if __name__ == "__main__":
... doctest.testmod(verbose=True)
...
해설:
- 별도 테스트 러너 없이도 파일 단독 실행으로 예제 검증이 가능합니다.
- 작은 유틸 파일이나 교육용 코드에서 특히 편리합니다.
자주 하는 실수
실수 1) 출력 공백/개행을 가볍게 보고 실패하는 경우
>>> def greet(name):
... """인사 메시지 반환
...
... >>> greet("건우")
... '안녕하세요, 건우님'
... """
... return f"안녕하세요, {name}님 "
...
>>> greet("건우")
'안녕하세요, 건우님 '
원인:
- doctest는 문자열을 "거의 같다"가 아니라 정확히 같다로 비교합니다.
- 끝 공백 하나, 줄바꿈 하나 차이도 실패로 처리됩니다.
해결:
>>> def greet(name):
... """인사 메시지 반환
...
... >>> greet("건우")
... '안녕하세요, 건우님'
... """
... return f"안녕하세요, {name}님"
...
>>> greet("건우")
'안녕하세요, 건우님'
실수 2) 비결정적 값(현재 시간/랜덤)을 그대로 예제에 넣는 경우
- 증상: 어제는 통과했는데 오늘은 doctest가 실패합니다.
- 원인: 출력이 실행 시점마다 달라서 정답 문자열이 고정되지 않습니다.
- 해결: 비결정적 값은 doctest에 직접 쓰지 말고, 입력이 고정된 순수 함수 중심으로 예제를 구성합니다.
>>> import random
>>> def pick_one(items):
... return random.choice(items)
...
>>> # 아래처럼 고정 출력 기대를 걸면 불안정함
>>> # pick_one(["A", "B", "C"])
>>> # 'A'
>>> print("랜덤/시간 의존 로직은 doctest보다 unittest/pytest + mock이 더 적합")
랜덤/시간 의존 로직은 doctest보다 unittest/pytest + mock이 더 적합
실수 3) 예외 케이스를 설명만 하고 검증하지 않는 경우
>>> def divide(a, b):
... """나눗셈
...
... >>> divide(10, 2)
... 5.0
... """
... return a / b
...
>>> divide(10, 2)
5.0
원인:
- 정상 케이스만 넣으면 문서가 예쁘게 보이지만, 실제 장애 포인트(0으로 나누기 등)를 놓칩니다.
해결:
>>> def divide(a, b):
... """나눗셈
...
... >>> divide(10, 2)
... 5.0
... >>> divide(3, 0)
... Traceback (most recent call last):
... ...
... ZeroDivisionError: division by zero
... """
... return a / b
...
- 예외도 docstring에 명시해 두면, 사용자와 개발자 모두 함수의 경계를 분명히 이해할 수 있습니다.
실무 패턴
- 입력 검증 규칙: doctest에는 "대표 입력 2~4개"만 넣고, 세부 경계값/대량 케이스는
unittest나pytest로 분리합니다. - 로그/예외 처리 규칙: 운영 코드의 로깅 출력 자체를 doctest로 맞추려 하지 말고, 반환값/예외 계약 중심으로 검증합니다.
- 재사용 함수/구조화 팁: 도메인 유틸 함수(문자열 정리, 변환, 포맷)부터 doctest를 적용하면 유지보수 체감이 빠릅니다.
- 성능/메모리 체크 포인트: doctest는 문서 검증에 초점이 있으므로 성능 측정 용도로 쓰지 않습니다. 성능은
timeit, 프로파일러, 벤치마크 스크립트로 분리합니다.
현업에서는 보통 이렇게 역할을 나눕니다. 1) doctest는 문서 예제 신뢰성 확보, 2) unittest/pytest는 로직 정확성 및 회귀 방지, 3) 통합 테스트는 실제 의존성 검증. 이 세 가지를 함께 쓰면 문서와 코드가 따로 노는 문제를 크게 줄일 수 있습니다. 특히 사내 공용 유틸 패키지에서는 "README 예제는 되는데 실제 코드에서는 안 되는" 사고가 자주 나는데, 해당 예제를 doctest로 옮겨 두면 배포 전에 자동으로 깨짐을 감지할 수 있습니다. 결국 핵심은 모든 테스트를 doctest로 하려는 게 아니라, 문서가 오래돼 거짓말하지 않게 만드는 데 doctest를 정확히 쓰는 것입니다.
오늘의 결론
한 줄 요약: doctest는 문서를 장식이 아닌 실행 가능한 계약으로 바꿔 주는 가장 가벼운 테스트 도구입니다.
기억할 것:
- docstring 예제는 "사용 가이드"이면서 동시에 "자동 테스트"가 될 수 있습니다.
- 랜덤/시간/외부 I/O 같은 비결정적 로직은 doctest에 억지로 넣지 않습니다.
- 문서 신뢰성은 개발 속도만큼 중요하며, doctest는 그 비용을 크게 낮춰 줍니다.
연습문제
normalize_phone(text)함수를 만들고, 하이픈/공백이 섞인 입력을 숫자만 남기는 doctest 예제 3개를 작성해 보세요.to_bool(text)함수를 만들어"yes","no","1","0"입력을True/False로 변환하는 doctest를 작성해 보세요. 잘못된 입력은ValueError를 발생시키세요.- 현재 작성 중인 유틸 모듈 하나를 골라, 기존 docstring 예제를 doctest 실행 가능한 형식으로 2개 이상 고쳐 보세요.
이전 강의 정답
fetch_profile(api, user_id)의 정상/예외 시나리오 테스트
>>> from unittest.mock import Mock
>>> def fetch_profile(api, user_id):
... try:
... return {"ok": True, "data": api.get_user(user_id)}
... except TimeoutError:
... return {"ok": False, "error": "timeout"}
...
>>> api = Mock()
>>> api.get_user.return_value = {"id": "u-1", "name": "Toby"}
>>> fetch_profile(api, "u-1")
{'ok': True, 'data': {'id': 'u-1', 'name': 'Toby'}}
>>> api.get_user.side_effect = TimeoutError("late")
>>> fetch_profile(api, "u-1")
{'ok': False, 'error': 'timeout'}
is_discount_time()를patch로 오전/오후 검증
>>> from unittest.mock import patch
>>> import datetime as dt
>>> def is_discount_time():
... now = dt.datetime.now()
... return 9 <= now.hour < 12
...
>>> with patch("datetime.datetime") as fake_dt:
... fake_dt.now.return_value = dt.datetime(2026, 2, 17, 10, 0, 0)
... print(is_discount_time())
...
True
>>> with patch("datetime.datetime") as fake_dt:
... fake_dt.now.return_value = dt.datetime(2026, 2, 17, 15, 0, 0)
... print(is_discount_time())
...
False
send_email(mailer, to, body)에서 호출 인자 검증
>>> from unittest.mock import Mock
>>> def send_email(mailer, to, body):
... subject = "[알림] 작업 결과"
... payload = f"수신자:{to}\n본문:{body}"
... mailer.send(to=to, subject=subject, body=payload)
...
>>> mailer = Mock()
>>> send_email(mailer, "[email protected]", "배포 완료")
>>> mailer.send.assert_called_once_with(
... to="[email protected]",
... subject="[알림] 작업 결과",
... body="수신자:[email protected]\n본문:배포 완료",
... )
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 실행 명령:
python -m doctest -v <파일명>.py - 권장 조합: doctest(문서 예제 검증) + unittest/pytest(정밀 로직 검증)