테스트도 코드다 - 읽히는 테스트
이 장에서 배우는 것
테스트가 실패했는데도 어느 규칙이 깨졌는지 알기 어렵다면, 테스트 자체를 읽는 데 시간이 든다. 실행 결과가 맞는 것과 그 결과를 사람이 이해할 수 있는 것은 서로 다른 문제다. 제품 코드의 이름과 책임을 정리하듯 테스트도 읽히는 코드로 다루어야 한다.
앞 장에서 다룬 불변 객체는 테스트의 입력으로도 유용하다. 한 번 만든 수강 정보를 실행 도중 바꾸지 않으면, 준비한 값과 확인한 결과 사이의 관계가 분명해진다. 이 장에서는 수강료의 미납액을 확인하는 오래된 테스트 묶음을 정리한다. 계산 규칙은 유지하고, 테스트가 규칙을 설명하도록 바꾼다.
- 준비·실행·확인 구조로 테스트의 흐름을 드러낸다.
- 실패한 조건과 기대한 행동을 테스트 이름에 담는다.
- 테스트 데이터 빌더로 중요한 입력만 눈에 띄게 만든다.
- 하나의 개념을 검증하면서 관련된 확인을 함께 묶는다.
- 정리 전후의 결과와 의도적으로 넣은 버그의 검출 여부를 비교한다.
문제 상황
학원 수강 관리 프로그램에는 수강생에게 남은 미납액을 보여 주는 기능이 있다. 수강을 취소했다면 미납액은 0이다. 취소하지 않았다면 수강료에서 납부액을 뺀다. 납부액이 수강료보다 많아도 미납액을 음수로 표시하지 않는다. 환불액을 계산하는 기능은 이번 예제의 범위에 포함하지 않는다.
담당자는 계산 코드를 수정한 뒤 기존 테스트를 실행한다. 모두 통과한다. 그런데 새로 합류한 동료는 테스트에 나오는 숫자와 참거짓 값이 무엇인지 묻는다. 테스트 이름에는 취소라고 적혀 있지만, 객체를 만드는 코드에서는 어느 값이 취소 여부인지 바로 보이지 않는다.
Enrollment a = new Enrollment("가람", 10000, 2000, true);
Enrollment b = new Enrollment("가람", 10000, 3000, false);
int x = rule.calculate(a);
int y = rule.calculate(b);
이 코드는 실행할 수 있지만 읽는 사람에게 생성자의 인자 순서를 기억하도록 요구한다. 테스트를 늘릴 때에는 기존 줄을 복사한 뒤 숫자 하나만 바꾸기 쉽다. 이름만 바꾸고 취소 여부를 그대로 두면 새 테스트가 의도와 다른 상황을 확인하게 된다.
이번 작업의 대상은 계산 코드가 아니라 테스트다. 수강료 10000 등의 금액은 규칙을 설명하기 위해 정한 가상의 입력이다. 실제 요금표와 관계없다. 정상 계산에서 얻는 이름·기댓값·실제값을 정리 전후에 비교하고, 잘못된 계산을 넣었을 때 두 테스트 묶음이 모두 문제를 발견하는지도 확인한다.
준비·실행·확인으로 읽는 흐름을 만든다
테스트는 보통 세 가지 질문에 답한다. 어떤 상황을 만들었는가, 무엇을 실행했는가, 어떤 결과를 기대하는가다. 이를 준비·실행·확인(Arrange–Act–Assert) 구조라고 부른다. 세 부분에 주석을 붙이는 것보다, 실제 코드가 그 순서로 짧게 이어지는 것이 먼저다.
준비에는 수강 정보와 기댓값이 놓인다. 실행에는 확인하려는 계산 호출이 놓인다. 확인에는 기댓값과 실제값을 비교하는 코드가 놓인다. 다른 수강 정보를 준비하는 코드가 실행과 확인 사이에 끼어들면, 독자는 어느 입력에서 나온 결과인지 다시 찾아야 한다.
완성 코드에서는 각 테스트가 실행 결과를 작은 객체로 반환하고, 공통 실행기가 확인을 맡는다. 테스트마다 출력 코드를 반복하지 않으면서도 준비한 수강 정보, 한 번의 계산 호출, 기댓값을 가까이 둘 수 있다. 작은 프로그램이므로 별도의 테스트 도구는 사용하지 않는다.
| 부분 | 담는 내용 | 미납액 예제 |
|---|---|---|
| 준비 | 규칙에 필요한 입력 | 수강료 10000, 납부액 3000 |
| 실행 | 검증 대상의 호출 | 미납액 계산 한 번 |
| 확인 | 관찰한 결과의 판정 | 실제값이 7000인지 비교 |
한 테스트가 하나의 개념을 검증한다는 말은 비교 연산을 하나만 쓰라는 뜻이 아니다. 수강 등록 결과를 확인하는 테스트라면 반환된 수강생과 강좌가 요청한 조합인지 함께 확인할 수 있다. 두 값이 함께 하나의 등록 결과를 설명하기 때문이다. 반면 같은 테스트에서 등록 결과와 월별 통계 갱신까지 확인하면 실패 원인을 나누어 읽기 어려워진다.
이번 예제에서는 각 테스트가 미납액 규칙 하나를 설명한다. 취소한 수강의 미납액과 초과 납부한 수강의 미납액은 모두 0이지만, 이유가 다르므로 이름과 입력을 나눈다. 결과 숫자가 같다는 이유로 하나의 상황으로 취급하면 규칙 하나가 빠져도 알아채기 어렵다.
이름과 데이터로 규칙을 설명한다
좋은 테스트 이름은 조건과 기대 행동을 함께 보여 준다. 여기서는 “취소한 수강의 미납액은 0이다”처럼 짧은 문장으로 쓴다. 실행 결과에 이 문장이 그대로 나오므로, 실패한 줄만 보고도 어떤 규칙을 살펴봐야 하는지 알 수 있다. 메서드 이름은 코드에서 찾기 쉽도록 같은 의미의 영어로 정한다.
“계산 테스트”, “정상 처리”, “두 번째 경우” 같은 이름은 실행 순서만 알려 주거나 너무 많은 행동을 포함한다. 이름을 구체적으로 쓸 수 없다면 한 테스트에 여러 개념이 섞였는지 확인할 만하다. 이름이 구체적인데 준비 코드가 그 조건을 만들지 않는다면, 테스트는 문서로서 잘못된 설명을 제공한다.
테스트 데이터 빌더(test data builder)는 유효한 기본 입력을 제공하고, 각 테스트가 필요한 값만 바꾸게 하는 보조 객체다. 취소 규칙에서는 cancelled()가, 일부 납부 규칙에서는 paid(3000)이 눈에 들어온다. 생성자의 세 번째 숫자가 무엇인지 외울 필요가 줄어든다.
다만 기본값을 숨기면 오히려 읽기 어려워질 수 있다. 미납액이 수강료에 따라 달라지는 테스트에서는 tuition(10000)을 명시한다. 기본값도 10000이지만, 이 숫자는 기대 결과를 이해하는 데 필요하기 때문이다. 수강생 이름은 계산 규칙에 영향을 주지 않으므로 공통 기본값으로 둔다.
빌더는 테스트마다 새로 만든다. 이전 테스트에서 바꾼 납부액이나 취소 여부가 다음 테스트로 넘어가면 실행 순서가 결과에 영향을 준다. 완성 코드의 anEnrollment()는 호출할 때마다 새 빌더를 반환한다. 생성된 수강 정보는 불변이므로 실행 과정에서 입력이 달라지지도 않는다.
테스트 정리의 결과도 검증한다
정리한 테스트가 모두 통과했다는 사실만으로 검증 능력이 유지되었다고 말할 수는 없다. 기대 결과를 잘못 적었거나 중요한 상황을 삭제했는데도 통과할 수 있다. 우선 같은 정상 계산에 대해 정리 전후가 같은 이름, 기댓값, 실제값을 만드는지 비교한다.
다음으로 계산을 일부러 잘못 구현해 본다. 이 예제에서는 취소 여부를 무시하는 계산과, 음수 미납액을 그대로 돌려주는 계산을 준비한다. 이렇게 작은 오류를 넣은 구현을 변이(mutant)라고 부른다. 올바른 테스트 묶음이라면 해당 오류가 나타나는 입력에서 실패해야 한다.
두 오류를 발견했다고 해서 모든 오류를 발견한다는 뜻은 아니다. 이번 비교는 취소 규칙과 음수 방지 규칙에 대한 검증이 남아 있다는 근거다. 변이를 하나도 발견하지 못했다면 테스트를 정리한 결과를 받아들이기 전에 입력과 기댓값부터 다시 살펴야 한다.
완성 코드에서는 오류를 넣은 계산의 실패를 개별 FAIL로 출력하지 않는다. 의도적으로 틀린 계산을 테스트가 거부했는지를 하나의 확인 대상으로 삼는다. 따라서 최종 실행의 모든 줄이 PASS여도 두 잘못된 계산은 내부에서 실패한 상태다. 출력의 의미를 이처럼 구별해야 결과를 오해하지 않는다.
완성 코드
다음 내용을 Main.java로 저장한다. 제품 코드 역할의 미납액 계산, 정리 전 테스트, 정리 후 테스트를 한 파일에 두어 비교하기 쉽게 구성했다. 실제 프로젝트에서는 테스트용 빌더와 실행기를 테스트 코드 쪽에 둔다.
Main.java
import java.util.Arrays;
public class Main {
record Enrollment(
String student,
int tuition,
int paid,
boolean cancelled) {
}
@FunctionalInterface
interface FeeRule {
int calculate(Enrollment enrollment);
}
record TestResult(String name, int expected, int actual) {
boolean passed() {
return expected == actual;
}
}
static final class EnrollmentBuilder {
private String student = "가람";
private int tuition = 10000;
private int paid = 0;
private boolean cancelled = false;
EnrollmentBuilder tuition(int value) {
tuition = value;
return this;
}
EnrollmentBuilder paid(int value) {
paid = value;
return this;
}
EnrollmentBuilder cancelled() {
cancelled = true;
return this;
}
Enrollment build() {
return new Enrollment(student, tuition, paid, cancelled);
}
}
static EnrollmentBuilder anEnrollment() {
return new EnrollmentBuilder();
}
static int outstandingFee(Enrollment enrollment) {
if (enrollment.cancelled()) {
return 0;
}
return Math.max(0, enrollment.tuition() - enrollment.paid());
}
static TestResult[] before(FeeRule rule) {
Enrollment a = new Enrollment("가람", 10000, 2000, true);
Enrollment b = new Enrollment("가람", 10000, 3000, false);
Enrollment c = new Enrollment("가람", 10000, 0, false);
Enrollment d = new Enrollment("가람", 10000, 12000, false);
int x = rule.calculate(a);
int y = rule.calculate(b);
int z = rule.calculate(c);
int w = rule.calculate(d);
return new TestResult[] {
new TestResult("취소한 수강의 미납액은 0이다", 0, x),
new TestResult("일부 납부하면 남은 금액이 미납액이다", 7000, y),
new TestResult("납부하지 않으면 수강료 전체가 미납액이다", 10000, z),
new TestResult("초과 납부해도 미납액은 0이다", 0, w)
};
}
static TestResult[] after(FeeRule rule) {
return new TestResult[] {
cancelledEnrollmentHasNoOutstandingFee(rule),
partialPaymentLeavesOutstandingFee(rule),
noPaymentLeavesFullTuition(rule),
overpaymentHasNoOutstandingFee(rule)
};
}
static TestResult cancelledEnrollmentHasNoOutstandingFee(FeeRule rule) {
Enrollment enrollment = anEnrollment()
.tuition(10000)
.paid(2000)
.cancelled()
.build();
int actual = rule.calculate(enrollment);
return new TestResult("취소한 수강의 미납액은 0이다", 0, actual);
}
static TestResult partialPaymentLeavesOutstandingFee(FeeRule rule) {
Enrollment enrollment = anEnrollment()
.tuition(10000)
.paid(3000)
.build();
int actual = rule.calculate(enrollment);
return new TestResult(
"일부 납부하면 남은 금액이 미납액이다", 7000, actual);
}
static TestResult noPaymentLeavesFullTuition(FeeRule rule) {
Enrollment enrollment = anEnrollment()
.tuition(10000)
.paid(0)
.build();
int actual = rule.calculate(enrollment);
return new TestResult(
"납부하지 않으면 수강료 전체가 미납액이다", 10000, actual);
}
static TestResult overpaymentHasNoOutstandingFee(FeeRule rule) {
Enrollment enrollment = anEnrollment()
.tuition(10000)
.paid(12000)
.build();
int actual = rule.calculate(enrollment);
return new TestResult("초과 납부해도 미납액은 0이다", 0, actual);
}
static boolean hasFailure(TestResult[] results) {
for (TestResult result : results) {
if (!result.passed()) {
return true;
}
}
return false;
}
static int failureCount = 0;
static void check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
if (!condition) {
failureCount++;
}
}
static void report(String heading, TestResult[] results) {
System.out.println(heading);
for (TestResult result : results) {
check(result.passed(), result.name());
}
}
public static void main(String[] args) {
FeeRule correct = Main::outstandingFee;
TestResult[] oldResults = before(correct);
TestResult[] newResults = after(correct);
report("정리 전", oldResults);
report("정리 후", newResults);
check(Arrays.equals(oldResults, newResults),
"정리 전후 이름·기댓값·실제값이 같다");
FeeRule ignoresCancellation = enrollment ->
Math.max(0, enrollment.tuition() - enrollment.paid());
TestResult[] oldCancellation = before(ignoresCancellation);
TestResult[] newCancellation = after(ignoresCancellation);
check(hasFailure(oldCancellation)
&& hasFailure(newCancellation)
&& Arrays.equals(oldCancellation, newCancellation),
"취소 무시 버그를 두 묶음이 같은 결과로 잡는다");
FeeRule allowsNegative = enrollment -> enrollment.cancelled()
? 0
: enrollment.tuition() - enrollment.paid();
TestResult[] oldNegative = before(allowsNegative);
TestResult[] newNegative = after(allowsNegative);
check(hasFailure(oldNegative)
&& hasFailure(newNegative)
&& Arrays.equals(oldNegative, newNegative),
"음수 미납액 버그를 두 묶음이 같은 결과로 잡는다");
if (failureCount != 0) {
throw new AssertionError("실패한 확인 수: " + failureCount);
}
}
}
줄별 해설
Enrollment는 수강생, 수강료, 납부액, 취소 여부를 묶는다. record로 선언했으므로 생성한 뒤 각 구성 요소를 다시 대입하는 메서드가 없다. 이번 장에서는 정상 범위의 작은 정수만 사용하며, 입력 검증 정책을 추가하지 않는다. 기존 테스트를 읽히게 만드는 작업에 집중하기 위해서다.
FeeRule은 수강 정보 하나를 받아 미납액을 반환하는 계약이다. 정상 계산과 잘못된 계산을 같은 테스트에 전달하기 위해 둔다. 실제 계산을 호출하는 대신 테스트 안에서 기댓값을 반환하면 제품 코드의 오류를 발견할 수 없으므로, 테스트는 전달받은 규칙을 반드시 실행한다.
TestResult는 이름과 기댓값, 실제값을 보관한다. passed()는 두 정수를 비교한다. 결과를 참거짓만으로 저장하지 않는 이유는 정리 전후에 실제로 어떤 값이 나왔는지까지 비교하기 위해서다. 모두 실패하더라도 서로 다른 값을 반환했다면 동일한 결과로 보지 않는다.
EnrollmentBuilder의 기본 납부액은 0이고 취소 여부는 거짓이다. tuition(), paid(), cancelled()는 값을 설정하고 같은 빌더를 반환한다. 이 연결 방식 덕분에 입력을 문장처럼 읽을 수 있다. build()를 호출하면 그 시점의 값으로 새 수강 정보를 만든다.
outstandingFee()는 취소를 먼저 확인한 뒤 차액의 하한을 0으로 제한한다. 테스트를 정리하는 동안 이 계산은 바꾸지 않는다. 정리 전의 before()는 네 입력을 먼저 만들고 네 계산을 실행한 뒤 결과를 모은다. 입력과 결과를 연결하려면 a에서 x로, 다시 배열의 첫 항목으로 눈을 옮겨야 한다.
after()는 네 테스트의 목록 역할을 한다. 각 메서드는 수강 정보를 준비하고, 규칙을 한 번 실행하고, 기대하는 결과를 반환한다. 빈 줄은 이 세 부분의 경계를 보여 준다. 공통 실행기인 report()가 check()를 호출하는 시점에 최종 확인과 출력이 이루어진다.
취소 테스트는 납부액을 2000으로 두었다. 취소 여부를 무시한다면 8000이 나오므로 오류를 구별할 수 있다. 수강료와 납부액을 같은 값으로 두었다면 취소 처리가 없어도 0이 나와 이 버그를 놓친다. 읽기 좋은 준비 코드는 짧은 코드뿐 아니라, 규칙의 차이를 드러내는 입력도 포함한다.
Arrays.equals()는 두 결과 배열의 대응하는 원소를 비교한다. TestResult는 레코드이므로 이름·기댓값·실제값이 모두 같아야 같은 원소로 판단한다. 따라서 이 비교는 통과 여부만 맞춰 보는 것보다 구체적이다. 두 테스트 목록의 순서도 같아야 한다.
hasFailure()는 결과 중 하나라도 실패했는지 살핀다. 변이를 확인할 때에는 정리 전후가 모두 실패를 포함하고, 전체 결과도 같은지를 함께 확인한다. 정상 결과와 두 변이 결과는 각각 네 경우 모두 비교된다. 마지막의 AssertionError는 확인 실패가 있었다면 프로그램을 실패 상태로 끝내기 위한 장치다.
실행 결과
JDK 17이 설치된 macOS 또는 Linux의 터미널에서 파일을 저장한 디렉터리로 이동해 실행한다. 프로그램은 파일을 쓰지 않으며 입력, 시각, 네트워크에 의존하지 않는다. 아래의 out은 컴파일러가 만드는 클래스 파일 디렉터리다.
javac -d out *.java
java -cp out Main
예상 출력은 다음과 같다. 정리 전과 정리 후의 네 결과 줄이 일치한다. 이어지는 세 줄은 정상 결과의 동등성과 두 버그에 대한 검출 결과를 확인한다.
정리 전
PASS 취소한 수강의 미납액은 0이다
PASS 일부 납부하면 남은 금액이 미납액이다
PASS 납부하지 않으면 수강료 전체가 미납액이다
PASS 초과 납부해도 미납액은 0이다
정리 후
PASS 취소한 수강의 미납액은 0이다
PASS 일부 납부하면 남은 금액이 미납액이다
PASS 납부하지 않으면 수강료 전체가 미납액이다
PASS 초과 납부해도 미납액은 0이다
PASS 정리 전후 이름·기댓값·실제값이 같다
PASS 취소 무시 버그를 두 묶음이 같은 결과로 잡는다
PASS 음수 미납액 버그를 두 묶음이 같은 결과로 잡는다
취소 무시 버그에서는 첫 번째 테스트의 실제값이 8000이 된다. 음수 미납액 버그에서는 네 번째 테스트의 실제값이 -2000이 된다. 두 경우 모두 기댓값은 0이므로 해당 결과의 passed()는 거짓이다. 실행기에서는 이 거짓이 두 묶음에 똑같이 나타나는 것을 확인하므로 마지막 두 줄은 PASS다.
실무에서 자주 틀리는 것
여러 규칙을 하나의 결과로 뭉친다
다음 코드는 두 규칙 중 무엇이 틀렸는지 출력만으로 구별할 수 없다. 첫 조건이 거짓이면 두 번째 계산은 실행하지 않는다.
check(rule.calculate(cancelled) == 0
&& rule.calculate(partiallyPaid) == 7000,
"미납액 계산");
각 규칙의 실패를 따로 보여 주면 필요한 상황을 바로 찾을 수 있다. 아래 코드는 준비된 두 입력을 사용한 부분 예제다.
check(rule.calculate(cancelled) == 0,
"취소한 수강의 미납액은 0이다");
check(rule.calculate(partiallyPaid) == 7000,
"일부 납부하면 남은 금액이 미납액이다");
확인 개수보다 중요한 것은 실패 메시지가 가리키는 개념의 범위다. 한 행동의 결과를 설명하는 여러 값은 함께 볼 수 있지만, 서로 다른 조건의 규칙은 분리하는 편이 읽기 쉽다.
기댓값을 제품 코드로 계산한다
다음 테스트는 같은 계산을 두 번 호출한다. 계산 규칙이 잘못되어도 양쪽 결과가 같으면 통과한다.
int expected = outstandingFee(enrollment);
int actual = rule.calculate(enrollment);
check(actual == expected, "일부 납부한 수강");
이 상황의 기댓값은 요구 규칙에서 독립적으로 정한다. 수강료가 10000이고 납부액이 3000이라는 준비 코드와 함께 읽히도록 둔다.
int actual = rule.calculate(enrollment);
check(actual == 7000,
"일부 납부하면 남은 금액이 미납액이다");
기댓값이 길고 복잡한 계산이라면 대표 입력을 더 작게 잡을 수 있는지 살펴본다. 테스트 안에 또 하나의 계산 모듈을 만들면 어느 쪽이 옳은지 판단하기 어려워진다.
빌더를 공유해 테스트 순서에 의존한다
다음 코드의 두 번째 수강 정보도 취소 상태다. 첫 번째 준비에서 바꾼 빌더의 값이 남아 있기 때문이다.
EnrollmentBuilder builder = anEnrollment();
Enrollment cancelled = builder.cancelled().build();
Enrollment unpaid = builder.paid(0).build();
각 상황에서 새 빌더를 사용하면 앞선 준비가 다음 입력에 영향을 주지 않는다.
Enrollment cancelled = anEnrollment()
.cancelled()
.build();
Enrollment unpaid = anEnrollment()
.tuition(10000)
.paid(0)
.build();
공유할 대상은 기본값을 만드는 방법이다. 변경 가능한 빌더 인스턴스까지 공유할 필요는 없다. 생성된 불변 객체를 공유할 때에도 각 테스트가 그 값의 의미를 이해할 수 있는지는 따로 살펴야 한다.
이름에 적힌 조건만으로 충분하다고 생각한다
이름이 취소 규칙을 말해도 입력이 취소 처리의 유무를 구별하지 못하면 테스트는 약해진다.
Enrollment enrollment = anEnrollment()
.tuition(10000)
.paid(10000)
.cancelled()
.build();
check(rule.calculate(enrollment) == 0,
"취소한 수강의 미납액은 0이다");
이 입력은 취소 처리를 삭제해도 통과한다. 취소하지 않았다면 미납액이 남는 입력으로 바꾸어 조건의 효과를 드러낸다.
Enrollment enrollment = anEnrollment()
.tuition(10000)
.paid(2000)
.cancelled()
.build();
check(rule.calculate(enrollment) == 0,
"취소한 수강의 미납액은 0이다");
이름, 입력, 기댓값이 함께 규칙을 설명해야 한다. 셋 중 하나만 잘 적었다고 문서 역할이 생기는 것은 아니다.
한눈에 보기
| 대상 | 확인할 질문 | 이 장의 선택 |
|---|---|---|
| 구조 | 입력과 실행과 판정이 이어지는가 | 각 테스트에 준비와 실행을 모으고 공통 실행기에서 확인한다 |
| 이름 | 실패한 조건과 기대 행동이 보이는가 | 취소한 수강의 미납액은 0이라고 적는다 |
| 데이터 | 결과에 중요한 값이 보이는가 | 수강료와 납부액은 필요한 곳에 명시한다 |
| 범위 | 한 가지 규칙의 실패를 설명하는가 | 취소와 초과 납부를 별도 테스트로 둔다 |
| 검증 유지 | 정리한 뒤에도 선택한 오류를 발견하는가 | 정상 결과와 두 변이의 결과를 비교한다 |
테스트가 문서 역할을 한다는 것은 설명문을 많이 붙인다는 뜻이 아니다. 독자가 이름으로 규칙을 찾고, 준비 코드로 조건을 확인하고, 기댓값으로 결과를 이해할 수 있다는 뜻이다. 다음 장에서는 이런 확인 수단을 바탕으로 변경을 어떤 크기와 순서로 진행할지 다룬다.
연습 문제
- 완성 코드에 “전액 납부하면 미납액은 0이다”를 추가하라. 정리 전과 정리 후에 같은 입력을 넣고, 정리 후에는 독립된 테스트 메서드를 사용하라. 정상 결과 비교와 기존 두 버그 검출이 유지되는지 확인하라.
- 취소 테스트의 납부액을 두 묶음 모두 10000으로 바꾸어라. 어떤 최종 확인이 실패하는지 예상한 뒤 실행 결과와 비교하라. 실패하는 이유를 입력과 실제값으로 설명하라.
- 취소 처리는 유지하지만, 취소하지 않은 수강의 미납액에 항상 1을 더하는 잘못된 계산을 추가하라. 두 묶음이 이를 같은 결과로 발견하는지 확인하는 코드를 작성하라.
- 동료가 수강생 이름과 수강료를 모두 빌더 기본값으로 숨기자고 제안했다. 일부 납부 테스트를 기준으로 어떤 값은 숨기고 어떤 값은 드러낼지 설명하라.
정답과 해설
-
정리 후에는 다음 메서드를 추가하고
after()의 배열에도 호출을 넣는다. 정리 전에는 동일한 수강 정보를 직접 생성하고 같은 이름과 기댓값으로 결과를 추가한다. 두 배열에서 추가하는 위치도 맞춘다.static TestResult fullPaymentHasNoOutstandingFee(FeeRule rule) { Enrollment enrollment = anEnrollment() .tuition(10000) .paid(10000) .build(); int actual = rule.calculate(enrollment); return new TestResult("전액 납부하면 미납액은 0이다", 0, actual); }정상 계산에서 새 테스트는 통과한다. 기존 취소 테스트와 초과 납부 테스트가 그대로 있으므로 두 버그 검출 확인도 통과한다. 전액 납부 테스트를 추가했다고 초과 납부 테스트를 지우면 안 된다. 두 입력은 각각 차액이 0인 경우와 음수인 경우를 설명한다.
-
“취소 무시 버그를 두 묶음이 같은 결과로 잡는다”가 실패한다. 취소한 입력의 수강료와 납부액이 모두 10000이면 취소 여부를 무시해도 차액은 0이다. 나머지 세 입력은 원래부터 취소 상태가 아니므로 해당 버그의 영향을 받지 않는다.
따라서 취소 무시 변이에 대한 두 결과 배열에는 실패가 없다. 배열끼리는 같지만
hasFailure()가 거짓이므로 최종 확인은 실패한다. 정상 결과 비교와 음수 미납액 버그 확인은 통과하고, 마지막에는 누적 실패로AssertionError가 발생한다. -
main()의 마지막 실패 수 확인 앞에 다음 코드를 넣는다. 취소하지 않은 세 입력의 실제값이 각각 7001, 10001, 1이므로 두 묶음 모두 이 오류를 발견한다.FeeRule addsOne = enrollment -> enrollment.cancelled() ? 0 : Math.max(0, enrollment.tuition() - enrollment.paid()) + 1; TestResult[] oldAdded = before(addsOne); TestResult[] newAdded = after(addsOne); check(hasFailure(oldAdded) && hasFailure(newAdded) && Arrays.equals(oldAdded, newAdded), "미납액에 1을 더한 버그를 두 묶음이 같은 결과로 잡는다");이 확인은 한 곳이라도 실패했는지와 두 묶음의 결과가 같은지를 함께 본다. 어떤 규칙이 이 오류를 발견했는지 더 자세히 조사하려면 결과 배열의 각 기댓값과 실제값을 살펴볼 수 있다.
-
수강생 이름은 미납액 계산에 관여하지 않으므로 기본값으로 숨겨도 이 테스트의 설명이 줄어들지 않는다. 수강료 10000과 납부액 3000은 기댓값 7000을 이해하는 데 필요하므로 준비 코드에 드러내는 편이 낫다.
판단 기준은 기본값과 같은가가 아니라, 독자가 그 값을 알아야 규칙을 이해할 수 있는가다. 기본값은 반복을 줄이는 도구이고, 테스트가 설명해야 할 조건까지 생략하는 근거는 아니다.