컴퓨터공학 300 주제 시리즈의 191번째 글이다. 전체 지도는 여기.

한 줄 요약

단위 테스트는 함수나 클래스 같은 작은 단위를 외부 시스템과 떼어 놓고, 입력에 대해 기대한 결과가 나오는지 자동으로 확인하는 코드다. 좋은 단위 테스트는 빠르고, 서로 독립적이고, 몇 번을 돌려도 같은 결과를 낸다.

왜 필요한가

손으로 확인하는 테스트는 한 번만 한다. 코드를 고칠 때마다 쿠폰 할인, 만료 처리, 음수 금액 처리를 다시 손으로 확인하는 사람은 없다. 그래서 고친 곳 옆의 무언가가 조용히 깨진다.

단위 테스트는 “이 코드는 이렇게 동작해야 한다” 는 약속을 실행 가능한 형태로 남긴다. 약속이 수백 개 쌓이면 코드를 고칠 때 몇 초 만에 “아무것도 깨지지 않았다” 를 확인할 수 있다. 앞 글에서 본 리팩터링이 가능한 것도 이 안전망 덕분이다.

테스트는 문서 역할도 한다. test_expired_coupon_is_ignored 라는 이름의 테스트는 “만료된 쿠폰은 무시한다” 는 규칙을 어떤 위키 문서보다 정확하게 기록한다. 코드와 어긋나면 실패하기 때문이다.

핵심 개념

단위란 무엇인가

“단위” 의 크기에 대해서는 두 학파가 있다.

  • 고립(solitary) 단위 테스트: 테스트 대상 클래스 하나만 진짜고, 협력 객체는 모두 테스트 더블로 바꾼다.
  • 사교적(sociable) 단위 테스트: 협력 객체 중 빠르고 결정적인 것(값 객체, 순수 함수)은 진짜를 쓴다. 느리거나 외부에 닿는 것(DB, 네트워크, 시계)만 바꾼다.

어느 쪽이든 공통 조건은 프로세스 밖으로 나가지 않는다는 것이다. 네트워크, 실제 DB, 파일 시스템, 현재 시각에 의존하면 느려지고 결과가 흔들린다.

좋은 단위 테스트의 성질: FIRST

성질 뜻
Fast 빠르다. 수천 개를 몇 초 안에 돌린다
Independent 다른 테스트의 결과나 실행 순서에 의존하지 않는다
Repeatable 어떤 환경에서 몇 번을 돌려도 같은 결과다
Self-validating 사람이 로그를 읽지 않아도 통과·실패가 판정된다
Timely 제때(대상 코드와 함께, 또는 먼저) 작성한다

Robert C. Martin 의 Clean Code 가 정리한 머리글자다.

테스트 구조: Arrange–Act–Assert

# Arrange: 준비 — 대상 객체와 입력, 테스트 더블을 만든다
# Act:     실행 — 검증할 동작을 딱 한 번 호출한다
# Assert:  검증 — 결과와 부수 효과를 확인한다

BDD 의 Given–When–Then 과 같은 구조다. 한 테스트에 Act 가 여러 번 나오면 테스트 하나가 여러 동작을 검증하고 있다는 신호다. 실패했을 때 무엇이 깨졌는지 이름만 보고 알 수 있도록 쪼갠다.

테스트 더블

영화의 대역 배우(stunt double)처럼, 진짜 협력 객체 대신 쓰는 객체를 테스트 더블이라고 부른다. Gerard Meszaros 가 xUnit Test Patterns 에서 정리한 분류를 Martin Fowler 가 “Mocks Aren’t Stubs”에서 소개했다.

종류 하는 일
더미(dummy) 자리만 채운다. 실제로 쓰이지 않는다
페이크(fake) 진짜처럼 동작하는 간단한 구현(메모리 DB 등)
스텁(stub) 미리 정한 답을 돌려준다
스파이(spy) 스텁 + 어떻게 호출됐는지 기록한다
목(mock) 기대하는 호출을 미리 정하고, 그대로 호출됐는지 검증한다

같은 글은 상태 검증(결과값을 확인)과 행위 검증(어떤 메서드가 어떻게 호출됐는지 확인)을 구분한다. 행위 검증을 남발하면 구현 세부에 테스트가 묶여, 동작은 그대로인데 내부 호출 순서만 바꿔도 테스트가 깨진다. 결과로 확인할 수 있으면 결과로 확인하는 편이 낫다.

파이썬 표준 라이브러리의 unittest.mock은 Mock 객체 하나로 스텁(return_value)과 스파이·목(assert_called_once_with) 역할을 모두 한다.

테스트하기 어려운 코드

테스트를 쓰기 어렵다면 대개 설계 문제다.

  • 함수 안에서 datetime.now() 를 직접 부른다 → 시간을 인자로 받거나 주입한다.
  • 함수 안에서 DB 연결을 직접 만든다 → 저장소 객체를 주입한다(SOLID 의 DIP).
  • 전역 상태를 읽고 쓴다 → 상태를 객체로 감싸고 인스턴스를 넘긴다.

직접 해 보기

표준 라이브러리 unittest로 쿠폰 서비스를 테스트한다. 저장소는 Mock 으로, “오늘 날짜” 는 고정된 함수로 바꿔 넣는다.

import sys, unittest
from datetime import date
from unittest.mock import Mock

# ----- 테스트 대상 -----
class CouponService:
    def __init__(self, repo, today=date.today):
        self.repo, self.today = repo, today      # 의존성을 주입받는다

    def apply(self, code, amount):
        if amount <= 0:
            raise ValueError("금액은 양수여야 한다")
        coupon = self.repo.find(code)
        if coupon is None:
            return amount
        if coupon["expires"] < self.today():
            return amount
        self.repo.mark_used(code)
        return max(amount - coupon["discount"], 0)

# ----- 테스트 -----
class CouponServiceTest(unittest.TestCase):
    def setUp(self):
        self.repo = Mock()                                  # 테스트 더블
        self.fixed_today = lambda: date(2026, 10, 10)       # 시간을 고정
        self.svc = CouponService(self.repo, today=self.fixed_today)

    def test_valid_coupon_is_discounted_and_marked_used(self):
        # Arrange
        self.repo.find.return_value = {"discount": 3000, "expires": date(2026, 12, 31)}
        # Act
        result = self.svc.apply("WELCOME", 10000)
        # Assert
        self.assertEqual(result, 7000)
        self.repo.mark_used.assert_called_once_with("WELCOME")

    def test_expired_coupon_is_ignored(self):
        self.repo.find.return_value = {"discount": 3000, "expires": date(2026, 10, 9)}
        self.assertEqual(self.svc.apply("OLD", 10000), 10000)
        self.repo.mark_used.assert_not_called()

    def test_discount_never_goes_below_zero(self):
        self.repo.find.return_value = {"discount": 5000, "expires": date(2026, 12, 31)}
        self.assertEqual(self.svc.apply("BIG", 3000), 0)

    def test_unknown_coupon_returns_original_amount(self):
        self.repo.find.return_value = None
        self.assertEqual(self.svc.apply("NOPE", 10000), 10000)

    def test_non_positive_amount_raises(self):
        with self.assertRaises(ValueError):
            self.svc.apply("WELCOME", 0)

if __name__ == "__main__":
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(CouponServiceTest)
    unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)

실행 결과(Python 3.12, 시간 값은 환경마다 다르다):

test_discount_never_goes_below_zero (__main__.CouponServiceTest.test_discount_never_goes_below_zero) ... ok
test_expired_coupon_is_ignored (__main__.CouponServiceTest.test_expired_coupon_is_ignored) ... ok
test_non_positive_amount_raises (__main__.CouponServiceTest.test_non_positive_amount_raises) ... ok
test_unknown_coupon_returns_original_amount (__main__.CouponServiceTest.test_unknown_coupon_returns_original_amount) ... ok
test_valid_coupon_is_discounted_and_marked_used (__main__.CouponServiceTest.test_valid_coupon_is_discounted_and_marked_used) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.002s

OK

다섯 개가 모두 통과했다고 안심하기는 이르다. 만료일 당일(expires == today)에 쿠폰이 유효한지 확인하는 테스트가 없다. 누군가 < 를 <= 로 바꿔도 다섯 테스트는 그대로 통과한다. 경계값은 버그가 가장 많이 사는 곳이다. 테스트 하나를 직접 추가해 보자. 또 하나 눈여겨볼 점은 today 를 주입받게 만든 설계다. date.today() 를 함수 안에서 직접 불렀다면 만료 테스트는 날짜가 바뀔 때마다 결과가 달라졌을 것이다.

같은 테스트를 pytest로 쓰면 클래스 없이 함수와 평범한 assert 문만으로 작성할 수 있고, 반복되는 준비 코드는 픽스처로 뺀다. 원리는 같다.

현업에서는

  • “가끔 실패하는 테스트” 는 즉시 고친다. 현재 시각, 무작위 값, 실행 순서, 공유 전역 상태에 의존하는 테스트는 가끔 실패한다. 이런 테스트가 쌓이면 팀은 빨간불을 무시하기 시작하고, 그 순간 테스트 전체가 신뢰를 잃는다.
  • 커버리지는 바닥이지 목표가 아니다. 커버리지 100% 여도 위 예제처럼 경계값이 비어 있을 수 있다. 커버리지는 “테스트가 한 번도 실행하지 않은 코드” 를 찾는 도구로 쓰는 것이 맞다.
  • CI 에서 모든 PR 마다 돈다. 단위 테스트는 빠르기 때문에 모든 커밋에서 돌릴 수 있다. 홈랩 클러스터에 올라가는 사이드 프로젝트도 이미지 빌드 전에 단위 테스트를 통과해야 다음 단계로 가게 해 두면, 깨진 코드가 클러스터까지 가는 일이 없다.
  • 테스트 코드도 코드다. 중복이 많고 이름이 모호한 테스트는 유지보수 부담이 된다. 테스트 데이터 빌더, 픽스처, 명확한 테스트 이름으로 관리한다.

확인 문제

  1. FIRST 의 다섯 성질을 쓰라.
  2. 스텁과 목의 차이를 “상태 검증” 과 “행위 검증” 이라는 말로 설명하라.
  3. 위 예제에서 today 를 생성자 인자로 받게 만든 이유는?
  4. 위 예제의 테스트 다섯 개로는 잡을 수 없는 변경 하나를 들고, 그것을 잡을 테스트를 설계하라.
  5. 커버리지 100% 가 버그 없음을 보장하지 못하는 이유는?

풀이

  1. Fast, Independent, Repeatable, Self-validating, Timely.
  2. 스텁은 정해진 답을 돌려줘 대상의 결과(상태)를 검증하는 데 쓰이고, 목은 기대한 호출이 실제로 일어났는지(행위)를 검증한다.
  3. 현재 날짜에 의존하면 테스트 결과가 실행 날짜에 따라 달라진다. 날짜를 주입하면 테스트에서 고정할 수 있어 반복 가능해진다.
  4. 만료 비교를 < 에서 <= 로 바꾸는 변경. expires 를 고정된 오늘(2026-10-10)과 같게 두고 할인이 적용되는지(7000 반환, mark_used 호출) 확인하는 테스트를 추가한다.
  5. 커버리지는 코드가 실행됐는지만 측정할 뿐, 의미 있는 입력(경계값 등)으로 결과를 올바르게 검증했는지는 측정하지 않는다.

더 읽을거리 (References)