테스트 코드 리뷰
이 장에서 배우는 것
앞 장에서 성능에 관한 판단을 측정으로 확인했다면, 이번에는 테스트가 내놓는 증거를 살펴본다. 테스트가 모두 통과했다는 말은 안심할 근거가 될 수 있다. 다만 어떤 입력을 넣었고 무엇을 확인했는지에 따라 그 근거의 범위가 달라진다. 대출이 가능한 경우만 확인한 테스트는 대출을 거절해야 하는 경우에 관해서는 아무 말도 하지 않는다.
테스트 코드 리뷰는 테스트의 개수보다 요구와 검증의 연결을 읽는 작업이다. 이름이 설명하는 요구가 본문에 있는지, 기대값이 구현의 실수를 그대로 따라가는지, 잘못된 결과를 실제로 구별할 수 있는지를 확인한다. 이 장에서는 도서관 대출 가능 여부를 판단하는 작은 코드를 대상으로, 약한 테스트를 통과하는 버그를 일부러 만든 뒤 테스트를 보강한다.
- 테스트 이름을 입력 조건과 기대 행동으로 읽고, 이름과 검증 내용이 일치하는지 확인한다.
- 기대값을 구현과 독립적으로 정하고 요구사항에서 근거를 찾는다.
- 경계와 실패 사례를 선택하고, 하나의 테스트가 하나의 행동을 설명하도록 나눈다.
- 내부 표현에 묶여 깨지는 테스트와 행동의 변화를 잡는 테스트를 구별한다.
- 일부러 넣은 버그로 테스트 묶음의 빈틈을 확인하고 보강한다.
문제 상황
도서관 대출 서비스에서 대출 가능 여부를 판단하는 변경이 올라왔다. 작성자는 대출 중인 권수가 적고 책이 준비되어 있으면 대출할 수 있다는 테스트 두 개를 첨부했다. 두 테스트는 모두 통과했다. 리뷰어는 테스트 이름만 보고 지나가려다가, 허용 권수를 채운 회원의 사례가 없다는 점을 발견했다.
이 서비스의 요구는 다음과 같이 정했다. 여기서 세 권은 예제를 위한 고정된 정책이며 실제 기관의 운영 기준을 뜻하지 않는다.
- 회원 정보가 있고, 대출 중인 권수가 0 이상이어야 한다.
- 대출 중인 권수가 세 권보다 적고 책이 준비되어 있으면 대출할 수 있다.
- 이미 세 권 이상을 대출했거나 책이 준비되지 않았으면 대출할 수 없다.
- 회원 정보가 없거나 대출 중인 권수가 음수이면 IllegalArgumentException을 던진다.
권수를 초과한 회원에게 false를 반환하는 것과 잘못된 입력에 예외를 던지는 것은 서로 다른 약속이다. 전자는 업무상 거절이고, 후자는 호출자가 입력을 바로잡아야 하는 경우다. 테스트에서도 두 결과를 구별해야 한다.
“대출할 수 있다”는 사례는 확인했다. 허용 권수를 막 채운 회원도 거절하는지 확인하면 비교 연산자가 바뀌었을 때의 동작을 구별할 수 있겠다.
이번 리뷰의 대상 변경은 정상 사례 두 개만 있는 테스트를 경계와 실패 사례까지 다루도록 보강하는 일이다. 구현에도 일부러 버그를 넣어 보강 전후를 비교한다. 완성 코드의 ORIGINAL은 정상 구현, BUGGY는 비교 연산자를 잘못 바꾼 구현, FIXED는 이를 바로잡은 구현이다. 세 구현을 같은 프로그램에 남겨 결과를 나란히 확인한다.
테스트 이름과 기대값을 요구에 연결한다
“대출 테스트”라는 이름은 실행 대상을 알려 주지만 기대 행동은 알려 주지 않는다. “두 권 대출 중이고 책이 준비되어 있으면 허용한다”라는 이름은 조건과 결과를 함께 담는다. 이런 이름은 테스트를 열기 전에도 무엇이 깨졌는지 짐작하게 한다. 다만 이름이 길다는 이유만으로 좋은 테스트가 되는 것은 아니다. 본문이 정말 두 권을 입력하고 허용 여부를 확인해야 한다.
이름을 먼저 읽은 다음 준비한 입력, 실행한 동작, 확인한 결과를 차례로 연결해 본다. 이름은 책이 준비되지 않은 상황을 말하는데 입력에서는 준비 여부를 true로 지정했다면, 검증식이 맞더라도 요구를 확인한 테스트가 아니다. 복사해서 만든 테스트에서는 입력과 이름 중 한쪽만 바꾸는 실수가 자주 생긴다.
| 이름 | 입력 | 기대 행동 |
|---|---|---|
| 두 권 대출 중이면 허용한다 | 권수 2, 준비됨 | true 반환 |
| 세 권 대출 중이면 거절한다 | 권수 3, 준비됨 | false 반환 |
| 책이 준비되지 않으면 거절한다 | 권수 0, 준비되지 않음 | false 반환 |
| 회원이 없으면 입력 오류다 | 회원 null | 지정한 예외 발생 |
기대값을 정하는 기준을 테스트 오라클(test oracle)이라고 한다. 이 예제에서는 요구사항의 문장과 구체적인 사례가 그 기준이다. 세 권을 대출 중인 회원의 기대값은 false다. 구현이 어떤 연산자를 쓰는지 읽어서 기대값을 계산할 필요가 없다.
기대값을 구현에서 베끼면 두 코드가 같은 실수를 공유할 수 있다. 구현이 권수에 대해 잘못된 비교를 하는데 테스트도 같은 비교식으로 expected를 만들면, 실제 결과와 기대값은 함께 틀린다. 두 값이 같다는 사실만으로 요구를 지켰다고 말할 수 없다. 짧은 식도 이 문제를 피하지 못한다. 식의 길이보다 기대값의 출처가 중요하다.
작은 정책에서는 기대값을 true나 false로 직접 적는 편이 읽기 쉽다. 계산이 큰 문제라면 손으로 검산한 예제나 별도로 확인한 자료가 필요할 수 있다. 어느 방식이든 리뷰어가 “이 값은 왜 맞는가”를 구현 본문 밖에서 설명할 수 있어야 한다. 같은 메서드를 한 번 더 호출해 얻은 값은 독립적인 근거가 되지 않는다.
경계와 실패를 고르고 검증의 단위를 정한다
허용 권수 정책을 읽었다면 경계 바로 아래인 두 권, 경계인 세 권, 경계 위인 네 권을 나란히 떠올린다. 두 권과 세 권은 허용에서 거절로 결과가 바뀌는 지점이다. 네 권은 경계를 넘은 입력도 업무상 거절로 처리한다는 사실을 확인한다. 여기에 빈 대출 목록에 해당하는 0과 잘못된 권수인 음수를 더하면 서로 다른 의미의 사례가 생긴다.
모든 권수와 모든 준비 상태의 조합을 나열할 필요는 없다. 특정 요구가 잘못 구현되었을 때 차이를 드러내는 대표 입력을 고르면 된다. 책의 준비 여부만 확인할 때는 권수를 0으로 두어 다른 거절 이유를 없앤다. 세 권을 대출 중이고 책도 준비되지 않은 입력만 쓰면, 어느 조건 때문에 거절했는지 알기 어렵다.
하나의 테스트가 하나를 검증한다는 말은 검증 함수를 한 번만 호출하라는 기계적인 규칙이 아니다. 하나의 행동에 필요한 결과와 상태를 함께 확인할 수도 있다. 중요한 것은 실패했을 때 어떤 요구가 깨졌는지 설명할 수 있느냐다. 이 장의 함수는 반환값 하나를 내므로 각 사례를 하나의 check 호출로 표현한다. 여러 사례를 한 줄의 큰 논리식으로 묶지 않는다.
예외를 확인하는 테스트에는 두 가지 함정이 있다. 첫째, 정상 반환을 했는데도 성공으로 처리할 수 있다. 둘째, 어떤 예외든 발생하면 통과하게 만들어 엉뚱한 오류를 놓칠 수 있다. 완성 코드에서는 정상 반환이면 false를 돌려주고, 계약에 지정한 IllegalArgumentException일 때만 true를 돌려주는 보조 함수를 사용한다. 다른 예외도 실패로 판단한다.
예외가 발생했다는 사실만으로 통과시키면 회원 검사 대신 다른 곳에서 발생한 오류도 받아들일 수 있다. 요구에서 정한 예외 종류를 확인하고, 예외 없이 끝나는 경우도 실패로 처리하자.
테스트 수를 늘리기 전에 기존 테스트가 어떤 오류를 구별할 수 있는지 묻는 방법도 있다. 정상 구현의 비교 연산자 하나를 바꾸고 같은 테스트를 돌려 보는 것이다. 이런 식으로 구현을 조금 변형해 테스트의 검출 능력을 확인하는 방법을 변이 테스트(mutation testing)라고 한다. 여기서는 별도 도구 없이 한 가지 변형만 직접 만든다.
이번 변형은 세 권보다 적어야 한다는 조건을 세 권 이하여도 된다는 조건으로 바꾼다. 0권과 1권만 확인하는 테스트는 이 차이를 볼 수 없다. 세 권을 대출 중인 사례를 추가하면 정상 구현과 변형 구현의 결과가 갈린다. 변형 구현이 보강된 테스트에서 실패하는 것은 실습의 의도한 결과다.
이 실험으로 모든 결함을 찾았다고 결론 내리지는 않는다. 확인한 것은 선택한 비교 연산자 오류를 해당 테스트가 잡는다는 사실이다. 테스트를 보강할 때는 이처럼 검증한 범위를 구체적으로 설명하는 편이 유용하다.
깨지기 쉬운 테스트의 의존을 살핀다
테스트가 자주 실패한다고 검출 능력이 높은 것은 아니다. 요구가 그대로인데 내부 정렬 방식, 임시 변수명, 로그 문구를 바꿀 때마다 실패한다면 검증 대상을 다시 살펴야 한다. 테스트가 의존해야 하는 것은 호출자가 관찰하는 약속이다. 내부 사정을 따라가는 검증은 구현을 정리할 때 불필요한 수정까지 요구한다.
예를 들어 대출 목록의 순서가 요구에 없다면 출력 문자열의 쉼표 위치까지 비교할 이유가 없다. 반대로 반환 순서를 서비스가 약속했다면 순서도 검증 대상이다. “문자열 비교는 나쁘다”와 같은 형식상의 규칙보다 그 문자열이 공개된 결과인지부터 확인한다. 예외 메시지도 사용자에게 제공하는 계약인지, 개발 중 설명을 위한 문구인지에 따라 판단이 달라진다.
입력 준비도 테스트의 안정성에 영향을 준다. 실행할 때마다 현재 시각을 읽거나 이전 테스트가 만든 공유 목록을 재사용하면, 이름에 적히지 않은 조건이 결과를 바꾼다. 날짜가 필요하면 고정된 날짜를 전달하고, 변경 가능한 데이터는 사례마다 준비한다. 이 장의 회원 객체는 권수 하나만 가진 불변 객체이며 시각이나 외부 자원에 의존하지 않는다.
공통 준비 코드를 줄이는 일도 목적을 갖고 해야 한다. 보조 함수가 입력의 의미를 숨기면 테스트를 이해하기 위해 여러 메서드를 오가야 한다. 아래 코드는 회원을 만드는 표현이 짧아 각 사례에 직접 적는다. 예외 처리처럼 반복되면서도 검증 방식이 분명한 부분만 보조 함수로 뺀다.
완성 코드
다음 파일 하나를 저장하면 된다. weakTests가 변경 전의 약한 테스트 묶음이고 strongTests가 변경 후의 보강한 묶음이다. weakTests는 기대값을 직접 적었지만 정상 사례만 있어 범위가 좁다. 기대값의 독립성과 입력 선택은 각각 확인해야 한다는 점을 보여 준다.
sameResults는 ORIGINAL과 FIXED에 같은 입력을 전달해 반환값과 예외 종류가 같은지 비교한다. 이 비교는 복구 과정에서 관찰한 동작이 유지되는지 확인하는 보조 증거다. 두 구현이 함께 틀릴 수도 있으므로, 요구에서 정한 기대값을 확인하는 strongTests를 대신하지 않는다.
Main.java
public class Main {
enum Version {
ORIGINAL, BUGGY, FIXED
}
record Member(int activeLoans) {
}
static int passed;
static int failed;
static boolean canBorrow(
Version version, Member member, boolean available) {
if (member == null) {
throw new IllegalArgumentException("회원이 필요하다");
}
if (member.activeLoans() < 0) {
throw new IllegalArgumentException("권수는 음수일 수 없다");
}
return switch (version) {
case ORIGINAL -> available && member.activeLoans() < 3;
case BUGGY -> available && member.activeLoans() <= 3;
case FIXED -> available && member.activeLoans() < 3;
};
}
static void check(boolean condition, String name) {
if (condition) {
passed++;
System.out.println("PASS " + name);
} else {
failed++;
System.out.println("FAIL " + name);
}
}
static boolean rejectsInvalid(
Version version, Member member, boolean available) {
try {
canBorrow(version, member, available);
return false;
} catch (IllegalArgumentException expected) {
return true;
} catch (RuntimeException unexpected) {
return false;
}
}
static void weakTests(Version version) {
check(canBorrow(version, new Member(0), true),
"0권이고 준비되면 허용");
check(canBorrow(version, new Member(1), true),
"1권이고 준비되면 허용");
}
static void strongTests(Version version) {
check(canBorrow(version, new Member(0), true),
"0권이고 준비되면 허용");
check(canBorrow(version, new Member(2), true),
"2권이고 준비되면 허용");
check(!canBorrow(version, new Member(3), true),
"3권이면 거절");
check(!canBorrow(version, new Member(4), true),
"4권이면 거절");
check(!canBorrow(version, new Member(0), false),
"책이 준비되지 않으면 거절");
check(rejectsInvalid(version, null, true),
"회원이 없으면 입력 오류");
check(rejectsInvalid(version, new Member(-1), true),
"권수가 음수이면 입력 오류");
check(rejectsInvalid(version, new Member(-1), false),
"준비 여부와 무관하게 음수는 입력 오류");
}
static String outcome(
Version version, Member member, boolean available) {
try {
return Boolean.toString(canBorrow(version, member, available));
} catch (RuntimeException exception) {
return exception.getClass().getName();
}
}
static boolean sameResults() {
Member[] members = {
null, new Member(-1), new Member(0),
new Member(1), new Member(2), new Member(3),
new Member(4)
};
boolean[] availability = {false, true};
for (Member member : members) {
for (boolean available : availability) {
String before = outcome(Version.ORIGINAL, member, available);
String after = outcome(Version.FIXED, member, available);
if (!before.equals(after)) {
return false;
}
}
}
return true;
}
static void runSuite(
String title, Version version, boolean strong) {
passed = 0;
failed = 0;
System.out.println(title);
if (strong) {
strongTests(version);
} else {
weakTests(version);
}
System.out.println("합계 PASS=" + passed + " FAIL=" + failed);
}
public static void main(String[] args) {
runSuite("약한 테스트 / 원본", Version.ORIGINAL, false);
runSuite("약한 테스트 / 버그 삽입", Version.BUGGY, false);
runSuite("보강 테스트 / 버그 삽입", Version.BUGGY, true);
runSuite("보강 테스트 / 수정", Version.FIXED, true);
passed = 0;
failed = 0;
System.out.println("전후 동작 비교");
check(sameResults(), "같은 입력 14개에서 반환값과 예외 종류 유지");
System.out.println("합계 PASS=" + passed + " FAIL=" + failed);
}
}
줄별 해설
Version과 Member 선언. 세 버전을 이름으로 구별해 호출 실수를 줄인다. Member는 record로 선언해 권수 값을 보관한다. 음수 객체를 만드는 것 자체는 허용하고, 대출 판단 함수에서 잘못된 입력을 거절하도록 했다. 검증 책임을 다른 곳으로 옮긴 예제가 아니라 현재 함수의 입력 계약을 시험하는 예제다.
canBorrow의 입력 검사. 회원 존재 여부와 음수 권수를 먼저 확인한다. 책이 준비되지 않았다는 이유로 먼저 false를 반환하면 잘못된 회원 입력을 놓칠 수 있다. strongTests의 마지막 사례는 준비 여부가 false여도 음수 권수는 예외로 처리해야 한다는 요구를 확인한다.
세 갈래의 반환식. ORIGINAL과 FIXED는 같은 조건을 사용한다. BUGGY만 세 권을 포함하도록 바뀌었다. 일부러 넣은 차이를 작게 유지했기 때문에 테스트 결과가 달라진 원인을 추적하기 쉽다. 입력 검사는 세 버전이 공유하므로 이 실험은 입력 검사 자체의 변형까지 평가하지 않는다.
check의 두 분기. 조건을 한 번 받아 PASS 또는 FAIL을 출력하고 개수를 센다. 자바의 assert는 실행 옵션에 따라 동작 여부가 달라질 수 있어 사용하지 않는다. 실패해도 즉시 중단하지 않으므로 한 번 실행해 전체 사례의 결과를 볼 수 있다. 이 프로그램은 예상한 실패를 보여 주는 실습이어서 프로세스 종료 상태로 성공 여부를 전달하지 않는다.
rejectsInvalid의 반환 위치. 호출이 정상적으로 끝나면 false다. 지정한 예외를 잡았을 때만 true다. 다른 실행 중 예외는 false로 바꾸어 해당 검증을 실패시킨다. 간단한 예제라 실제 예외 종류를 추가 출력하지 않았지만, 규모가 커지면 원인을 볼 수 있도록 실패 정보도 제공하는 편이 좋다.
weakTests의 두 검증. 권수 0과 1의 기대값은 모두 허용이다. BUGGY도 두 입력에서는 올바른 결과를 내므로 전부 통과한다. 테스트 이름과 기대값이 맞더라도 입력 범위가 부족하면 버그를 놓친다는 장면이다.
strongTests의 여덟 검증. 허용되는 경계 아래, 거절되는 경계와 그 위, 준비되지 않은 책, 두 종류의 잘못된 입력을 나눈다. 각 check에는 실패했을 때 구분할 수 있는 이름이 있다. 여러 조건을 하나의 check에 묶었다면 결과가 FAIL이어도 어느 요구를 놓쳤는지 다시 조사해야 한다.
outcome과 sameResults. 정상 반환값은 문자열로, 예외는 클래스 이름으로 기록한다. 일곱 회원 입력과 두 준비 상태를 조합해 열네 입력을 비교한다. 이 비교에서 “동작 유지”는 반환값과 예외 종류에 한정된다. 예외 메시지나 내부 실행 순서까지 같다고 주장하지 않는다.
runSuite와 main. 묶음마다 집계를 초기화해 앞 실행의 실패가 다음 합계에 섞이지 않게 한다. 약한 테스트의 원본과 버그 버전을 먼저 실행하고, 같은 버그에 보강한 테스트를 적용한다. 마지막에는 수정 구현을 검증하고 원본과 같은 입력에 같은 결과를 내는지 실행으로 확인한다.
실행 결과
코드는 JDK 17에서 외부 라이브러리 없이 컴파일할 수 있다. 다음 명령의 -Xlint:all은 컴파일 경고도 확인하기 위해 덧붙인 옵션이다. 일반 명령인 javac -d out *.java로 컴파일해도 같은 결과가 나온다.
javac -Xlint:all -d out *.java
java -cp out Main
컴파일 경고는 없으며 예상 출력은 다음과 같다. 세 번째 묶음의 FAIL 하나는 일부러 넣은 버그가 검출되었음을 뜻한다. 네 번째 묶음에서는 수정한 구현이 같은 테스트를 모두 통과한다.
약한 테스트 / 원본
PASS 0권이고 준비되면 허용
PASS 1권이고 준비되면 허용
합계 PASS=2 FAIL=0
약한 테스트 / 버그 삽입
PASS 0권이고 준비되면 허용
PASS 1권이고 준비되면 허용
합계 PASS=2 FAIL=0
보강 테스트 / 버그 삽입
PASS 0권이고 준비되면 허용
PASS 2권이고 준비되면 허용
FAIL 3권이면 거절
PASS 4권이면 거절
PASS 책이 준비되지 않으면 거절
PASS 회원이 없으면 입력 오류
PASS 권수가 음수이면 입력 오류
PASS 준비 여부와 무관하게 음수는 입력 오류
합계 PASS=7 FAIL=1
보강 테스트 / 수정
PASS 0권이고 준비되면 허용
PASS 2권이고 준비되면 허용
PASS 3권이면 거절
PASS 4권이면 거절
PASS 책이 준비되지 않으면 거절
PASS 회원이 없으면 입력 오류
PASS 권수가 음수이면 입력 오류
PASS 준비 여부와 무관하게 음수는 입력 오류
합계 PASS=8 FAIL=0
전후 동작 비교
PASS 같은 입력 14개에서 반환값과 예외 종류 유지
합계 PASS=1 FAIL=0
약한 테스트 묶음은 변경 전후 모두 같은 통과 출력을 낸다. 그러나 그 사실은 버그가 없다는 증거가 되지 못했다. 보강한 묶음은 세 권이라는 입력에서 차이를 드러낸다. 테스트 리뷰에서 중요한 질문은 “통과했는가”에 더해 “어떤 잘못을 넣으면 실패하는가”다.
실무에서 자주 틀리는 것
아래 예제들은 완성 코드의 Main.java와 같은 디렉터리에 각각 표시한 클래스 이름의 .java 파일로 저장해 실행할 수 있다. 전후의 작은 실행 예제이며 제품 코드에 추가하는 테스트는 아니다. 모두 Main의 check를 사용한다.
기대값에 구현의 비교식을 다시 적는다
다음 코드는 세 권을 허용하는 버그와 같은 비교식을 기대값에 사용한다. 실행하면 잘못된 정책을 PASS로 출력한다. 구현을 다른 파일로 분리했더라도 기대값이 같은 판단을 반복하면 문제는 그대로다.
class CopiedExpected {
public static void main(String[] args) {
int count = 3;
boolean expected = count <= 3;
boolean actual = Main.canBorrow(
Main.Version.BUGGY, new Main.Member(count), true);
Main.check(actual == expected, "3권이면 거절");
}
}
고친 코드는 요구에서 정한 false를 기대값으로 둔다. 입력과 대상 구현은 그대로인데 결과는 FAIL이 된다. 테스트를 고친 목적은 버그가 있는 상태에서 올바르게 실패하게 만드는 데 있다.
class IndependentExpected {
public static void main(String[] args) {
int count = 3;
boolean expected = false;
boolean actual = Main.canBorrow(
Main.Version.BUGGY, new Main.Member(count), true);
Main.check(actual == expected, "3권이면 거절");
}
}
여러 사례를 하나의 이름으로 묶는다
다음 검증은 허용과 거절을 함께 확인하지만 출력에는 “대출 정책”만 남는다. 또 왼쪽 조건이 false이면 오른쪽 호출은 실행되지 않는다. 실패한 뒤 어떤 사례부터 조사해야 하는지 출력만으로 알기 어렵다.
class BundledCases {
public static void main(String[] args) {
Main.check(
Main.canBorrow(Main.Version.BUGGY, new Main.Member(2), true)
&& !Main.canBorrow(
Main.Version.BUGGY, new Main.Member(3), true),
"대출 정책");
}
}
각 행동에 이름을 붙이면 두 권 사례는 PASS, 세 권 사례는 FAIL로 나뉜다. 검사한 입력은 같지만 실패의 위치가 분명해진다. 반복이 조금 늘어도 사례의 의미가 바로 읽히는 이점이 있다.
class SeparateCases {
public static void main(String[] args) {
Main.check(
Main.canBorrow(Main.Version.BUGGY, new Main.Member(2), true),
"2권이면 허용");
Main.check(
!Main.canBorrow(Main.Version.BUGGY, new Main.Member(3), true),
"3권이면 거절");
}
}
계약에 없는 예외 문구에 묶인다
이 예제의 요구는 예외 종류를 정했고 문구는 정하지 않았다. 아래 테스트는 실제 메시지의 띄어쓰기가 다르다는 이유로 FAIL이 된다. 사용자가 관찰할 필요가 없는 표현을 검증하면 설명 문구를 다듬을 때도 테스트를 고쳐야 한다.
class MessageBound {
public static void main(String[] args) {
boolean matched = false;
try {
Main.canBorrow(Main.Version.FIXED, null, true);
} catch (IllegalArgumentException exception) {
matched = "회원정보가 필요하다".equals(exception.getMessage());
}
Main.check(matched, "회원이 없으면 입력 오류");
}
}
고친 코드는 이 서비스가 약속한 예외 종류를 확인한다. 같은 null 입력에 제품 코드가 보이는 동작은 그대로이며, 검증 기준을 요구에 맞게 조정했기 때문에 PASS가 된다. 메시지 자체가 공개된 요구라면 문구 검증을 유지하되 그 근거를 명시해야 한다.
class ContractBound {
public static void main(String[] args) {
Main.check(
Main.rejectsInvalid(Main.Version.FIXED, null, true),
"회원이 없으면 입력 오류");
}
}
이 여섯 파일도 저장했다면 함께 컴파일한 뒤 아래와 같이 실행할 수 있다. 기대값을 고친 첫 쌍에서는 버그 검출 여부가 달라지고, 두 번째 쌍에서는 실패를 구분하는 정보가 늘어난다. 세 번째 쌍에서는 요구에 없는 문구 의존을 제거한 결과를 확인한다.
javac -Xlint:all -d out *.java
java -cp out CopiedExpected
java -cp out IndependentExpected
java -cp out BundledCases
java -cp out SeparateCases
java -cp out MessageBound
java -cp out ContractBound
PASS 3권이면 거절
FAIL 3권이면 거절
FAIL 대출 정책
PASS 2권이면 허용
FAIL 3권이면 거절
FAIL 회원이 없으면 입력 오류
PASS 회원이 없으면 입력 오류
한눈에 보기
| 확인 대상 | 의심할 신호 | 보강 방법 | 이 예제의 근거 |
|---|---|---|---|
| 이름 | 대상만 적고 결과가 없음 | 조건과 기대 행동을 적음 | 3권이면 거절 |
| 기대값 | 구현과 같은 식으로 계산 | 요구에서 독립적으로 정함 | 세 권의 기대값은 false |
| 입력 범위 | 정상 사례만 반복 | 경계 아래·경계·위 선택 | 2권·3권·4권 |
| 실패 사례 | 어떤 예외든 통과 | 정상 반환과 다른 예외를 실패 처리 | 회원 없음·음수 권수 |
| 검증 단위 | 큰 논리식에 여러 행동을 묶음 | 실패 이유별로 이름을 나눔 | 독립된 check 호출 |
| 안정성 | 내부 표현을 바꾸면 실패 | 공개된 행동에 의존 | 예외 문구 대신 종류 확인 |
| 검출 능력 | 버그를 넣어도 모두 통과 | 결과가 갈리는 입력을 추가 | 세 권에서 BUGGY 실패 |
리뷰를 마칠 때는 테스트 개수보다 새로 확인한 요구를 설명한다. “여섯 개를 추가했다”보다 “허용 권수의 경계, 준비되지 않은 책, 잘못된 회원 입력을 확인했다”가 변경의 가치를 더 정확하게 전한다. 이후 구현이 바뀌어도 이 설명은 테스트가 지켜야 할 목적을 남긴다.
연습 문제
- BUGGY의 권수 조건을 두 권보다 적을 때만 허용하도록 바꾼다고 가정한다. weakTests와 strongTests에서 각각 어떤 결과가 나오는지 설명하라.
- canBorrow가 입력 검사보다 먼저 준비 여부를 확인하고, 준비되지 않았으면 곧바로 false를 반환하도록 바뀌었다. 현재 보강 테스트 중 어떤 사례가 이 변경을 잡는지 찾고, 아직 빠진 조합 하나를 제안하라.
- sameResults가 통과했으므로 strongTests를 지워도 된다는 의견이 나왔다. 두 검증의 근거가 어떻게 다른지 설명하고, sameResults만 통과할 수 있는 잘못된 변경을 제시하라.
- 대출 기록에 여러 속성이 추가되어 한 테스트에서 반환 상태와 기록된 회원을 모두 확인하게 되었다. 검증이 두 개라는 이유만으로 나누어야 하는지 판단하고, 이름을 나눌 기준을 설명하라.
정답과 해설
약한 테스트는 0권과 1권만 사용하므로 둘 다 통과한다. 보강 테스트에서는 “2권이고 준비되면 허용”이 실패한다. 나머지 사례는 기존 기대와 일치하므로 합계는 PASS 7, FAIL 1이다. 경계의 허용 쪽도 확인해야 거절 범위를 지나치게 넓힌 오류를 잡을 수 있다.
“준비 여부와 무관하게 음수는 입력 오류”가 실패한다. 예외가 필요한 입력에 false가 반환되기 때문이다. 추가할 조합은 회원이 null이고 준비 여부가 false인 경우다. 이 경우도 IllegalArgumentException이어야 한다. null과 음수는 서로 다른 잘못된 입력이므로 한쪽 결과만으로 다른 쪽을 보장하지 않는다.
sameResults의 기준은 다른 구현의 결과이고, strongTests의 기준은 요구사항의 기대값이다. ORIGINAL과 FIXED의 권수 조건을 모두 세 권 이하로 바꾸면 두 구현은 열네 입력에서 계속 같은 결과를 내지만 요구는 어긴다. strongTests는 세 권 사례에서 이를 잡는다. 동작 비교는 복구나 정리 과정의 보조 검사로 유지하고 요구 검증도 남긴다.
반환 상태와 기록된 회원이 모두 같은 대출 성공 행동의 필수 결과라면 한 테스트에 함께 둘 수 있다. 각 검증에는 어느 결과가 틀렸는지 구분할 이름을 붙인다. 반면 허용 권수 거절과 기록 조회처럼 다른 조건과 다른 행동을 확인한다면 나누는 편이 낫다. 판단 기준은 검증 호출 수보다 하나의 실패를 하나의 요구로 설명할 수 있는지다.