정상값과 경계값을 함께 검증
75분 안팎
학습 목표
경계 입력으로 규칙의 누락을 찾습니다.
개념
왜 0 주변을 검사하는가
0 이상의 정수라는 문장은 -1은 실패, 0과 1은 성공이라는 세 기대값을 만듭니다. 보통 양수 100만 검사하면 value <= 0처럼 잘못 쓴 조건도 통과합니다. 경계는 큰 숫자만 뜻하지 않고 허용 여부가 갈리는 지점입니다. 빈 문자열과 공백 문자열은 숫자 0과 다른 입력 부류입니다. 먼저 요구에서 기대 결과를 정하고 구현의 실제 결과와 비교합니다. 이번 목표는 쪽수 파서의 결과를 검사하는 함수를 작성해 누락된 조건을 드러내는 것입니다.
검사 표를 코드로 옮깁니다
입력과 기대값을 쌍으로 모으고 각 입력을 파서에 전달합니다. 빈 문자열은 (None, EMPTY), 0은 (0, None), 1은 (1, None), -1은 (None, NEGATIVE), abc는 (None, NOT_INTEGER)입니다. 검사 함수는 이 쌍 전체를 비교해야 오류 코드도 확인합니다. 실패 여부만 확인하면 음수가 NOT_INTEGER로 분류되어도 놓칩니다. 브라우저에서는 각 줄의 기대 출력과 실제 출력을 비교합니다. 로컬에서는 unittest.assertEqual로 같은 계약을 검사합니다.
검사 대상과 정답을 분리합니다
expected = parse_pages(raw)로 정답을 만들면 같은 함수를 자신과 비교해 버립니다. 기대값은 입력 계약에서 직접 적습니다. 버그 함수와 수정 함수를 같은 표로 비교하면 수정 효과가 보입니다. 테스트를 초록색으로 만들기 위해 기대값을 실제 값으로 바꾸기보다 요구가 바뀌었는지 먼저 확인합니다. 이 레슨의 브라우저 과제는 제공된 후보 구현이 표의 어떤 입력을 틀리는지 찾아 실패 입력을 출력합니다. 파서를 만드는 레슨과 다르게 검사 코드 자체를 연습합니다.
상태 보존은 별도 경계입니다
미션에서는 파서 결과가 맞아도 기록을 먼저 추가하면 실패 뒤 빈 기록이 남을 수 있습니다. 비어 있지 않은 목록을 준비하고 복사본을 남긴 뒤 실패 입력을 넣습니다. 이후 전체 내용, 건수, 쪽수 합계, 금액 합계를 함께 비교합니다. 길이가 같아도 기존 금액을 0으로 덮었을 수 있기 때문입니다. 금액만 틀린 경우는 쪽수 검증이 끝난 뒤의 변경을 발견하고 완료 표시만 틀린 경우는 더 늦은 변경을 발견합니다. 각 검증 실패 위치를 따로 검사합니다.
테스트 결과를 과장하지 않습니다
준비한 사례가 통과했다는 증거는 그 범위의 약속이 맞았다는 뜻입니다. 모든 입력과 모든 환경의 무결성을 보장하지 않습니다. 특히 문자열 길이 제한은 환경에 따라 달라질 수 있어 임의의 거대한 입력을 통과한다고 단정하지 않습니다. AssertionError에서 실제와 기대가 어떻게 다른지 읽고 테스트 이름으로 해당 규칙을 찾습니다. ERROR라면 import나 함수 반환 형태를 먼저 봅니다. 원본 입력이 바뀌지 않는지와 연속 실패 뒤 정상 추가가 가능한지도 미션에서 확인합니다.
입력 부류를 나누는 기준
정수 표기 검사에는 빈 값, 잘못된 표기, 음수, 허용되는 정수라는 서로 다른 갈래가 있습니다. 각 갈래에서 최소 하나의 사례를 고르고 허용 범위가 시작되는 0에는 별도의 자리를 줍니다. 공백만 있는 문자열은 strip 이후 빈 값이지만 문자열 0은 빈 값이 아닙니다. +0과 -0은 표기는 다르지만 변환 결과가 같으므로 정상 0과 함께 비교할 수 있습니다. 사례 수를 늘리는 목적은 같은 양수를 반복하는 것이 아니라 서로 다른 조건과 반환 경로를 드러내는 것입니다.
비교 기호 하나를 바꾼 후보
value가 0보다 작을 때 거절하는 조건을 0 이하로 바꾸면 음수 검사는 여전히 통과하고 양수 검사도 통과합니다. 오직 0의 성공 기대값이 이 차이를 드러냅니다. 세 번째 따라하기의 buggy는 그런 오류를 의도적으로 넣었습니다. FAIL 0 NEGATIVE OK는 입력 0, 실제 NEGATIVE, 기대 OK 순서이며 unittest의 전체 실행 보고서가 아닙니다. 출력에 FAIL이 포함되었다고 Python 실행 자체가 예외로 끝났다는 뜻도 아닙니다. 비교가 무엇을 검사하고 종료 코드는 어떻게 정해지는지 구분합니다.
후보를 바꿔 검사 코드를 평가하기
브라우저의 good 후보는 모든 표를 만족하고 zero 후보는 0을 잘못 거절합니다. empty 후보는 빈 입력을 성공 0으로 바꾸며 negative 후보는 -1을 성공 1로 바꿉니다. 제공된 candidates를 고쳐 모두 통과시키는 과제가 아니라 검사 반복문이 각 후보의 잘못된 입력을 찾게 하는 과제입니다. good에서 PASS만 나오고 다른 후보에서 지정된 입력이 나오면 정상 판정과 실패 탐지를 함께 확인한 것입니다. 모든 후보에 PASS를 출력하는 코드는 검사 표가 있어도 비교를 수행하지 않았으므로 증거가 되지 못합니다.
실패 목록을 만드는 순서
각 raw와 expected를 꺼내 candidate(mode, raw)를 한 번 호출하고 반환 쌍을 비교합니다. 불일치할 때만 failed에 입력 표시를 넣습니다. 빈 문자열은 화면에서 보이지 않으므로 EMPTY라는 표시를 사용하지만 이것은 이 과제의 입력 표시 규칙입니다. 반환 오류 코드 자체와 표시용 문자열을 같은 변수로 덮어쓰지 않습니다. 반복을 중간에 끝내면 뒤의 실패를 놓치므로 표 전체를 순회한 뒤 쉼표로 합칩니다. 실패 목록이 비었을 때만 PASS를 출력하고 비교 중의 디버그 출력은 최종 결과에서 제거합니다.
변경 전 상태를 제대로 남기기
records의 항목은 딕셔너리이므로 before = records는 복사가 아니라 같은 목록을 가리킵니다. records.copy()는 목록만 새로 만들고 안의 딕셔너리는 공유합니다. 기존 항목의 값을 덮어쓰는 오류도 찾으려면 이번처럼 값이 정수와 문자열인 자료에서는 각 dict를 복사해 before를 만들 수 있습니다. 더 깊은 중첩 자료가 있다면 그 구조에 맞는 복사 방법이 필요합니다. 호출 후 목록 전체를 비교하는 검사는 단순한 건수 검사보다 강하며, 성공 시 한 건 추가와 실패 시 원본 유지라는 서로 다른 기대를 각각 적습니다.
회귀 결과를 읽는 절차
새 경계 사례가 통과하면 이전 기능을 검사하는 테스트도 실행해 변화가 없었는지 확인합니다. unittest discover는 test로 시작하는 메서드를 TestCase에서 찾으므로 일반 함수 이름만 test로 시작한다고 실행되는 것은 아닙니다. 파일 패턴, 클래스 상속, 실행 폴더와 import 경로를 함께 확인합니다. 테스트 실행 수가 0이면 OK라도 확인한 계약은 없습니다. FAIL은 단언의 불일치를, ERROR는 테스트 실행 중 예외를 뜻합니다. 실패한 사례 이름과 실제·기대 차이를 읽은 뒤 구현 또는 검사 준비 중 어느 쪽이 잘못되었는지 판단합니다.
따라하기
경계 표
다음 코드를 별도 파일 demo.py에 저장하고 python3 demo.py로 실행합니다.
import re
def parse_number(raw):
text = raw.strip()
if not text:
return None, "EMPTY"
if not re.fullmatch(r"[+-]?[0-9]+", text):
return None, "NOT_INTEGER"
try:
value = int(text)
except ValueError:
return None, "NOT_INTEGER"
if value < 0:
return None, "NEGATIVE"
return value, None
for raw in ["-1", "0", "1"]:
print(raw, parse_number(raw))
실행 결과
-1 (None, 'NEGATIVE') 0 (0, None) 1 (1, None)
정답을 요구에서 결정
다음 코드를 별도 파일 demo.py에 저장하고 python3 demo.py로 실행합니다.
import re
def parse_number(raw):
text = raw.strip()
if not text:
return None, "EMPTY"
if not re.fullmatch(r"[+-]?[0-9]+", text):
return None, "NOT_INTEGER"
try:
value = int(text)
except ValueError:
return None, "NOT_INTEGER"
if value < 0:
return None, "NEGATIVE"
return value, None
import unittest
check = unittest.TestCase()
for raw, expected in [("", (None, "EMPTY")), ("0", (0, None)), ("-1", (None, "NEGATIVE"))]:
check.assertEqual(parse_number(raw), expected)
print("3 cases OK")
실행 결과
3 cases OK
비교 기호 오류 발견
다음 코드를 별도 파일 demo.py에 저장하고 python3 demo.py로 실행합니다.
def buggy(value):
return "NEGATIVE" if value <= 0 else "OK"
for value, expected in [(-1, "NEGATIVE"), (0, "OK"), (1, "OK")]:
if buggy(value) != expected:
print("FAIL", value, buggy(value), expected)
실행 결과
FAIL 0 NEGATIVE OK
확인 문제
실습
후보 구현을 검사하는 코드를 완성합니다. 첫 줄 mode는 good, zero, empty, negative 중 하나입니다. 제공된 파서·candidate·cases는 수정하지 않습니다. cases의 기대 쌍과 실제 반환 쌍을 비교해 실패한 입력을 표 순서대로 출력합니다. 빈 문자열은 EMPTY로 표시하고 실패가 여러 개면 쉼표로 연결하며 없으면 PASS입니다. 기대값을 candidate로 만들지 않습니다.
모범 답안
import re
def parse_number(raw):
text = raw.strip()
if not text:
return None, "EMPTY"
if not re.fullmatch(r"[+-]?[0-9]+", text):
return None, "NOT_INTEGER"
try:
value = int(text)
except ValueError:
return None, "NOT_INTEGER"
if value < 0:
return None, "NEGATIVE"
return value, None
def candidate(mode, raw):
if mode == "zero":
value, error = parse_number(raw)
if error is None and value == 0:
return None, "NEGATIVE"
return value, error
if mode == "empty" and not raw.strip():
return 0, None
if mode == "negative" and raw == "-1":
return 1, None
return parse_number(raw)
cases = [("", (None, "EMPTY")), ("0", (0, None)), ("1", (1, None)),
("-1", (None, "NEGATIVE")), ("abc", (None, "NOT_INTEGER"))]
mode = input()
failed = []
for raw, expected in cases:
if candidate(mode, raw) != expected:
failed.append("EMPTY" if raw == "" else raw)
print(",".join(failed) if failed else "PASS")
더 읽기
면접 질문
- 지출 합계 프로그램의 잘못된 입력 처리 방식을 설명합니다.