문서 클래스 표 계약 테스트를 되살린다

architecture.md의 클래스 책임 표는 코드 구조를 문서에 복제한 것이라
rename이나 package 이동이 있으면 조용히 낡는다. 이 테스트는 그 이유로
만들어졌고, 삭제된 원본 Javadoc에 "패키지 재구성 한 번에 네 개의
이름이 죽은 적이 있다"고 적혀 있다.

c9a6bd2의 dap에서 dat으로의 개명에서 같은 일이 다시 일어났다. 테스트가
그 커밋에서 함께 삭제되어 드리프트를 잡지 못했고, dc6f602에서 사람이
손으로 대조해 아홉 건을 고쳐야 했다.

패키지 문자열만 dat으로 바꿔 복원한다. 표의 클래스 행 서른 개를 모두
읽어 src/main/java에 해당 소스가 있는지 확인한다. 패키지 경계 표의
일곱 행은 첫 칸이 소문자 패키지명이라 정규식이 의도대로 건너뛰므로
사각지대는 없다.

존재하지 않는 클래스 행을 표에 심어 실패를 내는 것까지 확인했고 확인 후
되돌렸다. 195개 테스트 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:43:33 +09:00
parent dc6f60219b
commit 973c650bed

View File

@@ -0,0 +1,74 @@
package io.shinhanlife.dat.biz.mcp.docs;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네
* 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
*/
class ArchitectureDocumentContractTest {
private static final Path ARCHITECTURE = Path.of("docs", "architecture.md");
private static final Path MAIN_PACKAGE =
Path.of("src", "main", "java", "io", "shinhanlife", "dat", "biz", "mcp");
/**
* 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다.
*/
private static final Pattern TABLE_ROW =
Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|");
/**
* 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다.
*/
@Test
void everyDocumentedClassPathStillExists() throws IOException {
List<DocumentedType> documented = documentedTypes();
// 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다.
assertThat(documented)
.withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.")
.hasSizeGreaterThan(10);
List<DocumentedType> missing = documented.stream().filter(type -> !sourceExists(type)).toList();
assertThat(missing)
.withFailMessage(
"architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n"
+ "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.",
missing)
.isEmpty();
}
/**
* 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다.
*/
private List<DocumentedType> documentedTypes() throws IOException {
try (Stream<String> lines = Files.lines(ARCHITECTURE)) {
return lines.map(TABLE_ROW::matcher)
.filter(Matcher::find)
.map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2)))
.distinct()
.toList();
}
}
/**
* 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다.
*/
private boolean sourceExists(DocumentedType type) {
return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java"));
}
private record DocumentedType(String name, String packagePath) {
}
}