6.0 KiB
title, source_type, status, related_branch, tags, created
| title | source_type | status | related_branch | tags | created | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| ArchUnit fixture + testCompileOnly — NoClassDefFoundError at JUnit load time | error-note | raw | feature-streaming-response-contract |
|
2026-06-02 |
ArchUnit fixture + testCompileOnly — NoClassDefFoundError at JUnit load time
Parent
- raw/branch-notes/feature-streaming-response-contract
- raw/branch-notes/feature-domain-modeling-guardrails — 2026-06-05 addendum: record component variant + method-body 참조 패턴(4번)
현상
SpringWebSocketHandlerFixture 가 TextWebSocketHandler 를 extends 하도록 작성.
build.gradle 에 testCompileOnly 'org.springframework:spring-websocket' 추가.
./gradlew :app-bootstrap:compileTestJava — 성공.
./gradlew :app-bootstrap:test — 실패:
Could not execute test class 'dev.caskeleton.bootstrap.architecture.violations.streaming.SpringWebSocketHandlerFixture'.
Caused by: java.lang.NoClassDefFoundError: org/springframework/web/socket/handler/TextWebSocketHandler
원인
testCompileOnly 는 컴파일 classpath 에만 포함되고 runtime(test execution) classpath 에는 포함되지 않음.
JUnit 이 test source 를 스캔할 때 fixture 클래스를 JVM 에 로드 → superclass 로드 시도 → TextWebSocketHandler 없음 → NoClassDefFoundError.
ArchUnit 의 ClassFileImporter 는 바이트코드를 직접 읽으므로 class loading 불필요 — ArchUnit 자체는 무관.
문제는 JUnit 의 test class 스캐닝 이 모든 test source 클래스를 로드하려 하기 때문.
해결
Fixture 에서 forbidden type 을 annotation 으로만 참조 — annotation 은 JVM 이 class load 시점에 즉시 resolve 하지 않고 reflective access 시점에만 접근함.
@EnableWebSocket (from org.springframework.web.socket.config.annotation) 는:
org.springframework.web.socket..패키지 → ArchUnitno_websocket_handler규칙이 바이트코드에서 탐지.- runtime classpath 에
spring-websocket없어도 JVM 이 class 로드 성공.
@EnableWebSocket // annotation-only — no superclass loading at JVM load time
public class SpringWebSocketHandlerFixture {
}
적용 가능한 패턴
testCompileOnly fixture 에서 forbidden type 을 참조하는 방법:
- annotation — runtime-safe, bytecode 에 import 남음 ✅
- method return type / parameter type — class load 시 즉시 resolve 필요 →
testCompileOnly에서는NoClassDefFoundError⚠️ (단, 실제로는testImplementation로 이미 classpath 에 있는 경우 — e.g.spring-web— 는 문제 없음) - superclass extend / interface implement — class load 시 즉시 resolve 필요 →
testCompileOnly에서는NoClassDefFoundError✗
jakarta.websocket-api 2.1.1 추가 발견
jakarta.websocket-api 2.1.1 은 jakarta.websocket.server.* 만 포함 (server-only API jar).
Session, OnMessage 등 jakarta.websocket.* base 패키지 클래스 없음.
@ServerEndpoint 는 jakarta.websocket.server 에 있어서 annotation-only 참조 가능.
재발 방지
testCompileOnlydependency 의 fixture 에서 type 을 참조할 때는 annotation 참조 우선.- method/field 참조 시 해당 type 이
testImplementation에 transitively 포함되는지 확인. extends/implements는testCompileOnlytype 에 절대 사용 금지.
2026-06-05 addendum — record component variant (feature-domain-modeling-guardrails)
domain_events_are_transport_free 규칙의 violation fixture 를 @DomainEvent record 로
작성하면서, forbidden transport type 을 record component 로 두었다:
@DomainEvent
public record KafkaDomainEventFixture(TopicPartition partition) {} // testCompileOnly kafka-clients
compileTestJava 성공, 그러나 :app-bootstrap:test 가 다른 증상으로 실패:
TestEngine with ID 'junit-jupiter' failed to discover tests
Caused by: org.junit.platform.commons.JUnitException:
ClassSelector [className = '...JaxRsDomainEventFixture', ...] resolution failed
NoClassDefFoundError(named fixture)가 아니라 JUnit test discovery 단계 전체가 죽는다.
원인: record component 는 canonical constructor 시그니처 + accessor return type 에 들어가고,
JUnit 의 reflective discovery(getRecordComponents()/getDeclaredConstructors() 류)가 이를
즉시 resolve → testCompileOnly 라 런타임 부재 → discovery 전체 실패. 즉 2026-06-02 노트의
"method param/return = 즉시 resolve" 와 동일 메커니즘이 record component 로 확장된 것.
4번째 패턴 — method body 참조 (annotation 불가할 때)
annotation 으로 표현 못 하는 type(broker SDK 등)은 method body 안에서만 참조한다. 바이트코드에는 의존성이 남아 ArchUnit 이 탐지하지만, reflection(discovery)은 method body 의 타입을 즉시 resolve 하지 않는다:
@DomainEvent
public record KafkaDomainEventFixture(String aggregateId) { // component 는 안전한 도메인 타입
static String transportType() {
return TopicPartition.class.getName(); // .class literal — bytecode 의존성 O, discovery resolve X
}
}
추가로, 각 fixture 를 독립 subpackage 에 두고 importPackages("...event.kafka") 로 로드하면
ClassFileImporter 가 바이트코드만 읽어 격리 평가까지 동시에 달성(transport glob 별 비공허 증명).
importClasses(Foo.class) 는 class literal 이라 위 discovery 함정을 다시 부르므로 record fixture 에는 피한다.
갱신된 패턴 표 (testCompileOnly type 참조)
| 참조 위치 | discovery 시 resolve | ArchUnit 탐지 | testCompileOnly 안전 |
|---|---|---|---|
| annotation | X | O | ✅ |
method body (.class literal / new) |
X | O | ✅ (4번, 신규) |
| method param / return type | O | O | ✗ |
| record component (canonical ctor 시그니처) | O | O | ✗ (신규 확인) |
| field type | O | O | ✗ |
extends / implements |
O | O | ✗ |