교재 홈: 바이브 코더를 위한 코딩 기초 학습

**테스트(test)**는 정한 입력으로 코드를 실행한 뒤 실제 결과가 기대한 결과와 같은지 확인하는 일입니다. **기대값(expected value)**은 코드 실행 전에 요구사항에서 정한 정답입니다. AI가 코드와 테스트를 함께 만들면 같은 오해가 양쪽에 들어갈 수 있으므로 기대값은 작은 원자료나 손계산으로도 확인합니다. 수학 계산이 복잡할 필요는 없습니다.

정상·경계·실패 사례

정상 사례는 평소 허용한 값이 제대로 처리되는지 보는 경우입니다. **경계값(boundary value)**은 허용 범위의 맨 끝이나 기준이 바뀌는 값입니다. 0~1440을 허용한다면 0과 1440이 끝값이고 -1과 1441은 바로 바깥 값입니다. 실패 사례는 허용하지 않은 값을 넣었을 때 정한 방식으로 거부하는지 확인하는 경우입니다. 실패해야 할 입력을 제대로 거부했다면 그 테스트는 성공입니다.

**명세(specification)**는 입력으로 무엇을 받고 어떤 결과나 실패를 내야 하는지 적은 약속입니다. 요구사항을 확인 가능한 문장으로 만든 것이라고 생각하면 됩니다. 독서 시간 변환 함수의 명세는 ‘앞뒤 공백을 정리한 정수 문자열을 받아 0~1440의 정수를 반환하고, 나머지는 오류로 알린다’입니다. 함수의 반환과 예외는 04. 함수와 모듈 — 입력·처리·반환을 나누기의 「매개변수와 인수, 반환값」절 및 07. 오류 처리 — 메시지에서 원인 후보로 가기의 「예외를 처리할 때 남겨야 하는 정보」절에서 다시 볼 수 있습니다.

구분 입력 기대 결과
정상 "30", " 15 " 30, 15
경계 "0", "1440" 0, 1440
범위 밖 "-1", "1441" ValueError
형식 실패 "1.5", "abc" ValueError
누락 "", None ValueError

‘정수 문자열’에는 현재 구현의 int()가 허용하는 +30 같은 표현도 포함됩니다. 숫자 0~9로만 이루어진 문자열을 요구한다면 명세와 구현을 함께 더 좁혀야 합니다. 테스트 사례는 요구의 빈틈을 찾는 계기가 됩니다.

단위 테스트 작성

테스트 코드는 결과를 자동으로 대조하는 프로그램입니다. 아래 문법 중 class는 처음 등장하는 형태이므로 먼저 읽는 방법을 봅니다. 클래스는 관련 값과 동작을 묶어 객체를 만드는 틀이고, 그 안에 정의한 함수를 메서드라고 합니다. class DataToolsTests(unittest.TestCase):는 unittest가 마련한 테스트 기능을 사용할 수 있도록 테스트 묶음을 정의하는 줄입니다. 다른 클래스의 기능을 이어 사용하는 관계를 **상속(inheritance)**이라고 합니다. 지금은 클래스 설계를 따로 배우기보다 제공한 테스트 틀 안에서 입력과 정답을 읽고 바꾸면 됩니다.

self는 실행 중인 해당 테스트 객체를 가리키는 매개변수 이름입니다. self.assertEqual(실제값, 기대값)은 두 값이 같은지 검사하고, self.assertRaises(ValueError)는 지정한 오류가 나는지 검사합니다. with self.subTest(...)는 여러 입력을 한 테스트 안에서 확인하면서 어느 입력에서 실패했는지 구별하게 합니다. test_로 시작하는 메서드는 이 도구가 찾아 실행할 테스트입니다.

단위 테스트(unit test)는 비교적 작은 함수나 동작을 독립적으로 확인합니다. tests 폴더를 만들고 다음 내용을 tests/test_data_tools.py로 저장합니다.

import unittest
from data_tools import parse_minutes, clean_records

def row(record_id="R001", minutes="30"):
    return {"id": record_id, "title": "가상 도서", "minutes": minutes, "completed": "yes"}

class DataToolsTests(unittest.TestCase):
    def test_normal_minutes(self):
        self.assertEqual(parse_minutes(" 30 "), 30)

    def test_boundaries(self):
        for text, expected in [("0", 0), ("1440", 1440)]:
            with self.subTest(text=text):
                self.assertEqual(parse_minutes(text), expected)

    def test_invalid_minutes(self):
        for text in ["", "abc", "1.5", "-1", "1441", None]:
            with self.subTest(text=text):
                with self.assertRaises(ValueError):
                    parse_minutes(text)

    def test_zero_is_kept(self):
        clean, errors = clean_records([row(minutes="0")])
        self.assertEqual(clean[0]["minutes"], 0)
        self.assertEqual(errors, [])

    def test_duplicate_id_is_reported(self):
        clean, errors = clean_records([row(), row(minutes="45")])
        self.assertEqual(len(clean), 1)
        self.assertEqual(errors, [{"record_number": 2, "reason": "중복 id"}])

    def test_invalid_first_row_does_not_claim_id(self):
        clean, errors = clean_records([row(minutes=""), row(minutes="45")])
        self.assertEqual(clean[0]["minutes"], 45)
        self.assertEqual(len(errors), 1)

    def test_input_is_preserved(self):
        original = row(minutes=" 30 ")
        clean_records([original])
        self.assertEqual(original["minutes"], " 30 ")

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

프로젝트 폴더에서 다음 명령을 실행합니다.

python -m unittest discover -s tests -v

이 파일만 있을 때 테스트 메서드 7개가 실행되고 모두 성공하면 OK가 표시됩니다. subTest는 한 메서드 안에서 여러 입력을 구별해서 확인합니다. 앞으로 종합 실습의 테스트를 추가하면 총 개수는 늘어납니다.

assertEqual은 실제 값과 기대값을 비교하고, assertRaises는 특정 예외가 나오는지 확인합니다. 잘못된 입력에서 오류가 발생하는 것이 요구사항이라면 오류 발생이 곧 테스트 성공입니다.

테스트 결과를 과장하지 않기

**통과(pass)**는 실행한 테스트가 기대한 조건을 만족했다는 뜻이고, **실패(fail)**는 그 조건과 실제 결과가 달랐다는 뜻입니다. 통과한 사례 밖의 모든 입력까지 확인했다는 뜻은 아닙니다. 실패하면 코드의 처리, 테스트의 기대값, 요구한 조건 중 어디가 잘못되었는지 확인합니다. 정답 근거를 확인하지 않고 비교 조건을 느슨하게 하거나 테스트를 지우면 문제를 놓칠 수 있습니다.

**assert(어설트)**는 ‘이 조건이 맞아야 한다’고 검사하는 Python 문장입니다. 일부 실행 옵션에서는 검사를 생략할 수 있으므로 외부 입력을 반드시 거부해야 하는 처리는 if와 raise로 명시합니다. unittest의 assertEqual 같은 검사 메서드와 Python의 단순 assert 문을 같은 것으로 취급하지 않습니다.