JUnit으로 계약 확인
80분 안팎
학습 목표
명세 사례를 JUnit 5 테스트로 옮기고 구현과 별도로 기대 결과를 고정합니다.
개념
명세 사례를 실행 가능한 약속으로 바꿉니다
AI 코드가 컴파일되어 실행된다는 말은 개인 학습 할 일의 규칙을 지켰다는 말과 다릅니다. 새 항목이 처음부터 완료 상태여도 실행 자체는 성공할 수 있습니다. 이번 레슨은 앞에서 적은 생성·조회·완료 사례를 JUnit 5의 비교식으로 옮깁니다. 입력, 기대값, 상태 보존을 테스트 작성자가 직접 결정하고 구현에 어떤 결함이 있어야 실패하는지 설명하는 것을 목표로 합니다.
JUnit의 Test 애노테이션은 해당 메서드를 테스트로 실행하게 표시합니다. 테스트 클래스는 src/test/java/lab에, 업무 코드는 src/main/java/lab에 둡니다. 테스트 메서드는 준비, 실행, 확인의 세 부분으로 읽습니다. new TaskService가 준비이고 add 호출이 실행이며 assertEquals가 확인입니다. 서비스의 결과를 화면에 출력만 하는 메서드는 기대값을 검사하는 테스트가 아닙니다.
assertEquals는 기대값을 먼저, 실제값을 다음에 씁니다. assertEquals(new Task(1, 복습, false), 실제 생성 결과)처럼 명세에서 고정한 전체 값을 비교합니다. Task record는 필드 값을 기준으로 비교하므로 제목뿐 아니라 id와 done도 함께 보호합니다. assertTrue(task.id()가 양수) 정도만 검사하면 잘못된 번호 7도 통과하므로 명세가 요구한 정확한 값과 약한 성질을 구별합니다.
기대값을 구현에서 다시 만들지 않습니다
명세의 R1은 생성 뒤 done이 false여야 한다고 합니다. 테스트 안에서 구현의 기본 상태 상수를 읽어 기대값을 만들면 그 상수를 true로 잘못 바꾸었을 때 둘 다 같은 결함을 따를 수 있습니다. 기대 false를 테스트에 직접 적고 C01과 연결합니다. 코드 중복을 줄이는 습관과 독립된 판단 근거를 유지하는 일은 다르며, 기대값을 자동 생성하는 도우미도 어떤 규칙을 복제하는지 검토합니다.
creationMatchesSpec은 공백 제거된 제목과 번호 1, 미완료를 확인하고 같은 제목을 다시 생성하면 번호 2가 생기는지도 봅니다. 제목 중복 허용과 식별자 증가를 하나의 짧은 시나리오에서 관찰합니다. 두 번째 기대값을 첫 번째 실제 결과에서 복사하지 않습니다. 구현이 첫 생성부터 번호를 잘못 냈을 때 둘 다 어긋났다고 명확하게 표시할 수 있도록 기대 번호를 따로 둡니다.
각 테스트는 자기 TaskService를 새로 만듭니다. static 저장소를 공유하면 한 테스트의 생성이 다른 테스트의 초기 번호를 바꿉니다. 실행 순서에 맞춰 기대 번호를 늘리는 대신 독립된 초기 상태를 준비합니다. 반복 완료처럼 연속 호출의 관계가 계약인 경우에는 같은 테스트 안에서 순서대로 실행합니다. 서비스 재시작과 메모리 초기화를 임의로 합쳐 계약을 바꾸지는 않습니다.
예외뿐 아니라 이후 상태도 비교합니다
assertThrows는 람다로 감싼 호출에서 지정한 예외가 발생하는지 확인하고 예외 객체를 반환합니다. 람다는 () 다음 화살표와 호출식으로 적는 실행 묶음입니다. 서비스 준비는 람다 밖에 둬 준비 과정의 실패를 업무 오류로 오인하지 않습니다. IllegalArgumentException이 발생했는지 확인한 뒤 getMessage로 EMPTY_TITLE인지 비교하면 다른 이유의 예외를 정답으로 받아들이지 않습니다.
failedCreationPreservesStateAndId는 먼저 유효한 항목을 하나 넣고 before 목록을 보관합니다. 빈 제목으로 생성해 오류를 확인하고 전체 목록이 before와 같은지 비교합니다. 다음 정상 생성의 id가 2인지 확인합니다. 오류 메시지, 상태 보존, 실패가 번호를 소비하지 않는다는 세 조건을 분리해 검사하는 이유는 한 조건이 맞아도 나머지가 틀린 구현이 가능하기 때문입니다.
invalidIdsPreserveState는 boolean, 0, 음수, 문자열, 실수, null을 각각 넣습니다. 모두 INVALID_ID여야 하며 목록은 유지되어야 합니다. 숫자 모양의 문자열을 정수로 자동 변환하는 구현도 이 테스트가 거절합니다. absentIdPreservesState는 양의 정수 9를 넣고 NOT_FOUND를 요구합니다. 형식이 잘못된 값과 형식은 맞지만 없는 값을 구별하는 경계를 확인합니다.
completeAndRepeat는 첫 완료와 재완료가 모두 성공하는지 확인합니다. 처음 add에서 받은 Task는 계속 미완료여야 하므로 새 완료 값의 생성도 간접 확인합니다. 반환 리스트를 비우는 별도 검사는 조회가 내부 저장소를 내주지 않는지 봅니다. 업무의 성공 경로와 외부 수정 영향은 서로 다른 검사이며 하나의 큰 테스트에서 모든 조건을 숨기지 않습니다.
실패 보고서를 수정 방향으로 읽습니다
starter는 add가 만드는 done을 true로 두었습니다. 생성 결과를 비교하는 테스트는 실패하지만 잘못된 id를 거절하는 테스트는 통과합니다. 이 상태에서 테스트 수가 6이라는 사실과 실패한 테스트 이름을 함께 기록합니다. 사용자가 고칠 곳은 TaskService.add의 새 항목 초기 상태입니다. assertEquals를 assertNotNull로 약하게 만들면 결함이 남아도 테스트가 통과하므로 그렇게 완료하지 않습니다.
expected에 done=false, actual에 done=true가 보이면 어느 입력에서 어느 필드가 달랐는지 바로 알 수 있습니다. AssertionFailedError는 업무 결과의 비교 실패이며 Errors에 잡힌 예기치 않은 예외와 구별합니다. 테스트 컴파일 오류라면 Surefire 보고서가 새로 생기지 않을 수도 있습니다. 이전 보고서를 현재 실행의 증거로 쓰지 않도록 명령 시각과 종료 코드를 같이 기록합니다.
실패를 고친 뒤에는 해당 테스트만 한 번 실행해 원인을 확인하고 전체 실습 테스트를 실행합니다. 개별 메서드를 고르는 Maven 옵션은 -Dtest=TaskServiceTest#creationMatchesSpec처럼 사용합니다. 부분 통과는 그 메서드에 대한 증거이고 전체 계약 통과가 아닙니다. @Disabled나 테스트 제외로 실패를 숨기지 않으며 Skipped 숫자도 검토합니다. 서버를 띄우지 않아도 이런 서비스 계약은 검증할 수 있습니다.
미션에서 앞 모델과 Java를 나란히 대조합니다
미션 starter는 m02 solution의 명세, cases.json, Python 참조 모델과 문서 검사기를 이어받습니다. Java 프로젝트를 그 위에 추가했습니다. ContractCasesTest는 C01부터 C12까지 각 행의 initial로 저장소를 만들고 input을 apply에 넣습니다. expected와 결과를, expectedState와 snapshot을 비교합니다. 입력 초기 목록과 명령이 바뀌지 않았는지도 확인해 앞 모델과 동일한 관찰 조건을 보호합니다.
Python은 결과와 새 상태를 반환하지만 Java apply는 결과만 반환하고 상태는 snapshot에서 읽습니다. 이 차이는 JAVA-PORT.md에 적습니다. 외부 응답의 task나 tasks는 새 Map으로 만들고 수정해도 저장소는 그대로입니다. boolean과 실수는 id로 거절하며 거대한 양의 정수는 형식이 맞으므로 NOT_FOUND로 구별합니다. Java의 좁은 정수 변환으로 큰 id가 기존 번호에 겹치는 일도 검사합니다.
미션에서는 TitlePolicy·State·Build·TaskService 테스트와 사례 파일 검사를 함께 실행합니다. 앞 문서의 D3 미확인 기록은 과거 단계의 기록이므로 spec.md를 덮어쓰기보다 새 evidence-java.md에 실제 실행 명령과 결과, Java 단위 계약 확인 범위, HTTP 미확인을 적습니다. Python 검사와 Java 검사는 서로 대신하지 않으며 설명 문서의 의미와 AI에 맡긴 판단은 사람이 코드와 대조합니다.
테스트 전체가 통과하면 무엇이 아직 검증되지 않았는지도 말할 수 있어야 합니다. 동시 요청, 재시작 뒤 저장, JSON을 실제 HTTP로 보낸 경로는 이번 단위 테스트의 범위에 없습니다. 이 레슨의 완료는 기대값과 상태를 비교하는 JUnit을 직접 읽고 결함을 고칠 수 있는 상태입니다. 모의 객체, 매개변수 테스트와 통합 테스트의 확장은 더 읽기에서 이어갑니다.
실습 코드에서 찾을 부분
아래 테스트는 제공된 TaskServiceTest의 실패 후 상태와 번호를 보호하는 검사입니다. 준비를 assertThrows 밖에 둔 이유와 기대값 2가 명세에서 나온 근거를 설명합니다.
@Test
void failedCreationPreservesStateAndId() {
TaskService s = new TaskService();
s.add("복습");
var before = s.list();
var e = assertThrows(IllegalArgumentException.class,
() -> s.add(" "));
assertEquals("EMPTY_TITLE", e.getMessage());
assertEquals(before, s.list());
assertEquals(2, s.add("정리").id());
}따라하기
관찰 프로젝트 준비
TaskServiceTest를 실행해 준비·실행·확인의 위치를 읽습니다. 각 검사에 새 서비스를 만드는 이유를 설명하고 여섯 검사 결과를 기록합니다.
./mvnw test명세 생성값을 직접 관찰
case 1은 같은 제목을 두 번 생성합니다. 출력의 id와 done을 C01 및 중복 제목 허용 규칙과 비교합니다. 객체 주소를 확인하는 대신 세 필드의 값을 읽습니다.
java -cp target/classes demos/Demo.java 1실행 결과
Task[id=1, title=복습, done=false] Task[id=2, title=복습, done=false]
실패 후 상태와 번호 확인
case 2는 먼저 한 항목을 만들고 공백 제목으로 실패시킵니다. 오류 코드, UNCHANGED, NEXT를 각각 다른 계약으로 설명하고 어느 하나만 확인했을 때 놓칠 결함을 적습니다.
java -cp target/classes demos/Demo.java 2실행 결과
EMPTY_TITLE UNCHANGED=true NEXT=2
재완료와 이전 반환값 비교
case 3은 완료 후 다시 완료하고 처음 생성 결과를 보관합니다. OLD, DONE, REPEAT의 참·거짓을 예측한 뒤 불변 값과 재완료 성공을 구분해서 설명합니다.
java -cp target/classes demos/Demo.java 3실행 결과
OLD=false DONE=true REPEAT=true
확인 문제
실습
TaskService.add의 신규 done 값을 명세에 맞춥니다. 독립된 기대값, 실패 후 상태와 번호, 재완료, 잘못된 id, 조회 복사를 검사하는 제공 테스트 6개를 유지합니다. 추가로 본인이 확인하고 싶은 반례를 적고 미션의 C01~C12 연결 검사로 이어갑니다.
실행 명령
./mvnw test
기대 결과
테스트 6개가 실패·오류·건너뜀 없이 통과하며 종료 코드는 0입니다.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- AI가 만든 코드가 실행될 때 추가로 확인할 내용을 설명해 주시면 됩니다.