단위 테스트 입문: 첫 테스트 작성과 실패 diff 읽는 법

Python unittest로 첫 테스트를 쓰고, 경계값으로 버그를 찾고, 실패 diff를 정확히 읽는 법. doctest, 오프라인 Dart test, 테스트 1만 개 실행 시간까지 실제 출력과 실측으로 정리.

시리즈: 테스트·디버깅 강좌 3/6 (중급). 스택 트레이스를 읽고 디버거도 쓸 줄 알지만(1·2강), 테스트 파일을 직접 써 본 적은 없는 분을 위한 글입니다. 다 읽고 나면 unittest 테스트 파일을 처음부터 쓰고, 경계값을 보고 테스트 케이스를 고르고, 실패 diff를 추측 없이 읽고, 무엇을 테스트하지 않아도 되는지 판단할 수 있습니다.

신입 온보딩이 끝나고 처음 올린 PR에 코드 리뷰 코멘트가 딱 하나 달렸습니다. "테스트는요?" 이 글은 그 상황에 있는 분을 위한 것입니다. 그리고 장애 대응 회고에서 "세 줄짜리 테스트가 있었으면 막을 수 있었다"는 결론을 들어 본 분을 위한 글이기도 합니다. 실행 환경은 Python 3.9.6(일부는 3.12.14로도 확인)과 Dart 3.11, Apple Silicon Mac(M1 Pro)이며, 출력의 경로는 ~/demo/로 줄였습니다.

결론부터 세 가지. 파일 이름을 잘못 지은 테스트는 0개를 실행하고도 OK 를 출력했습니다(Python 3.9). 경계값을 생각해 보는 것만으로 정상 경로 테스트가 한 번도 건드리지 않은 입력을 4개 찾았습니다. 그리고 작은 테스트 10,000개는 286ms 만에 끝납니다. "테스트는 느려서"는 작은 테스트를 안 쓸 이유가 되지 못합니다.

5분 만에 첫 테스트 파일

용어 세 개만 먼저 짚겠습니다. 단위 테스트(unit test)는 작은 코드 조각(보통 함수 하나)에 정해진 입력을 넣고 결과를 확인하는 코드입니다. 단언(assertion)은 그 확인 한 줄, "이 값은 저 값과 같아야 한다"입니다. 테스트 러너는 테스트를 찾아서 실행하고 결과를 보고하는 프로그램인데, Python에는 표준 라이브러리 unittest가 있으니 설치할 것이 없습니다.

예제는 회식비 더치페이입니다. 금액은 원 단위 정수로 다룹니다.

# bill.py
"""회식비 더치페이. 금액은 원 단위 정수."""

FREE_SHIPPING_FROM = 50000   # 50,000원
SHIPPING_FEE = 3000   # 3,000원


def split_bill(total, people):
    """합계를 인원수로 나눠, 더하면 합계가 되는 정수 리스트를 돌려준다.

    >>> split_bill(1000, 3)
    [334, 333, 333]
    >>> split_bill(900, 3)
    [300, 300, 300]
    >>> split_bill(10.0, 3)
    Traceback (most recent call last):
      ...
    TypeError: total must be an int, got float
    """
    if not isinstance(total, int):
        raise TypeError(f"total must be an int, got {type(total).__name__}")
    if people < 1:
        raise ValueError(f"people must be >= 1, got {people}")
    base, extra = divmod(total, people)
    return [base + 1] * extra + [base] * (people - extra)


def shipping_fee(subtotal):
    return 0 if subtotal >= FREE_SHIPPING_FROM else SHIPPING_FEE


def format_share(name, amount):
    return f"{name} 님: {amount:,}원"

같은 폴더에 테스트 파일을 둡니다.

# test_bill.py
import doctest
import unittest

import bill
from bill import format_share, shipping_fee, split_bill


class SplitBillTest(unittest.TestCase):
    def test_even_total_gives_equal_shares(self):
        # Arrange (준비)
        total, people = 90000, 3
        # Act (실행)
        shares = split_bill(total, people)
        # Assert (검증)
        self.assertEqual(shares, [30000, 30000, 30000])

    def test_remainder_goes_to_first_people(self):
        self.assertEqual(split_bill(100000, 3), [33334, 33333, 33333])

    def test_shares_always_add_up_to_total(self):
        for total in (0, 1, 2, 99, 100000, 100001):
            with self.subTest(total=total):
                self.assertEqual(sum(split_bill(total, 3)), total)

    def test_zero_people_is_rejected(self):
        with self.assertRaises(ValueError):
            split_bill(1000, 0)

    def test_float_total_is_rejected_clearly(self):
        with self.assertRaisesRegex(TypeError, "total must be an int"):
            split_bill(10.0, 3)

    def test_negative_total_still_adds_up(self):
        self.assertEqual(split_bill(-100, 3), [-33, -33, -34])


class ShippingFeeTest(unittest.TestCase):
    def test_one_won_below_threshold_pays_fee(self):
        self.assertEqual(shipping_fee(49999), 3000)

    def test_exactly_threshold_is_free(self):
        self.assertEqual(shipping_fee(50000), 0)


class FormatShareTest(unittest.TestCase):
    def test_uses_thousands_separator(self):
        self.assertEqual(format_share("김민준", 33334), "김민준 님: 33,334원")


def load_tests(loader, tests, ignore):
    tests.addTests(doctest.DocTestSuite(bill))
    return tests


if __name__ == "__main__":
    unittest.main()

두 파일이 있는 폴더에서 실행합니다.

$ python3 -m unittest -v
test_uses_thousands_separator (test_bill.FormatShareTest) ... ok
test_exactly_threshold_is_free (test_bill.ShippingFeeTest) ... ok
test_one_won_below_threshold_pays_fee (test_bill.ShippingFeeTest) ... ok
test_even_total_gives_equal_shares (test_bill.SplitBillTest) ... ok
test_float_total_is_rejected_clearly (test_bill.SplitBillTest) ... ok
test_negative_total_still_adds_up (test_bill.SplitBillTest) ... ok
test_remainder_goes_to_first_people (test_bill.SplitBillTest) ... ok
test_shares_always_add_up_to_total (test_bill.SplitBillTest) ... ok
test_zero_people_is_rejected (test_bill.SplitBillTest) ... ok
split_bill (bill)
Doctest: bill.split_bill ... ok

----------------------------------------------------------------------
Ran 10 tests in 0.002s

OK

10개는 메서드 9개에 load_tests가 추가한 doctest 1개입니다. 실행 순서가 파일에 쓴 순서가 아니라 클래스·메서드 이름의 알파벳순이라는 점에 주의하세요. 다른 테스트가 먼저 돌았다는 전제에 기대는 테스트는 쓰면 안 됩니다.

흐름도. python3 -m unittest 는 test*.py 이름의 파일과 test 로 시작하는 메서드를 찾는다. 각 테스트는 준비·실행·검증을 거친다. 단언이 맞으면 성공이고 점으로 표시된다. 단언이 거짓이면 FAIL 이고 F 로 표시된다. 판정 전에 예상하지 못한 예외가 빠져나오면 ERROR 이고 E 로 표시된다. 이름을 잘못 지은 파일이나 메서드는 발견되지 않아 0개가 실행된다.
unittest 한 번 실행할 때 일어나는 일. 자동 탐색은 test*.py 파일과 test_* 메서드만 찾는다. 각 테스트는 준비·실행·검증을 거쳐 성공(.), 실패(F), 에러(E) 중 하나로 끝난다.

이름 함정: "Ran 0 tests"인데 OK

인자 없이 실행한 python3 -m unittest는 자동 탐색(discovery)으로 동작합니다. test*.py에 맞는 파일을 가져와서 test로 시작하는 메서드를 실행합니다. 파일 이름을 bill_tests.py, 메서드 이름을 check_remainder로 지으면, 안의 단언을 일부러 틀리게 써 놔도 이렇게 됩니다.

$ python3 -m unittest          # Python 3.9.6
----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
$ echo $?
0
$ python3.12 -m unittest       # Python 3.12.14, 같은 파일
----------------------------------------------------------------------
Ran 0 tests in 0.000s

NO TESTS RAN
$ echo $?
5

파일 이름만 test_bill.py로 고쳐도 메서드 이름이 틀렸으니 여전히 0개였습니다. 3.9 CI라면 영원히 초록불입니다. 테스트를 추가했으면 한 번은 일부러 실패시켜서 정말 빨간불이 들어오는지 확인하세요.

Arrange·Act·Assert, 그리고 문장처럼 읽히는 이름

test_even_total_gives_equal_shares는 좋은 테스트 대부분이 가진 3단 구조입니다.

  • Arrange(준비): 입력을 만든다(다음 강의에서는 가짜 협력 객체도 여기서 만든다).
  • Act(실행): 테스트 대상을 딱 한 번 호출한다.
  • Assert(검증): 결과를 확인한다.

주석을 매번 달 필요는 없지만, Act가 두 번 있는 테스트는 대개 테스트 두 개로 나눠야 합니다. 이름에는 "무엇을", "어떤 조건에서", "어떻게 돼야 하는지"를 담습니다. test_one_won_below_threshold_pays_fee라면 파일을 열기 전에 버그를 짐작할 수 있습니다. test_shipping_2는 새벽 장애 대응 때 아무것도 알려 주지 않습니다.

subTest는 "같은 검증을 여러 입력으로" 반복할 때 쓰는 도구입니다. 그냥 루프만 쓰면 처음 실패한 값에서 멈추지만, subTest를 쓰면 값마다 따로 보고됩니다(아래 실패 출력에서 확인합니다).

경계값을 생각하면 엣지 케이스가 보인다

처음 쓰는 테스트는 대부분 정상 경로입니다(9만 원을 3명이). 하지만 버그는 가장자리에 삽니다. 테스트를 더 쓰기 전에, split_bill의 첫 버전(isinstance 검사 없음)에 경계에서 고른 입력을 넣어 봤습니다. 1명, 인원보다 작은 금액, 0, 음수, 잘못된 타입입니다.

split_bill(100000, 1)      -> [100000]
split_bill(2, 3)           -> [1, 1, 0]
split_bill(0, 3)           -> [0, 0, 0]
split_bill(-100, 3)        -> [-33, -33, -34]
split_bill(100000, True)   -> [100000]
split_bill(100000, 2.5)    -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(100000.0, 3)    -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(100000, '3')    -> TypeError: '<' not supported between instances of 'str' and 'int'
split_bill(100000, 0)      -> ValueError: people must be >= 1, got 0

정상 경로 테스트로는 절대 안 보였을 발견이 네 가지입니다.

입력 일어난 일 판단
(2, 3) [1, 1, 0]: 한 명은 0원 맞다. 합계가 맞는다
(-100, 3) 환불. 합계는 정확히 -100 유지하고 테스트로 고정
(100000.0, 3) "sequence를 곱할 수 없다"는 엉뚱한 TypeError float는 명확한 메시지로 거부
(100000, True) bool이 int의 하위 클래스라 True가 1명으로 처리됨 허용. 문서에만 적는다

현실적으로 가장 흔한 건 float 경우입니다. 폼에서 받은 값을 float()로 바꾼 채 넘기는 일은 자주 있고, 그때 에러 메시지가 sequence 얘기를 하면 디버깅하는 사람은 엉뚱한 곳부터 뒤집니다. 그래서 split_bill이 타입을 확인하고, test_float_total_is_rejected_clearly는 예외 종류뿐 아니라 메시지까지 확인합니다.

경계값 체크리스트: 0, 1, 많이. 기준값 바로 아래, 정확히 기준값, 바로 위(49999와 50000). 빈 입력, 음수, 다른 타입, 아주 큰 값. 배송비 테스트가 딱 49999/50000 짝인 이유는, 요금 계산 코드에서 가장 흔한 한 글자 버그가 >=와 >를 헷갈리는 것이기 때문입니다.

실패한 테스트 읽는 법

이제 코드를 세 군데, 피곤한 리뷰어가 놓칠 만한 정도로 망가뜨립니다. divmod 로직을 [total // people] * people로, shipping_fee의 >=를 >로 바꾸고, 천 단위 구분 :,를 지웠습니다. 실제 출력(일부 생략)입니다.

$ python3 -m unittest
FF...FF.F
======================================================================
FAIL: test_uses_thousands_separator (test_bill.FormatShareTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "~/demo/bill/test_bill.py", line 47, in test_uses_thousands_separator
    self.assertEqual(format_share("김민준", 33334), "김민준 님: 33,334원")
AssertionError: '김민준 님: 33334원' != '김민준 님: 33,334원'
- 김민준 님: 33334원
+ 김민준 님: 33,334원
?          +

======================================================================
FAIL: test_exactly_threshold_is_free (test_bill.ShippingFeeTest)
...
AssertionError: 3000 != 0
======================================================================
FAIL: test_negative_total_still_adds_up (test_bill.SplitBillTest)
...
AssertionError: Lists differ: [-34, -34, -34] != [-33, -33, -34]

First differing element 0:
-34
-33

- [-34, -34, -34]
?    ^    ^

+ [-33, -33, -34]
?    ^    ^

======================================================================
FAIL: test_shares_always_add_up_to_total (test_bill.SplitBillTest) (total=1)
...
AssertionError: 0 != 1
...
----------------------------------------------------------------------
Ran 10 tests in 0.002s

FAILED (failures=9)

위에서부터 읽어 봅시다.

  1. 진행 줄 FF...FF.F는 테스트 하나에 한 글자입니다. . 성공, F 실패, E 에러. 테스트 10개에 9글자인데 실패는 9개입니다. subTest 실패는 값마다 하나씩(total=1, 2, 100000, 100001) 세지만 글자는 하나로 합쳐지기 때문입니다.
  2. 트레이스백은 unittest 내부가 아니라 내 테스트 줄에서 끝납니다. 볼 곳은 test_bill.py 47번째 줄입니다.
  3. first != second: unittest는 두 인자를 "실제값", "기대값"이 아니라 그냥 first, second로 부릅니다. 우리는 항상 assertEqual(실제값, 기대값) 순서로 쓰므로 - 줄이 코드가 돌려준 값, + 줄이 원했던 값입니다. 팀 전체가 이 순서를 지키지 않으면 diff가 거짓말을 합니다.
  4. ? 줄은 다른 글자의 위치를 가리킵니다. 그런데 위치를 "글자 수"로 세기 때문에, 한글처럼 화면에서 두 칸을 차지하는 글자가 섞이면 어긋납니다. 위 예의 +는 앞에서 12번째 글자(쉼표 자리)를 가리키지만, 터미널에서는 : 아래쯤에 보입니다. 한글 문자열을 비교할 때는 ? 줄을 그대로 믿지 말고 - 줄과 + 줄을 직접 비교하세요.
  5. 리스트는 diff 앞에 "First differing element 0"이 붙습니다. 여기서 음수 케이스가 다른 버그를 드러냈습니다. 내림 나눗셈 때문에 100원 환불이 102원이 됐습니다.
  6. 테스트 이름 뒤의 (total=1) 이 subTest 라벨입니다. subTest가 없으면 처음 실패한 값만 보입니다.

종료 코드는 1. CI가 보는 것은 이 값입니다.

FAIL과 ERROR의 차이

이번에는 people < 1 검사와 타입 검사를 뺐습니다. 다른 글자가 나옵니다.

....F...EF
======================================================================
ERROR: test_zero_people_is_rejected (test_bill.SplitBillTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "~/demo/bill/test_bill.py", line 27, in test_zero_people_is_rejected
    split_bill(1000, 0)
  File "~/demo/bill/bill.py", line 19, in split_bill
    base, extra = divmod(total, people)
ZeroDivisionError: integer division or modulo by zero

======================================================================
FAIL: test_float_total_is_rejected_clearly (test_bill.SplitBillTest)
----------------------------------------------------------------------
TypeError: can't multiply sequence by non-int of type 'float'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "~/demo/bill/test_bill.py", line 31, in test_float_total_is_rejected_clearly
    split_bill(10.0, 3)
AssertionError: "total must be an int" does not match "can't multiply sequence by non-int of type 'float'"

FAIL은 단언이 평가됐고 거짓이었다는 뜻입니다. ERROR는 판정하기도 전에 테스트가 죽었다는 뜻입니다. assertRaises(ValueError)는 ValueError만 잡으므로 ZeroDivisionError는 그대로 빠져나갔습니다. 두 번째 블록은 예외 연쇄(1강)로, 원래의 TypeError가 먼저, 그 메시지를 거부한 단언이 나중에 나옵니다.

doctest: 낡지 않는 사용 예

split_bill docstring의 >>> 줄이 doctest입니다. Python이 그 줄을 실행해서 출력 결과를 다음 줄과 비교합니다. 따로 돌리려면 python3 -m doctest bill.py(성공하면 아무것도 출력하지 않고 종료 코드 0), 자세히 보려면 -v를 붙입니다. 테스트 파일의 load_tests가 이것을 일반 unittest 실행에 끼워 넣습니다.

docstring의 기대값을 [333, 333, 334]로 바꾸면 이렇게 됩니다.

**********************************************************************
File "~/demo/bill/bill.py", line 10, in bill.split_bill
Failed example:
    split_bill(1000, 3)
Expected:
    [333, 333, 334]
Got:
    [334, 333, 333]
**********************************************************************
1 items had failures:
   1 of   3 in bill.split_bill
***Test Failed*** 1 failures.

doctest는 문서를 겸하는 짧은 예에 어울립니다. 출력이 안정적이지 않은 것, 예를 들어 외부 데이터에서 온 dict 순서, 자릿수가 긴 float, <Foo at 0x10...> 같은 주소는 unittest로 보내세요.

이 글을 준비하다 밟은 함정도 하나 있습니다. docstring을 1초 안에 두 번 고쳤고, 두 번째 수정 후 파일 크기가 그대로였습니다. 그러자 Python이 캐시된 바이트코드(.pyc)를 그대로 썼습니다. 기록된 수정 시각과 크기가 같았기 때문입니다. 고친 doctest가 옛 내용으로 계속 실패했고, 이 환경의 Python은 .pyc를 별도 캐시 디렉터리에 두기 때문에 __pycache__를 지워도 소용이 없었습니다. 1초 뒤에 파일을 touch하자 해결됐습니다. 손으로 편집할 때는 거의 안 생기지만, 소스를 고쳐 쓰는 스크립트에서는 생길 수 있습니다.

같은 테스트를 Dart로 (오프라인)

네트워크 없이 Dart의 package:test가 돌아갈까요? 조건은 dart pub get --offline이 로컬 패키지 캐시만으로 해결되는 것입니다. 결과는 1.0초 만에 해결됐고 test 1.30.0이 쓰였습니다.

// lib/pricing.dart
int shippingFee(int subtotal) {
  if (subtotal < 0) throw ArgumentError.value(subtotal, 'subtotal');
  return subtotal >= 5000 ? 0 : 500;
}

// test/pricing_test.dart
import 'package:pricing/pricing.dart';
import 'package:test/test.dart';

void main() {
  group('shippingFee', () {
    test('charges 500 just below the threshold', () {
      expect(shippingFee(4999), 500);
    });
    test('is free exactly at the threshold', () {
      expect(shippingFee(5000), 0);
    });
    test('rejects a negative subtotal', () {
      expect(() => shippingFee(-1), throwsArgumentError);
    });
  });
}

>=를 >로 바꾸자 dart test는 이렇게 보고했습니다(종료 코드 1).

00:00 +1 -1: test/pricing_test.dart: shippingFee is free exactly at the threshold [E]
  Expected: <0>
    Actual: <500>

  package:matcher              expect
  test/pricing_test.dart 10:7  main.<fn>.<fn>

00:00 +2 -1: Some tests failed.

Dart의 matcher는 Expected와 Actual을 명시하므로 인자 순서 문제가 unittest만큼 심각하지 않습니다.

package:test를 못 쓰면 대안은 평범한 assert인데, 실행 환경이 assert를 어떻게 다루는지 먼저 확인하세요. 이번에 아래 세 경우는 모두 에러 없이 마지막 줄까지 실행됐습니다.

실행 환경 평범한 assert가 무시되는 조건 이번 결과
Dart dart run file.dart (--enable-asserts를 주지 않으면 꺼짐) 조용히 건너뜀
Python python3 -O file.py (assert 문이 제거됨) 조용히 건너뜀
PHP 8.5 php -d zend.assertions=-1 (운영 서버에 흔한 설정) 조용히 건너뜀

참고로 dart test 안에서는 assert가 켜져 있어서 AssertionError를 기대하는 테스트가 통과했습니다. 이 Mac에는 PHPUnit이 설치돼 있지 않아서, PHP는 zend.assertions=1에서 assert만 돌려 봤습니다.

테스트가 늘면 실행 시간은?

"테스트는 느리다"는 핑계를 자주 듣습니다. 작은 테스트(split_bill을 한 번 호출)를 N개 넣은 파일을 생성하고 python3 -m unittest 프로세스 전체 시간을 쟀습니다. 각각 6회 실행해 첫 회(콜드)를 버리고 나머지 5회의 중앙값입니다. Python 3.9.6, Apple Silicon Mac(M1 Pro).

테스트 수 N 프로세스 전체 "Ran N tests in" 값
1 48.4 ms 0.0 ms
10 46.1 ms 0.0 ms
100 52.7 ms 1.0 ms
1,000 69.8 ms 19.0 ms
10,000 285.9 ms 192.0 ms
막대그래프. 1개 48.4ms, 10개 46.1ms, 100개 52.7ms, 1,000개 69.8ms, 10,000개 285.9ms.
작은 테스트 N개에 대한 python3 -m unittest 프로세스 전체 시간. 콜드 1회를 뺀 5회의 중앙값, Python 3.9.6, Apple Silicon Mac(M1 Pro). 100개까지는 대부분 시작 시간이고, 그 뒤로는 테스트당 약 20마이크로초.

100개까지는 사실상 Python 시작 시간(약 45ms)을 재고 있는 셈입니다. 그 뒤로는 테스트 하나당 약 19~24마이크로초입니다. -v를 붙이면 10,000개가 432ms(안 붙이면 272ms, 별도 측정)가 됐습니다. 한 줄씩 출력하는 비용이 테스트를 돌리는 비용보다 큽니다.

dart test로도 같은 실험을 했습니다(워밍업 후 각 3회). 1개 0.91~0.95초, 100개 1.07~1.24초, 1,000개 1.84~1.94초, 10,000개 6.3~6.4초. Dart는 컴파일과 isolate 시작 때문에 고정 비용이 크고, 리포터가 테스트마다 하는 일도 더 많습니다.

결론: 작은 단위 테스트는 쌉니다. 스위트를 느리게 만드는 건 sleep 하거나 네트워크, 진짜 데이터베이스를 건드리는 몇 개의 테스트입니다. 그것들을 바꿔 끼우는 방법이 4강의 주제입니다.

테스트하지 않아도 되는 것

테스트도 유지보수해야 하는 코드입니다. 다음은 대개 얻는 것보다 비용이 큽니다.

  • 언어와 표준 라이브러리 자체. divmod(7, 2) == (3, 1)을 확인하면 Python을 테스트하는 셈입니다.
  • private 헬퍼를 직접. 공개 함수를 통해 테스트하면 헬퍼를 고쳐도 테스트를 다시 쓸 필요가 없습니다.
  • 내가 소유하지 않은 문구. Python의 "can't multiply sequence…" 메시지에 맞추면 Python 버전이 바뀔 때 깨집니다. 우리 테스트는 우리가 쓴 메시지에 맞춥니다.
  • 로직이 없는 단순 코드. 필드를 돌려주기만 하는 프로퍼티 같은 것.
  • 결과 대신 구현. "split_bill이 divmod를 한 번 부른다"는 무해한 리팩터링에도 깨집니다. "나눈 금액의 합이 원래 금액과 같다"는 리팩터링을 견딥니다.
  • 난수나 시각에 의존하는 출력을 그대로. 시드나 시계를 먼저 고정하세요(5강).

반대로 반드시 쓸 것: 고친 버그 전부(실패하는 테스트를 먼저 쓰고 고친다), 숫자가 들어간 규칙의 경계, 호출하는 쪽이 의지하는 에러 처리.

솔직한 한계와 버전 메모

  • 주 실행 환경은 Python 3.9.6이고, Python 3.12.14는 메시지 비교에만 썼습니다. "테스트 0개면 종료 코드 5"와 --durations 옵션은 둘 다 3.12부터이며, 3.9에서 --durations를 주면 unrecognized arguments가 났습니다. 3.12에서 처음 --durations로 돌렸을 때는 doctest가 0.335초(콜드 스타트)로 나왔고, 이후에는 0.001~0.002초였습니다.
  • assertRaisesRegex는 Python 3.2, subTest는 3.4부터 있습니다.
  • Dart 3.11.5, test 1.30.0은 로컬 pub 캐시에서 가져왔고 아무것도 내려받지 않았습니다. 환경에 따라 다른 버전으로 해결될 수 있습니다.
  • 시간은 Apple Silicon Mac(M1 Pro) 한 대에서 잰 값입니다. 절대값은 환경마다 다르지만 "고정된 시작 비용 + 테스트당 수 마이크로초"라는 모양은 같을 것입니다.
  • 서드파티 pytest는 diff가 더 친절하지만, 이 환경에는 설치돼 있지 않아 돌려 보지 않았습니다.

연습 문제

  1. split_bill(5, 5)가 1원씩 5명을 돌려주는 테스트와, split_bill(4, 5)가 [1, 1, 1, 1, 0]을 돌려주는 테스트를 추가하세요. 실행하기 전에 통과할지 예상해 보세요.
  2. 누군가 shipping_fee의 >=를 >로 바꾸면 실패하는 테스트를 하나 쓰고, shipping_fee(49999)만으로는 그 버그를 잡을 수 없는 이유를 설명하세요.

정답

  1. 둘 다 통과합니다. divmod(5, 5)는 (1, 0)이라 나머지가 없고, divmod(4, 5)는 (0, 4)라서 앞의 4명이 0 + 1, 마지막 1명이 0입니다. 예상이 틀렸다면 이유를 적어 두세요. 그게 아직 생각하지 못했던 경계입니다.
  2. self.assertEqual(shipping_fee(50000), 0). 49999에서는 >=든 >든 답이 같아서(3000) 둘을 구별하지 못합니다. 두 연산자를 가를 수 있는 건 기준값 정확히 그 값뿐입니다.

다음 글

자주 묻는 질문

unittest가 'Ran 0 tests'인데 OK라고 나오는 이유는?

자동 탐색(discovery)은 test*.py 이름의 파일과 test로 시작하는 메서드만 찾습니다. bill_tests.py 안의 check_remainder 메서드는 0개 실행됐고, Python 3.9는 OK와 종료 코드 0을 돌려줬습니다. Python 3.12는 NO TESTS RAN을 출력하고 종료 코드 5(CI에서는 실패)로 끝납니다.

unittest 실패 diff에서 어느 쪽이 기대값인가요?

unittest는 두 인자를 first, second라고만 부릅니다. assertEqual(실제값, 기대값) 순서로 썼다면 - 줄이 코드가 돌려준 값, + 줄이 기대한 값입니다. ? 줄은 다른 글자의 위치를 가리키는데, 한글처럼 폭이 넓은 글자가 섞이면 화면상 위치가 어긋납니다.

테스트가 늘어나면 실행 시간은 얼마나 늘어나나요?

Apple Silicon Mac(M1 Pro), Python 3.9에서 작은 테스트 1~100개는 46~53ms(대부분 인터프리터 시작 시간), 1,000개는 70ms, 10,000개는 286ms였습니다. 테스트 하나당 약 20마이크로초입니다. 네트워크를 건드리는 느린 테스트 하나가 빠른 테스트 수천 개보다 비쌉니다.

  • #테스트
  • #중급
  • #python
  • #unittest
  • #doctest
  • #dart

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑