테스트는 종종 고급 주제처럼 보입니다. 프레임워크 이름도 낯설고, 어디까지 써야 하는지 감이 잡히지 않기 때문입니다. 그래서 입문자는 테스트를 나중에 배울 것으로 미뤄 두는 경우가 많습니다.
테스트의 본질은 그렇게 크지 않습니다. 내가 기대하는 결과를 미리 적어 두고, 코드가 그 결과를 내는지 확인하는 일입니다. 이 글에서는 함수 하나를 골라 테스트를 쓰고, 그것이 실패하는 화면까지 끝까지 따라가 보겠습니다.
왜 가장 작은 케이스부터인가
큰 시나리오로 시작하면 테스트가 깨졌을 때 얻는 정보가 적습니다. 로그인 전체 흐름을 검증하는 테스트가 빨간색이 되면, 원인은 입력 검증일 수도 있고 세션 처리일 수도 있고 화면 이동일 수도 있습니다. 결국 테스트를 돌린 뒤에 다시 처음부터 디버깅을 시작하게 됩니다.
반대로 입력과 출력이 분명한 함수 하나를 검증하면, 실패했을 때 범위가 그 함수 안으로 좁혀집니다. 테스트가 주는 실질적인 이득은 "동작함을 증명하는 것"보다 "고장 난 위치를 좁혀 주는 것"에 가깝습니다. 그래서 작은 케이스가 먼저입니다.
부수 효과도 있습니다. 테스트를 쓰려면 함수가 무엇을 받아 무엇을 돌려주는지 먼저 말로 정리해야 합니다. 정리가 되지 않는 함수는 대체로 하는 일이 너무 많습니다. 그 신호를 만나면 함수를 언제 쪼갤지를 같이 생각해 볼 수 있습니다.
점수를 등급으로 바꾸는 함수 하나
예로 쓸 함수는 점수를 받아 등급 문자를 돌려주는 함수입니다. 실제 코드에서 흔히 나오는 모양이고, 경계값이 분명해서 테스트를 붙이기 좋습니다. 파일 두 개를 같은 폴더에 만듭니다. 하나는 함수, 하나는 테스트입니다.
scoring.py & test_scoring.py
# scoring.py
def grade(score):
if score > 90:
return "A"
if score >= 80:
return "B"
return "C"
# test_scoring.py
from scoring import grade
def test_grade_returns_a_for_90():
assert grade(90) == "A"
def test_grade_returns_b_for_89():
assert grade(89) == "B"
테스트 파일 이름은 test_로 시작하고 함수 이름도 test_로 시작합니다. pytest는 이 규칙으로 테스트를 찾습니다. 함수 이름에 무엇을 기대하는지 적어 두면 나중에 실패 목록만 읽어도 상황이 보입니다.
실패 화면을 한 줄씩 읽습니다
이 코드에는 의도적으로 실수를 하나 남겨 두었습니다. 90점이 A여야 하는데 조건이 score > 90으로 되어 있습니다. 터미널에서 pytest를 실행하면 다음과 같은 화면이 나옵니다.
$ pytest
========================= test session starts =========================
platform darwin -- Python 3.12.3, pytest-8.1.1, pluggy-1.4.0
rootdir: /Users/me/code/first-tests
collected 2 items
test_scoring.py F. [100%]
============================== FAILURES ===============================
_____________________ test_grade_returns_a_for_90 _____________________
def test_grade_returns_a_for_90():
> assert grade(90) == "A"
E AssertionError: assert 'B' == 'A'
E
E - A
E + B
test_scoring.py:5: AssertionError
======================= short test summary info =======================
FAILED test_scoring.py::test_grade_returns_a_for_90 - AssertionError: assert 'B' == 'A'
===================== 1 failed, 1 passed in 0.02s =====================
처음 보면 글자가 많아 보이지만, 실제로 읽어야 할 줄은 네다섯 줄입니다.
collected 2 items는 pytest가 테스트 함수를 두 개 찾았다는 뜻입니다. 여기 숫자가 0이면 파일 이름이나 함수 이름이 규칙에 맞지 않는 것이지, 코드가 틀린 것이 아닙니다.
test_scoring.py F. 줄에서 F는 실패, .은 통과입니다. 파일에 적힌 순서대로 표시되므로, 첫 번째 테스트가 실패하고 두 번째가 통과했다는 것을 이 한 줄로 알 수 있습니다.
FAILURES 아래 블록이 본론입니다. > 기호가 붙은 줄이 실패가 발생한 바로 그 줄입니다. 여기서는 assert grade(90) == "A"입니다. 그 아래 E로 시작하는 줄들이 pytest가 대신 계산해 준 내용입니다.
AssertionError: assert 'B' == 'A'는 왼쪽 값이 실제로 나온 결과, 오른쪽 값이 내가 기대한 결과라는 뜻입니다. 함수가 'B'를 돌려줬는데 나는 'A'를 기대했습니다. 이어지는 - A와 + B는 같은 내용을 차이 형식으로 보여 준 것입니다. 빼기 줄이 기대값, 더하기 줄이 실제값입니다.
test_scoring.py:5는 파일과 줄 번호입니다. 에디터에서 바로 그 줄로 이동하면 됩니다. 마지막 요약 줄은 몇 개가 실패하고 몇 개가 통과했는지 알려 줍니다. 테스트가 수십 개로 늘어나면 이 요약과 short test summary info 목록만 보고 움직이게 됩니다.
여기까지 읽으면 고칠 곳이 정해집니다. score > 90을 score >= 90으로 바꾸고 다시 실행하면 2 passed가 나옵니다. 실패 화면을 읽는 순서 자체는 일반적인 에러 읽기 순서와 같습니다. 테스트는 그 순서를 자동으로 반복해 주는 장치일 뿐입니다.
경계값을 하나씩 늘려 갑니다
테스트 두 개로 끝내지 않고, 조건문이 갈라지는 지점마다 하나씩 추가합니다. 등급 함수라면 90, 89, 80, 79처럼 조건이 바뀌는 바로 앞뒤 값이 후보입니다. 버그는 대체로 이 자리에서 나옵니다. 정중앙에 있는 값인 95나 50은 이미 다른 케이스가 지나가는 길목이라 새로 알려 주는 것이 적습니다.
다음으로 있을 법한 이상한 입력을 하나 넣습니다. 음수 점수, 100을 넘는 점수, 숫자가 아닌 값 같은 것들입니다. 여기서 중요한 것은 테스트를 통과시키는 것이 아니라, 그런 입력이 들어오면 어떻게 되어야 하는지 스스로 정하는 일입니다. 오류를 내야 하는지, 기본값을 돌려줘야 하는지 정하고 나면 테스트는 그 결정을 적어 두는 자리가 됩니다.
한 번에 여러 개를 쓰지 않는 편이 좋습니다. 테스트 하나를 추가하고 실행하고, 실패하면 고치고 다시 추가합니다. 이 리듬이 붙으면 테스트를 쓰는 시간이 디버깅에 쓰던 시간을 대신하기 시작합니다. 파이썬으로 여기서 더 나아가고 싶다면 파이썬 테스트 가이드에 픽스처와 파라미터화까지 정리되어 있습니다.
테스트를 먼저 쓰는 것이 손해인 구간
테스트를 항상 먼저 쓰라는 조언은 상황을 가리지 않고 적용되지 않습니다. 손해가 나는 구간이 분명히 있습니다.
설계가 아직 흔들리는 탐색 코드가 그렇습니다. 함수 이름과 인자 구성이 오늘 안에 세 번 바뀔 예정이라면, 테스트는 그 세 번을 따라다니며 같이 고쳐져야 합니다. 아직 무엇을 만들지 모르는 단계에서는 화면에 값을 찍어 보며 감을 잡는 편이 빠릅니다. 모양이 잡힌 다음에 테스트를 붙여도 됩니다.
화면 시안도 비슷합니다. 여백과 색과 배치가 계속 바뀌는 단계에서 그 값을 테스트로 고정하면, 디자인을 고칠 때마다 테스트를 고치게 됩니다. 이런 영역은 눈으로 확인하는 쪽이 정확합니다. 화면 쪽에서 테스트가 값을 하는 자리는 겉모습이 아니라 계산 로직입니다.
한 번 쓰고 버릴 스크립트도 예외입니다. 파일 이름을 한 번 일괄 변경하는 스크립트에 테스트를 붙이는 것은, 결과를 눈으로 확인하는 것보다 오래 걸립니다. 다만 그 스크립트를 두 번째로 실행하게 된다면 그때는 생각을 바꿀 시점입니다.
마지막으로 커버리지 숫자를 목표로 삼는 순간 생기는 문제가 있습니다. 몇 퍼센트를 채우겠다고 정하면 사람은 채우기 쉬운 곳부터 채웁니다. 분기가 없는 단순한 함수, 값을 그대로 돌려주는 코드가 먼저 덮이고, 정작 조건이 얽힌 부분은 뒤로 밀립니다. 그러면 숫자는 올라가는데 실제로 잡히는 버그는 늘지 않습니다. 커버리지는 "아직 아무도 실행해 보지 않은 코드가 어디인지" 알려 주는 지도로 쓸 때 값을 합니다. 목표로 삼는 순간 지도가 아니라 성적표가 됩니다.
오늘 해 볼 것
- 지금 쓰고 있는 코드에서 입력과 출력이 분명한 함수를 하나 고릅니다.
- 그 함수가 통과해야 하는 정상 케이스 하나를 테스트로 씁니다.
- 일부러 기대값을 틀리게 적어 실패 화면을 한 번 봅니다.
- 실패 화면에서
>줄과E줄, 줄 번호를 찾아봅니다. - 기대값을 바로잡고, 조건이 갈라지는 경계값으로 케이스를 하나 더 추가합니다.
테스트 함수 이름을 test_1, test_2 대신 test_grade_returns_a_for_90처럼 문장으로 적어 두면, 실패 목록 자체가 무엇이 깨졌는지 알려 주는 문서가 됩니다. 이름이 길어지는 것은 문제가 되지 않습니다. 이 이름은 실패했을 때만 읽히기 때문입니다.
테스트가 실패했을 때 테스트를 고쳐서 통과시키는 것입니다. 기대값을 실제 출력에 맞춰 바꾸면 화면은 초록색이 되지만, 방금 기록한 것은 "코드가 지금 하는 동작"일 뿐 "코드가 해야 하는 동작"이 아닙니다. 기대값을 바꾸기 전에 무엇이 맞는 답인지 먼저 정합니다. 정하지 못하겠다면 그 판단이 필요한 사람에게 물어볼 시점입니다.
테스트는 체계를 배우는 일이 아니라, 기대를 문장으로 적어 두고 컴퓨터에게 확인을 맡기는 습관입니다.