05. 타입 힌트와 테스트
코드의 계약을 드러내고, 바뀌어도 깨지지 않는지 확인하는 방법을 정리합니다.
함수 타입 힌트
Python의 타입 힌트는 실행에 영향을 주지 않습니다. 인터프리터는 검사하지 않고 그냥 지나갑니다.
def add(a: int, b: int) -> int:
return a + b
add("a", "b") # 실행됩니다. 결과는 "ab"그래도 적는 이유는 편집기의 자동 완성과 별도 검사 도구, 그리고 읽는 사람 때문입니다.
from pathlib import Path
def load_todos(path: Path) -> list[dict[str, object]]:
...
def find_todo(todos: list[Todo], todo_id: int) -> Todo | None:
for todo in todos:
if todo.id == todo_id:
return todo
return Nonelist[str]과 int | None 표기는 비교적 최근 문법입니다. 예전 코드에서는 typing 모듈의 List, Optional을 볼 수 있습니다.
# 예전 표기
from typing import List, Optional, Dict
def find(todos: List[dict], id: int) -> Optional[dict]: ...
# 지금 표기
def find(todos: list[dict], id: int) -> dict | None: ...자주 쓰는 것들입니다.
from collections.abc import Callable, Iterable, Sequence
def apply(items: Iterable[int], fn: Callable[[int], str]) -> list[str]:
return [fn(item) for item in items]받는 쪽은 넓게, 돌려주는 쪽은 좁게 적습니다. 인자를 list가 아닌 Iterable로 받으면 제너레이터도 넘길 수 있습니다.
반복되는 긴 표기에는 이름을 붙입니다.
type TodoDict = dict[str, str | bool] # Python 3.12+
TodoDict = dict[str, str | bool] # 그 이전검사는 별도 도구가 합니다.
pip install mypy
mypy src의존성 기록
무엇을 깔아야 이 코드가 도는지 적어 둡니다. 가장 단순한 방법은 requirements.txt입니다.
pip freeze > requirements.txt
pip install -r requirements.txt다만 pip freeze는 직접 설치한 것과 딸려 온 것을 구분하지 않습니다. 목록이 길어지고, 나중에 무엇을 지워도 되는지 알 수 없게 됩니다. 직접 쓰는 것만 적어 두는 편이 낫습니다.
requests>=2.32
pytest>=8.0요즘은 pyproject.toml 하나에 모으는 방식을 많이 씁니다.
[project]
name = "todo"
requires-python = ">=3.11"
dependencies = ["requests>=2.32"]
[project.optional-dependencies]
dev = ["pytest>=8.0", "mypy>=1.11"]pip install -e ".[dev]"실행에 필요한 것과 개발할 때만 쓰는 것을 나눠 두면, 배포 환경에 테스트 도구가 섞이지 않습니다.
단위 테스트
pytest를 많이 씁니다. test_로 시작하는 파일과 함수를 찾아 실행합니다.
# test_todo.py
import pytest
from src.todo import add_todo, EmptyTitleError
def test_목록_끝에_추가한다():
todos = [{"title": "공부", "done": False}]
result = add_todo(todos, "운동")
assert len(result) == 2
assert result[1]["title"] == "운동"
def test_원본을_바꾸지_않는다():
todos = []
add_todo(todos, "운동")
assert todos == []
def test_제목이_비면_오류를_낸다():
with pytest.raises(EmptyTitleError):
add_todo([], " ")pytest
pytest -v # 테스트 이름까지 출력
pytest -k 제목 # 이름으로 골라 실행같은 준비가 반복되면 fixture로 뺍니다.
@pytest.fixture
def todos():
return [{"title": "공부", "done": False}]
def test_필터(todos):
assert len(undone(todos)) == 1입력만 다른 테스트는 묶어서 씁니다.
@pytest.mark.parametrize(
"text,expected",
[("1", 1), ("10", 10), ("-1", -1)],
)
def test_파싱(text, expected):
assert parse(text) == expected파일을 다루는 코드는 실제 경로 대신 임시 디렉터리를 받습니다. 테스트가 서로 간섭하지 않습니다.
def test_저장(tmp_path):
target = tmp_path / "todos.json"
save(target, [{"title": "공부"}])
assert target.exists()무엇을 테스트할지는 규칙을 기준으로 고릅니다. 빈 제목을 막는지, 원본을 바꾸지 않는지, 경계값에서 어떻게 되는지 같은 것들입니다. 단순한 대입과 반환까지 전부 덮을 필요는 없습니다.
명령행 인자
스크립트를 다른 사람이 쓰게 하려면 인자를 받아야 합니다. sys.argv를 직접 파싱하는 대신 argparse를 씁니다.
import argparse
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="할 일 목록을 관리합니다")
parser.add_argument("command", choices=["list", "add"])
parser.add_argument("title", nargs="?", help="추가할 제목")
parser.add_argument(
"--file",
type=Path,
default=Path("todos.json"),
help="저장 파일 경로",
)
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
if args.command == "add" and not args.title:
parser.error("add 명령에는 제목이 필요합니다")
run(args)
if __name__ == "__main__":
main()python main.py add "공부" --file data/todos.json
python main.py --help # 도움말이 자동으로 만들어집니다type=Path를 지정하면 문자열이 아니라 Path로 변환되어 들어옵니다. --help는 적어 둔 help 문구로 자동 생성됩니다.
종료 코드를 맞춰 주면 다른 스크립트에서 이어 쓸 수 있습니다.
import sys
try:
run(args)
except TodoError as error:
print(f"오류: {error}", file=sys.stderr)
sys.exit(1)실패를 표준 에러로 내보내고 0이 아닌 코드로 끝내는 것이 명령행 도구의 관례입니다.