G-2: 테스트 입문
목차 30
안녕하세요, 홍순구 튜터입니다. 지난 시간엔 파이썬에 딸려 오는 도구상자를 열어 Counter·datetime·re 같은 표준 라이브러리를 골라 익혔어요. 그러면서 제가 마지막에 이런 약속을 했죠. "지금까지 만든 코드가 진짜 잘 도는지는 매번 직접 실행해 눈으로 확인했는데, 다음엔 그 확인을 자동으로 해주는 도구를 만난다" 고요. 오늘이 바로 그날이에요.
그리고 오늘은 조금 특별해요. A-1 에서 변수와 print 로 시작해 자료구조, 함수, 클래스, 예외, 타입 힌트, 비동기, 표준 라이브러리까지 — 파이썬을 한 바퀴 돌았어요. 오늘 테스트로 그 여정을 멋지게 닫습니다. 이 과목의 마지막 시간이에요.
테스트가 뭔지 한 문장으로 먼저 잡고 갈게요. "이 함수에 이 값을 넣으면 이 결과가 나와야 한다" 를 코드로 적어두는 거예요. 그러면 나중에 코드를 고칠 때마다 컴퓨터가 그 약속을 대신 검사해 줘요. 사람이 매번 눈으로 확인하던 일을 기계에게 맡기는 거죠.
오늘의 여정 — 코드가 잘 도는지 컴퓨터에게 맡기기
① assert "이건 이래야 한다" 를 한 줄로 약속
② 첫 테스트 test_*.py 파일과 uv run pytest
③ 실패는 친구 틀렸을 때 pytest 가 보여주는 친절한 메시지
④ 경계 테스트 빈 입력·하나·여러 개 — 여러 경우를 검사
⑤ 예외 테스트 pytest.raises 로 "에러가 나야 정상" 까지
⑥ fixture 반복되는 준비를 미리 만들어두기
⑦ parametrize 같은 테스트, 여러 입력을 표처럼
⑧ 정리 unittest 와의 관계, 그리고 테스트 환경
💡 오늘 수업의 핵심 — "맞는지 매번 눈으로 보지 말고, 컴퓨터에게 검사를 맡기자"
지금까지 우리는 함수를 만들 때마다 print 로 결과를 찍어보고 "음, 맞게 나왔네" 하고 넘어갔어요. 그런데 코드를 고치다 보면 멀쩡하던 다른 부분이 슬쩍 망가지기도 하죠. 그걸 매번 사람이 다 확인하긴 어려워요. 테스트는 그 확인을 글로 적어두고, 명령 한 줄로 전부 자동 검사하게 만드는 일이에요.
🎯 학습 목표
- 평문
assert와 pytest 로 함수가 기대대로 도는지 자동으로 검사해요. pytest.raises로 "에러가 나야 정상" 인 경우까지 검증하고, fixture·parametrize 로 테스트를 깔끔하게 정리해요.- 표준 라이브러리
unittest와의 관계를 알고, 우리 프로젝트의 테스트 환경을 이해해요.
Step 1: "이건 이래야 한다 — assert 한 줄"
테스트의 가장 작은 알맹이부터 볼게요. 파이썬엔 assert(단언) 라는 키워드가 있어요. "이 조건은 반드시 참이어야 한다" 고 못 박는 거예요. 조건이 참이면 아무 일도 안 일어나고, 거짓이면 그 자리에서 AssertionError 를 내며 멈춰요.
REPL 에서 직접 해볼게요.
assert 1 + 1 == 2 # 통과 — 아무 일도 안 일어나요
assert 1 + 1 == 3 # AssertionError!
assert 1 + 1 == 2 는 조건이 참이라 조용히 지나가요. 그런데 assert 1 + 1 == 3 은 조건이 거짓이죠. 실행하면 이렇게 멈춰요.
Traceback (most recent call last):
File "<string>", line 1, in <module>
assert 1 + 1 == 3
^^^^^^^^^^
AssertionError
^^^^^^^^^^ 가 어느 식이 거짓이었는지 콕 짚어줘요. 파이썬 3.14 의 친절한 에러 표시죠. 테스트란 결국 이 assert 의 모음이에요. "우리 함수가 이런 입력엔 이런 결과를 내야 한다" 를 assert 로 적어두면, 그게 곧 테스트가 돼요.
지난 시간 만든 해시태그 빈도 함수로 한번 적어볼까요? #봄 이 두 번 든 리스트를 넣으면 [("#봄", 2)] 가 나와야겠죠.
from instagram.insights import tag_ranking
assert tag_ranking(["#봄", "#봄"]) == [("#봄", 2)]
이 한 줄이 "tag_ranking 은 이렇게 동작해야 한다" 는 약속이에요. 통과하면 함수가 약속을 지킨 거고, AssertionError 가 나면 어딘가 어긋난 거예요.
💡 한 줄 정리
assert 조건 은 조건이 참이면 조용히 지나가고, 거짓이면 AssertionError 로 멈춰요. "이 입력엔 이 결과" 를 assert 로 적으면 그게 테스트의 알맹이예요.
🙋 학생 질문 — "튜터님, assert 를 그냥 실제 코드 안에도 써도 되나요?"
쓸 수는 있지만, 용도를 구분하는 게 좋아요. assert 는 "개발하면서 확인하는 용도" 예요. 사실 파이썬을 최적화 모드(python -O)로 실행하면 assert 문은 전부 무시되고 사라져요. 그래서 "사용자가 잘못된 값을 넣었을 때 꼭 막아야 하는 진짜 안전장치" 는 assert 가 아니라, 지난 E-1 에서 배운 if ...: raise 로 만들어야 해요.
정리하면, 입력을 검증하는 진짜 방어는 raise 로, "내 코드가 기대대로 도는지 확인" 은 테스트의 assert 로 — 이렇게 나눠 생각하면 돼요. 오늘 assert 는 전부 테스트 안에서만 쓸 거예요.
Step 2: "첫 테스트 파일 — test_*.py 와 uv run pytest"
assert 를 한 줄씩 직접 실행하는 건 결국 사람이 하는 일이죠. 이걸 자동으로 모아 돌려주는 도구가 pytest 예요. 우리 프로젝트엔 이미 들어 있어요(없으면 uv add --dev pytest 한 줄로 설치하는데, 자세한 건 Step 8 에서 볼게요).
pytest 에는 약속이 두 가지 있어요. 첫째, 테스트는 tests/ 폴더 안에 test_ 로 시작하는 파일에 담아요. 둘째, 그 안에서 test_ 로 시작하는 함수가 테스트 하나예요. pytest 가 이 규칙을 보고 테스트를 알아서 찾아내요.
해시태그 함수를 검증하는 첫 테스트 파일이에요.
# tests/test_g2_tags.py
"""해시태그 빈도 함수(tag_ranking) 테스트 — G-2 본문 예제."""
from instagram.insights import tag_ranking
def test_tag_ranking():
tags = ["#봄", "#꽃", "#봄", "#여행", "#봄", "#꽃"]
assert tag_ranking(tags) == [("#봄", 3), ("#꽃", 2), ("#여행", 1)]
평범한 함수예요. 안에 assert 한 줄이 들었을 뿐이죠. 이제 명령 한 줄로 돌려봐요.
uv run pytest tests/test_g2_tags.py
============================= test session starts ==============================
platform darwin -- Python 3.14.4, pytest-9.1.0, pluggy-1.6.0
rootdir: /.../instagram-python-basic
configfile: pyproject.toml
collected 4 items
tests/test_g2_tags.py .... [100%]
============================== 4 passed in 0.00s ===============================
읽는 법을 짚어볼게요. collected 4 items 는 테스트 함수를 4개 찾았다는 뜻이에요(이 파일엔 잠시 뒤 Step 4 에서 더 채울 거예요). 그 아래 점(.) 하나가 통과한 테스트 하나예요. 마지막 초록 줄 4 passed 가 "4개 다 통과" 라는 결론이고요. 우리가 한 일은 assert 를 적고 명령 한 줄을 친 것뿐인데, 검사는 pytest 가 다 해줬어요.
💡 한 줄 정리
pytest 는 tests/ 안 test_*.py 파일에서 test_ 로 시작하는 함수를 찾아 자동으로 돌려요. uv run pytest 한 줄이면 통과(.)와 실패가 한눈에 나와요.
🙋 학생 질문 — "왜 함수 이름이 꼭 test_ 로 시작해야 하나요?"
pytest 가 "어떤 함수가 테스트인지" 를 그 이름으로 구분하기 때문이에요. 파일 안엔 테스트가 아닌 보조 함수가 섞일 수도 있잖아요. pytest 는 test_ 로 시작하는 함수만 골라 실행하고, 나머지는 그냥 건너뛰어요.
그래서 만약 함수 이름을 check_tag_ranking 처럼 지으면, pytest 가 그걸 테스트로 보지 않아 실행하지 않아요. 이름 규칙은 외우기보다 "pytest 와 나 사이의 약속" 이라고 생각하면 편해요.
Step 3: "실패는 친구 — pytest 의 친절한 메시지"
A-1 에서 "에러는 친구" 라고 했던 거 기억나요? 에러 메시지를 잘 읽으면 고칠 길이 보인다고요. 테스트의 실패 메시지도 똑같아요. 오히려 pytest 의 실패 메시지는 평소 에러보다 훨씬 친절해요.
일부러 틀린 기대값을 적어볼게요. #봄 은 3번 나오는데 기대값을 2 라고 잘못 적으면 어떻게 될까요?
def test_tag_ranking():
tags = ["#봄", "#꽃", "#봄", "#여행", "#봄", "#꽃"]
assert tag_ranking(tags) == [("#봄", 2), ("#꽃", 2), ("#여행", 1)]
uv run pytest 로 돌리면 빨간 글씨로 이렇게 나와요.
=================================== FAILURES ===================================
_______________________________ test_tag_ranking _______________________________
def test_tag_ranking():
tags = ["#봄", "#꽃", "#봄", "#여행", "#봄", "#꽃"]
> assert tag_ranking(tags) == [("#봄", 2), ("#꽃", 2), ("#여행", 1)]
E AssertionError: assert [('#봄', 3), (...), ('#여행', 1)] == [('#봄', 2), (...), ('#여행', 1)]
E
E At index 0 diff: ('#봄', 3) != ('#봄', 2)
E Use -v to get more diff
tests/test_g2_tags.py:9: AssertionError
=========================== short test summary info ============================
FAILED tests/test_g2_tags.py::test_tag_ranking - AssertionError: ...
============================== 1 failed in 0.01s ===============================
여기서 pytest 가 얼마나 친절한지 보세요. > 표시가 실패한 바로 그 줄을 가리켜요. 그 아래 E 줄들은 "실제로는 ('#봄', 3) 이 나왔는데 너는 ('#봄', 2) 를 기대했다" 고 두 값을 나란히 비교해 줘요. 게다가 At index 0 diff 로 "리스트의 0번째가 다르다" 까지 콕 짚어주죠. 평범한 assert 였다면 그냥 AssertionError 한 줄로 끝났을 텐데요.
빨간 F 와 초록 . 의 색만 봐도 상태를 알 수 있어요. 기대값을 3 으로 고치면 다시 초록 . 으로 돌아와요. 이렇게 "빨강 → 초록" 을 오가는 게 테스트와 함께 코드를 고치는 리듬이에요.
💡 한 줄 정리
pytest 는 실패한 줄(>)과 "기대 vs 실제" 를 나란히, 어디가 다른지(At index 0 diff)까지 짚어줘요. 실패 메시지를 읽으면 어디를 고칠지 바로 보여요.
🙋 학생 질문 — "At index 0 diff 가 정확히 무슨 뜻이에요?"
두 리스트를 앞에서부터 하나씩 비교하다가, 처음으로 달라진 위치를 알려주는 거예요. index 0 은 리스트의 맨 앞(0번째) 항목이라는 뜻이죠. 우리 경우엔 실제 ('#봄', 3) 과 기대 ('#봄', 2) 가 0번째에서 처음 어긋났어요.
리스트가 길 때 특히 고마운 기능이에요. 수십 개 중 어디가 틀렸는지 직접 눈으로 찾을 필요 없이, pytest 가 "여기서부터 달라" 라고 먼저 알려주니까요.
Step 4: "경계를 테스트하라 — 여러 경우"
정상적인 입력 하나가 통과한다고 안심하면 안 돼요. 진짜 버그는 보통 가장자리에서 나와요. 빈 리스트를 넣으면? 항목이 딱 하나면? 요청한 개수보다 적게 들었으면? 이렇게 "끄트머리(경계) 경우" 를 함께 검사해야 든든해요.
한 함수에 테스트 함수를 여러 개 둘 수 있어요. 각각 독립이라, 하나가 실패해도 나머지는 그대로 돌아가요.
def test_empty_tags():
assert tag_ranking([]) == []
def test_single_tag():
assert tag_ranking(["#봄"]) == [("#봄", 1)]
def test_top_two():
tags = ["#봄", "#꽃", "#봄", "#여행", "#봄", "#꽃"]
assert tag_ranking(tags, 2) == [("#봄", 3), ("#꽃", 2)]
test_empty_tags 는 "빈 리스트를 넣으면 빈 결과가 나오나" 를 확인해요. 입력이 없을 때 함수가 엉뚱하게 터지지 않는지 보는 거죠. test_single_tag 는 항목이 하나뿐인 경우, test_top_two 는 상위 2개만 뽑는 경우예요. 처음 만든 test_tag_ranking 까지 더하면 이 파일엔 테스트가 4개가 됐어요. 아까 collected 4 items 가 바로 이거였죠.
이렇게 "정상 1개 + 경계 몇 개" 를 갖춰두면, 나중에 함수를 고쳐도 어느 경우가 깨지는지 pytest 가 바로 알려줘요.
💡 한 줄 정리
정상값 하나로는 부족해요. 빈 입력·항목 하나·개수 제한 같은 경계 경우를 따로 테스트로 두면, 가장자리에서 숨은 버그를 잡아낼 수 있어요.
🙋 학생 질문 — "테스트를 몇 개나 써야 충분한가요?"
딱 떨어지는 정답은 없어요. 다만 입문 단계에서 기억하면 좋은 감각이 있어요. "대표적인 정상 경우 1개 + 경계 경우 몇 개(빈 것·하나·아주 많은 것) + 특이한 경우" 정도면 대부분 충분해요.
너무 많이 쓰려고 부담 가질 필요는 없어요. "이 함수가 어디서 잘못되기 쉬울까?" 를 떠올리고, 그 경우를 한두 개 적는 것만으로도 안전망이 크게 두꺼워져요.
Step 5: "예외도 테스트한다 — pytest.raises"
지난 E-1 에서 우리는 잘못된 입력엔 예외를 던지게 만들었죠. 이메일이 중복이면 DuplicateEmailError, 캡션이 비었으면 ValueError 처럼요. 그런데 여기서 묘한 문제가 생겨요. "에러가 나는 게 정상" 인 경우를 어떻게 테스트하죠? 그냥 함수를 부르면 예외가 터지면서 테스트 자체가 실패해 버리잖아요.
이럴 때 쓰는 게 pytest.raises 예요. "이 블록 안에서 이 예외가 나면 통과" 라고 거꾸로 말해주는 거죠. 검증할 함수부터 볼게요.
# src/instagram/signup.py
from instagram.exceptions import DuplicateEmailError
def register(username: str, email: str, taken_emails: list[str]) -> dict:
"""새 회원을 등록한다. 이름이 비었거나 이메일이 중복이면 예외를 던진다."""
if not username.strip():
raise ValueError("사용자 이름은 비어 있을 수 없어요")
if email in taken_emails:
raise DuplicateEmailError(f"{email} 은(는) 이미 가입된 이메일이에요")
return {"username": username.strip(), "email": email}
def create_post(caption: str) -> dict:
"""게시물을 만든다. 캡션이 비어 있으면 예외를 던진다."""
if not caption.strip():
raise ValueError("캡션이 비어 있어요")
return {"caption": caption.strip(), "likes": 0}
이 함수들이 예외를 제대로 던지는지 테스트로 확인해요.
# tests/test_g2_signup.py
import pytest
from instagram.signup import register, create_post
from instagram.exceptions import DuplicateEmailError
def test_register_duplicate_email():
taken = ["minji@example.com", "jaehoon@example.com"]
with pytest.raises(DuplicateEmailError):
register("minji2", "minji@example.com", taken)
def test_register_empty_username():
with pytest.raises(ValueError):
register("", "new@example.com", [])
def test_create_post_empty_caption():
with pytest.raises(ValueError):
create_post(" ")
with pytest.raises(DuplicateEmailError): 는 "이 안에서 DuplicateEmailError 가 나면 정상" 이라는 뜻이에요. 실제로 예외가 나면 테스트가 통과하고, 어쩐 일로 예외가 안 나면 그땐 테스트가 실패해요. 우리가 E-1 에서 만든 DuplicateEmailError 같은 커스텀 예외도, 파이썬 내장 ValueError 도 똑같은 방식으로 검증돼요.
💡 한 줄 정리
"에러가 나야 정상" 인 경우는 with pytest.raises(예외): 블록으로 검사해요. 그 안에서 해당 예외가 나면 통과, 안 나면 실패예요. 커스텀 예외든 내장 예외든 똑같이 써요.
🙋 학생 질문 — "pytest.raises 블록 안에서 예외가 안 나면 어떻게 되나요?"
그땐 테스트가 실패해요. pytest 가 "여기서 ValueError 가 나야 하는데 아무 예외도 안 났다" 고 알려주죠.
이게 중요한 이유가 있어요. 예를 들어 누군가 register 함수를 고치다가 실수로 중복 검사를 빼버렸다고 해봐요. 그러면 중복 이메일을 넣어도 예외가 안 나겠죠. pytest.raises 테스트가 바로 그 순간 빨갛게 실패하면서 "방어막이 사라졌다" 고 경고해 줘요. 예외를 던지는 일도 함수의 중요한 약속이라, 이렇게 테스트로 지켜두는 거예요.
Step 6: "반복되는 준비를 미리 — fixture 맛보기"
방금 Step 5 테스트를 다시 보면, taken = ["minji@example.com", ...] 같은 준비 데이터를 테스트마다 다시 만들고 있어요. 테스트가 늘어날수록 같은 준비를 계속 복사해 붙이게 되죠. 이런 반복을 한곳에 모아두는 게 fixture(픽스처) 예요.
fixture 는 실험을 시작하기 전에 책상 위에 준비물을 미리 깔아주는 조수 같아요. @pytest.fixture 를 붙인 함수를 하나 만들어두면, 테스트는 그 이름을 인자로 받기만 하면 돼요. 그러면 pytest 가 알아서 그 준비물을 만들어 넣어줘요.
@pytest.fixture
def taken_emails():
return ["minji@example.com", "jaehoon@example.com"]
def test_register_new_member(taken_emails):
member = register("seungwoo", "seungwoo@example.com", taken_emails)
assert member["username"] == "seungwoo"
def test_register_duplicate_with_fixture(taken_emails):
with pytest.raises(DuplicateEmailError):
register("minji2", "minji@example.com", taken_emails)
taken_emails 라는 fixture 를 한 번 만들어두니, 두 테스트가 그냥 인자로 taken_emails 라고 받기만 했어요. pytest 가 테스트를 실행할 때 그 이름을 보고, fixture 함수를 호출해 결과를 슬쩍 넣어주는 거예요. 준비 데이터가 한 줄로 깔끔해졌죠. 데이터가 더 복잡해질수록 이 방식이 더 고마워져요.
💡 한 줄 정리
@pytest.fixture 로 준비 작업을 함수로 만들어두면, 테스트는 그 이름을 인자로 받기만 하면 pytest 가 알아서 넣어줘요. 반복되는 준비를 한곳에 모으는 도구예요.
🙋 학생 질문 — "fixture 는 테스트마다 새로 만들어지나요?"
기본적으로는 그래요. 테스트 함수 하나가 fixture 를 받을 때마다 fixture 함수가 새로 실행돼서, 매번 새 준비물을 받아요.
이게 왜 좋냐면, 한 테스트가 받은 데이터를 마음껏 바꿔도 다음 테스트엔 전혀 영향이 없거든요. 테스트끼리 서로 간섭하지 않고 독립적으로 도는 거죠. "각 테스트는 깨끗한 출발선에서 시작한다" — 이게 테스트를 믿을 수 있게 만드는 중요한 성질이에요.
Step 7: "같은 테스트, 여러 입력 — parametrize 맛보기"
Step 4 를 떠올려 보세요. test_empty_tags·test_single_tag 처럼 거의 똑같은 테스트를 입력만 바꿔 여러 개 썼죠. 검사하는 모양은 같은데 값만 다른 거예요. 이런 걸 표 한 장으로 묶어주는 게 @pytest.mark.parametrize 예요.
입력과 기대값의 목록을 주면, pytest 가 그 목록을 한 줄씩 꺼내 테스트를 따로따로 돌려줘요.
# tests/test_g2_parametrize.py
import pytest
from instagram.insights import tag_ranking
@pytest.mark.parametrize(
"tags, expected",
[
([], []),
(["#봄"], [("#봄", 1)]),
(["#봄", "#봄"], [("#봄", 2)]),
(["#봄", "#꽃", "#봄"], [("#봄", 2), ("#꽃", 1)]),
],
)
def test_tag_ranking_cases(tags, expected):
assert tag_ranking(tags, 2) == expected
"tags, expected" 는 "이 두 이름으로 값을 받겠다" 는 선언이에요. 그 아래 목록의 각 줄이 (tags, expected) 한 쌍이고요. 테스트 함수는 한 번만 썼는데, pytest 는 목록의 4줄만큼 4번 돌려요.
uv run pytest tests/test_g2_parametrize.py -v
tests/test_g2_parametrize.py::test_tag_ranking_cases[tags0-expected0] PASSED
tests/test_g2_parametrize.py::test_tag_ranking_cases[tags1-expected1] PASSED
tests/test_g2_parametrize.py::test_tag_ranking_cases[tags2-expected2] PASSED
tests/test_g2_parametrize.py::test_tag_ranking_cases[tags3-expected3] PASSED
-v 는 자세히 보여달라는 옵션이에요(verbose). 케이스마다 [tags0-expected0] 처럼 번호가 붙어 따로 표시되죠. 새 경우를 추가하고 싶으면 목록에 한 줄만 더 적으면 돼요. 비슷한 테스트 함수를 복사해 붙이는 수고가 사라지는 거예요.
💡 한 줄 정리
@pytest.mark.parametrize 는 입력·기대값 목록을 주면 케이스마다 테스트를 따로 돌려줘요. 새 경우를 추가하는 게 목록에 한 줄 더 적는 일로 끝나요.
🙋 학생 질문 — "케이스 하나가 실패하면 나머지도 같이 멈추나요?"
아니요, 각 케이스는 독립적으로 돌아요. 그래서 4개 중 3번째만 틀리면 pytest 가 [tags2-expected2] 처럼 어느 케이스가 실패했는지 콕 집어 알려주고, 나머지 케이스는 그대로 통과로 표시해요.
이게 parametrize 의 큰 장점이에요. "여러 입력을 한 번에 검사" 하면서도, 문제가 생기면 "정확히 어느 입력에서" 인지 바로 보이니까요.
Step 8: "정리 — unittest 와의 관계, 그리고 환경"
마지막으로 두 가지를 정리하며 닫을게요. 하나는 "pytest 말고 다른 테스트 도구는 없나" 이고, 다른 하나는 "테스트 환경은 어떻게 갖추나" 예요.
먼저 unittest. 사실 파이썬 표준 라이브러리에도 테스트 도구가 원래 들어 있어요. unittest 라고 하는데, 설치 없이 import 만으로 쓸 수 있죠. 다만 모양이 조금 더 무거워요. 클래스를 만들어 상속하고, assertEqual 같은 전용 메서드로 비교해요.
import unittest
from instagram.insights import tag_ranking
class TagRankingTest(unittest.TestCase):
def test_tag_ranking(self):
tags = ["#봄", "#꽃", "#봄"]
self.assertEqual(tag_ranking(tags, 2), [("#봄", 2), ("#꽃", 1)])
같은 검사를 pytest 로 쓰면 assert tag_ranking(...) == [...] 평문 한 줄이면 돼요. 클래스도, 전용 메서드도 필요 없죠. 이 가벼움과 친절한 실패 메시지 덕분에, 2026년 새로 시작하는 프로젝트는 대부분 pytest 를 써요. unittest 는 "표준 라이브러리에 내장돼 있어 설치가 막힌 환경에서도 쓸 수 있다" 는 강점이 있어, 둘 다 알아두면 좋아요.
다음은 환경이에요. pytest 는 표준 라이브러리가 아니라 따로 설치하는 도구예요. 우리 프로젝트는 pyproject.toml 에 이렇게 적혀 있어요.
[dependency-groups]
dev = [
"pytest>=9.1.0",
]
dev 라는 묶음에 pytest 가 들어 있죠. dev 의존성(개발 의존성) 은 "개발하고 테스트할 때만 쓰고, 실제 서비스로 배포할 땐 빠지는 도구" 를 뜻해요. 테스트 도구는 사용자에게 전달할 필요가 없으니까요. 새로 추가할 땐 uv add --dev pytest 한 줄이면 이 묶음에 들어가요.
[!WARNING] pytest 9 버전부터,
async함수를 테스트하려면 별도 플러그인이 필요해요. 플러그인 없이 비동기 테스트를 쓰면 그냥 실패해 버리죠. 다행히 오늘 우리가 검증한 함수는 전부 평범한(동기) 함수라, 그대로 깔끔하게 검사됐어요. 비동기 테스트는 나중에 필요할 때 플러그인을 더하면 돼요.
💡 한 줄 정리
unittest 는 표준 라이브러리 내장(클래스 기반), pytest 는 평문 assert 로 가벼워 입문에 좋아요. pytest 는 dev 의존성으로 설치하고, uv run pytest 로 돌려요.
🙋 학생 질문 — "그럼 unittest 는 이제 안 써도 되나요?"
꼭 그렇진 않아요. unittest 는 표준 라이브러리라 추가 설치 없이 어디서나 돌아간다는 점이 강력해요. 외부 패키지 설치가 제한된 회사나, 표준 라이브러리만 쓰자는 방침이 있는 프로젝트에선 여전히 unittest 를 골라요.
다만 대부분의 현대 프로젝트는 pytest 를 기본으로 써요. 그리고 재미있는 건, pytest 로 unittest 스타일의 테스트도 그대로 실행할 수 있다는 거예요. 그래서 "pytest 를 익혀두면 양쪽을 다 다룰 수 있다" 고 생각하면 마음이 편해요.
마무리
오늘은 코드가 잘 도는지 사람이 매번 확인하는 대신, 그 검사를 컴퓨터에게 맡기는 법을 배웠어요. assert 한 줄로 약속을 적고, pytest 로 그 약속들을 자동으로 검사했죠. 예외가 나야 정상인 경우(pytest.raises), 반복되는 준비(fixture), 여러 입력을 표처럼(parametrize) 다루는 법까지 맛봤어요.
오늘 배운 핵심 세 가지
💡 하나: 테스트는 "이 입력엔 이 결과" 를 assert 로 적어두는 것이고, pytest 는 tests/ 안 test_*.py 를 모아 uv run pytest 한 줄로 자동 검사해요.
💡 둘: pytest.raises 로 예외까지, @pytest.fixture 로 반복되는 준비를, @pytest.mark.parametrize 로 여러 케이스를 깔끔하게 검사할 수 있어요.
💡 셋: unittest 는 표준 라이브러리 내장, pytest 는 평문 assert 로 입문 친화예요. 테스트가 있으면 코드를 고칠 때마다 컴퓨터가 대신 약속을 지켜줘요.
과목을 마치며 — 우리가 걸어온 길
이게 이 과목의 마지막 시간이에요. A-1 에서 print("Hello") 한 줄로 떨면서 시작했는데, 지금은 테스트까지 짜고 있네요. 우리가 걸어온 길을 한 장으로 펼쳐 볼게요.
변수·입출력 A 첫 줄을 실행한다
└ 자료구조 B list·dict·set 에 담는다
└ 함수 C def 로 쪼갠다
└ 객체 D class·dataclass 로 묶는다
└ 예외·파일 E 실패를 다루고 파일에 남긴다
└ 모던·async F 타입 힌트·제너레이터·비동기로 끌어올린다
└ 표준 라이브러리·테스트 G 도구를 꺼내 쓰고, 검증한다
각 단계가 바로 앞 단계 위에 쌓였어요. 변수를 알아야 자료구조에 담고, 함수를 알아야 객체로 묶고, 예외를 알아야 안전하게 파일을 다뤘죠. 이제 여러분은 타입이 안전하고 테스트로 검증된 순수 파이썬 코드를 직접 쓸 수 있어요.
다음 시간 예고 — 다음 과목으로 가는 길
파이썬이라는 언어 자체는 여기서 한 바퀴를 마쳤어요. 이제 이 언어로 무엇을 만드느냐에 따라 길이 갈려요.
- 웹 백엔드 — 우리가 만든 함수를 인터넷으로 열어 진짜 서비스로 만들어요. 거기선 입력을 런타임에 검증해주는 도구를 만나는데, 한마디로 우리가 배운
@dataclass의 "값까지 자동으로 검사해주는" 버전이라고 보면 돼요. - 데이터 분석 — 표 형태의 큰 데이터를 다루는 도구로, 좋아요·팔로워 같은 숫자를 한꺼번에 분석해요.
- AI 활용 — 똑똑한 언어 모델을 우리 코드에 붙여, 캡션을 요약하거나 추천을 만들어요.
- 자료구조·알고리즘 — 오늘 배운 파이썬으로 코딩테스트의 문제들을 풀어내요.
어느 길로 가든, 오늘까지 다진 변수·자료구조·함수·객체·예외·타입·테스트가 바탕이 돼요. 그동안 정말 고생 많으셨어요. 첫 줄을 두려워하던 모습에서 여기까지 온 것만으로 충분히 멋져요. 다음 과목에서 또 만나요!
과제
오늘 배운 테스트를 직접 손으로 짜볼 차례예요. 함수를 만들고, 그 함수를 검증하는 테스트까지 함께 작성해 보세요. 테스트는 tests/ 폴더에 test_ 로 시작하는 파일로 두고, uv run pytest 로 돌려 확인하면 돼요.
[기초] 좋아요 등급 함수와 테스트
좋아요 수를 받아 등급을 돌려주는 like_label 함수를 만들고, 그 함수를 검증하는 테스트를 작성해 보세요. 1000 이상이면 "인기", 100 이상이면 "보통", 그 미만이면 "새 글" 이에요.
# src/instagram/grades.py
def like_label(likes: int) -> str:
# 여기를 채워 보세요
...
# tests/test_grades.py — 직접 채워 보세요
from instagram.grades import like_label
def test_like_label():
assert like_label(1500) == "인기"
# 보통·새 글 경우도 직접 추가해 보세요
- Step 4 처럼 "정상 경우 + 경계 경우" 를 떠올리세요. 1000·100 처럼 딱 걸치는 값도 테스트하면 좋아요.
- A-3 의
if/elif로 등급을 나누면 돼요.
[응용] 캡션 검증과 예외 테스트
캡션을 받아, 비었거나 30자를 넘으면 ValueError 를 던지고, 괜찮으면 앞뒤 공백을 정리해 돌려주는 validate_caption 함수를 만들고 테스트해 보세요.
def validate_caption(text: str) -> str:
# 비었거나 30자 초과면 raise ValueError, 아니면 strip 해서 반환
...
- Step 5 의
with pytest.raises(ValueError):로 "에러가 나야 정상" 인 경우를 검증하세요. - 정상 입력(
" 좋은 하루 "→"좋은 하루")도 테스트로 함께 확인하세요.
[심화] parametrize 로 등급 경계 한 번에
[기초] 의 like_label 을 @pytest.mark.parametrize 로 여러 경계값을 한꺼번에 검사하는 테스트로 바꿔 보세요.
import pytest
from instagram.grades import like_label
@pytest.mark.parametrize("likes, expected", [
(0, "새 글"),
# 99·100·999·1000 경계도 채워 보세요
])
def test_like_label_cases(likes, expected):
assert like_label(likes) == expected
- 경계값(99 와 100, 999 와 1000)을 양쪽 다 넣어, 등급이 바뀌는 지점이 정확한지 확인하세요.
- 새 경우를 추가하는 게 목록에 한 줄 더 적는 일이라는 걸 직접 느껴 보세요.
생각해볼 주제
1. 테스트는 어디까지 써야 할까
테스트가 많을수록 안전하지만, 그만큼 테스트 코드도 관리할 짐이 돼요. 함수 하나에 테스트를 100개 쓰는 게 과연 좋을까요? 반대로 "잘 돌겠지" 하고 하나도 안 쓰면 어떤 위험이 있을까요? "어떤 코드에 테스트를 우선 써야 하는가" 라는 기준으로 생각해 보세요. 자주 바뀌는 코드, 틀리면 큰일 나는 코드를 떠올리면 실마리가 보일 거예요.
2. 테스트를 먼저 쓰면 뭐가 달라질까
오늘은 함수를 먼저 만들고 테스트를 나중에 붙였어요. 그런데 순서를 뒤집어, "이 함수는 이렇게 동작해야 한다" 는 테스트를 먼저 적고 그다음 함수를 만들면 어떨까요? 함수를 만들기도 전에 "무엇을 만들지" 가 또렷해지는 효과가 있어요. 이 순서가 주는 장점과, 익숙해지기 전의 불편함을 함께 상상해 보세요.
3. 통과하는 테스트가 항상 "옳은" 테스트일까
테스트가 초록불이면 마음이 놓이죠. 그런데 만약 테스트의 기대값 자체가 틀렸다면 어떨까요? 잘못된 기대값에 맞춰 함수도 잘못 만들면, 테스트는 통과하지만 결과는 틀려요. "테스트가 통과한다" 와 "코드가 옳다" 는 같은 말일까요? 테스트를 믿되 무엇을 더 챙겨야 하는지 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 먼저 스스로 함수와 테스트를 손으로 짠 뒤에 펼쳐 보세요. 테스트는 "직접 틀려보고 → 메시지를 읽고 → 고치는" 흐름을 한 번 겪으면 훨씬 오래 남아요.
🎯 [과제 1 예시답안] 좋아요 등급 함수와 테스트
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
if/elif 로 1000·100 기준 등급을 나누는가 |
★★★ | |
| 정상 경우(인기·보통·새 글)를 테스트로 검증하는가 | ★★★ | |
| 경계값(1000·100)을 따로 테스트하는가 | ★★☆ |
풀이 예시
# src/instagram/grades.py
def like_label(likes: int) -> str:
"""좋아요 수에 따라 게시물 등급을 글자로 돌려준다."""
if likes >= 1000:
return "인기"
if likes >= 100:
return "보통"
return "새 글"
# tests/test_grades.py
from instagram.grades import like_label
def test_like_label():
assert like_label(1500) == "인기"
assert like_label(300) == "보통"
assert like_label(5) == "새 글"
def test_like_label_boundary():
assert like_label(1000) == "인기"
assert like_label(100) == "보통"
assert like_label(99) == "새 글"
uv run pytest tests/test_grades.py
tests/test_grades.py .. [100%]
============================== 2 passed in 0.00s ===============================
핵심은 두 가지예요. 첫째, 등급은 if likes >= 1000 처럼 큰 값부터 차례로 거르면 깔끔해요(A-3 의 if/elif 감각). 둘째, test_like_label_boundary 처럼 딱 걸치는 값(1000·100·99)을 따로 테스트한 거예요. 등급이 바뀌는 경계가 바로 버그가 숨기 쉬운 곳이거든요.
✅ 경계값(1000·100)을 테스트하면 >= 와 > 를 헷갈린 실수를 바로 잡아낼 수 있어요.
❌ 정상값(1500·300·5)만 테스트하면 — 통과는 하지만, 999 와 1000 사이 같은 경계 실수는 놓쳐요.
💡 튜터의 한마디: "경계값을 테스트하라" 는 말은 시험에 나오는 격언이 아니라, 실제로 버그를 가장 많이 잡아주는 습관이에요. 숫자로 등급·범위를 나누는 함수를 만들면, 항상 "걸치는 값" 을 한 번 더 떠올리세요.
🎯 [과제 2 예시답안] 캡션 검증과 예외 테스트
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
빈 캡션·30자 초과에 raise ValueError 하는가 |
★★★ | |
with pytest.raises(ValueError): 로 예외를 검증하는가 |
★★★ | |
정상 입력의 공백 정리(strip) 결과도 테스트하는가 |
★★☆ |
풀이 예시
# src/instagram/grades.py
def validate_caption(text: str) -> str:
"""캡션을 검증한다. 비었거나 30자를 넘으면 예외를 던진다."""
stripped = text.strip()
if not stripped:
raise ValueError("캡션이 비어 있어요")
if len(stripped) > 30:
raise ValueError("캡션이 너무 길어요 (30자 이하)")
return stripped
# tests/test_grades.py
import pytest
from instagram.grades import validate_caption
def test_validate_caption_ok():
assert validate_caption(" 좋은 하루 ") == "좋은 하루"
def test_validate_caption_empty():
with pytest.raises(ValueError):
validate_caption(" ")
def test_validate_caption_too_long():
with pytest.raises(ValueError):
validate_caption("가" * 31)
with pytest.raises(ValueError): 블록이 핵심이에요. 빈 캡션이나 너무 긴 캡션을 넣었을 때 ValueError 가 나면 통과, 안 나면 실패예요. 정상 입력은 앞뒤 공백이 정리돼 "좋은 하루" 로 나오는지 assert 로 확인했고요. 검증(raise)과 검사(pytest.raises)가 짝을 이루는 모습이에요.
✅ 예외가 나야 하는 경우와 정상 경우를 둘 다 테스트하면, 함수의 양쪽 동작이 모두 지켜져요.
❌ 정상 경우만 테스트하면 — 검증 로직(raise)이 실수로 빠져도 테스트가 통과해 버려요.
💡 튜터의 한마디: E-1 에서 배운 "잘못된 입력은 예외로 막는다" 와, 오늘 배운 "그 예외가 제대로 나는지 테스트한다" 가 만나는 지점이에요. 방어막을 세우는 것만큼, 그 방어막이 살아 있는지 지켜보는 것도 중요해요.
🎯 [과제 3 예시답안] parametrize 로 등급 경계 한 번에
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
@pytest.mark.parametrize 로 입력·기대값 목록을 주는가 |
★★★ | |
| 경계값(99·100, 999·1000)을 양쪽 다 넣었는가 | ★★★ | |
테스트 함수가 (likes, expected) 를 인자로 받는가 |
★★☆ |
풀이 예시
# tests/test_grades.py
import pytest
from instagram.grades import like_label
@pytest.mark.parametrize(
"likes, expected",
[
(0, "새 글"),
(99, "새 글"),
(100, "보통"),
(999, "보통"),
(1000, "인기"),
],
)
def test_like_label_cases(likes, expected):
assert like_label(likes) == expected
uv run pytest tests/test_grades.py -v
test_like_label_cases[0-...] PASSED
test_like_label_cases[99-...] PASSED
test_like_label_cases[100-...] PASSED
test_like_label_cases[999-...] PASSED
test_like_label_cases[1000-...] PASSED
과제 1 의 test_like_label 과 test_like_label_boundary 두 함수를, parametrize 한 줄짜리 목록으로 합쳤어요. 경계가 바뀌는 지점을 양쪽으로(99↔100, 999↔1000) 넣은 게 포인트예요. 99 가 "새 글", 100 이 "보통" 으로 갈리는지 한눈에 검사돼요. 새 경계를 더 보고 싶으면 목록에 한 줄만 추가하면 되죠.
✅ 경계의 양쪽 값을 나란히 넣으면, 등급이 갈리는 지점이 정확한지 또렷하게 검증돼요.
❌ 경계 한쪽만 넣으면 — >= 를 > 로 잘못 적은 실수(딱 1000 일 때 새는 버그)를 놓칠 수 있어요.
💡 튜터의 한마디: parametrize 는 "비슷한 테스트를 복사해 붙이고 싶을 때" 가 신호예요. 그 순간 목록 한 장으로 묶으면, 케이스를 늘리는 일이 훨씬 가벼워지고 어느 입력이 틀렸는지도 콕 집어 보여요.
🤔 [생각해볼 주제 1] 테스트는 어디까지 써야 할까
문제 상황 요약
테스트가 많으면 안전하지만, 테스트 코드 자체도 관리할 짐이 돼요. 함수 하나에 100개를 쓰는 게 좋을까요? 하나도 안 쓰면요? "어디에 테스트를 우선 써야 하는가" 를 가늠해 보는 주제예요.
튜터의 가이드 및 해설
테스트는 "많이 쓰는 것" 이 목표가 아니라 "불안한 곳을 덮는 것" 이 목표예요. 모든 함수에 똑같이 테스트를 도배하면, 코드를 조금 고칠 때마다 테스트도 우르르 고쳐야 해서 오히려 발이 묶여요.
우선순위를 정하는 두 가지 기준이 있어요. 첫째, 자주 바뀌는 코드. 손이 자주 가는 곳일수록 망가질 기회도 많으니, 테스트가 지켜주면 든든해요. 둘째, 틀리면 크게 다치는 코드. 결제 금액 계산, 권한 확인처럼 한 번 틀리면 피해가 큰 곳이죠. 반대로, 거의 안 바뀌고 틀려도 영향이 작은 단순한 코드는 테스트를 가볍게 가도 괜찮아요.
그리고 하나 안심해도 되는 게 있어요. 입문 단계에선 "완벽한 커버리지" 를 목표로 삼을 필요가 전혀 없어요. "이 함수가 어디서 잘못되기 쉽지?" 를 떠올리고 그 한두 개를 적는 것만으로도, 테스트가 하나도 없는 것과는 비교할 수 없이 안전해져요.
💡 핵심을 한마디로
테스트는 양이 아니라 "불안한 곳을 고른다" 가 핵심이에요. 자주 바뀌고, 틀리면 크게 다치는 코드부터 덮으세요.
🤔 [생각해볼 주제 2] 테스트를 먼저 쓰면 뭐가 달라질까
문제 상황 요약
오늘은 함수를 먼저 만들고 테스트를 나중에 붙였어요. 순서를 뒤집어 "이 함수는 이렇게 동작해야 한다" 는 테스트를 먼저 적고 함수를 만들면 어떤 차이가 생길지 상상해 보는 주제예요.
튜터의 가이드 및 해설
테스트를 먼저 적는 순서엔 묘한 효과가 있어요. 함수를 만들기도 전에 "무엇을 만들지" 를 먼저 또렷하게 정하게 되거든요. like_label(1000) 이 "인기" 여야 한다고 테스트로 적는 순간, 등급 기준이 분명해지죠. 즉 테스트가 "이 함수의 사용 설명서" 역할을 먼저 해주는 거예요.
또 하나의 장점은 "딱 필요한 만큼만 만들게 된다" 는 거예요. 테스트를 통과시키는 데 필요한 코드만 짜면 되니까, 쓸데없이 복잡하게 만드는 일이 줄어요. 그리고 만들자마자 곧바로 초록불을 확인하니, "되는지 안 되는지" 모르는 불안한 구간이 거의 없어요.
물론 익숙해지기 전엔 불편해요. 아직 함수 모양도 안 잡혔는데 테스트부터 적으려니 막막하죠. 그래서 처음엔 "아주 간단한 함수 하나" 로 이 순서를 연습해 보는 걸 권해요. 한번 리듬을 타면, "무엇을 만들지 먼저 정하고 시작하는" 감각이 큰 무기가 돼요.
💡 핵심을 한마디로
테스트를 먼저 적으면, 코드를 짜기 전에 "무엇을 만들지" 가 또렷해져요. 테스트가 사용 설명서이자 출발선이 되는 거예요.
🤔 [생각해볼 주제 3] 통과하는 테스트가 항상 "옳은" 테스트일까
문제 상황 요약
테스트가 초록불이면 안심되죠. 그런데 기대값 자체가 틀렸다면요? 잘못된 기대값에 맞춰 함수도 잘못 만들면 테스트는 통과하지만 결과는 틀려요. "통과한다" 와 "옳다" 가 같은 말인지 따져보는 주제예요.
튜터의 가이드 및 해설
아주 중요한 지점이에요. 테스트는 "코드가 내가 적은 기대대로 도는가" 를 확인할 뿐, "그 기대 자체가 옳은가" 는 검사하지 못해요. 만약 like_label(1000) 이 "보통" 이어야 한다고 잘못 적고, 함수도 그렇게 잘못 만들면, 테스트는 멀쩡히 통과해요. 하지만 실제 등급 기준과는 어긋나죠.
그래서 테스트를 믿되, 두 가지를 더 챙겨야 해요. 첫째, 기대값의 근거. 그 값이 어디서 왔는지(요구사항·실제 예시)를 확인하면, 기대값이 틀릴 위험이 줄어요. 둘째, 일부러 틀려보기. 오늘 Step 3 에서 했듯, 함수를 잠깐 망가뜨렸을 때 테스트가 빨갛게 실패하는지 확인하는 거예요. 만약 함수를 망가뜨렸는데도 테스트가 통과한다면, 그 테스트는 사실 아무것도 검사하지 않고 있던 거죠.
정리하면, 초록불은 "내 기대와 코드가 일치한다" 는 신호일 뿐, "내 기대가 옳다" 는 보증은 아니에요. 그래서 테스트는 강력한 안전망이지만, 사람의 판단을 완전히 대신하진 못해요.
💡 핵심을 한마디로
통과하는 테스트는 "코드가 기대와 맞다" 는 뜻이지, "기대가 옳다" 는 뜻은 아니에요. 기대값의 근거를 챙기고, 가끔 일부러 틀려보세요.