--- title: ArchUnit fixture + testCompileOnly — NoClassDefFoundError at JUnit load time source_type: error-note status: raw related_branch: feature-streaming-response-contract tags: [archunit, gradle, testCompileOnly, fixture, NoClassDefFoundError] created: 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`) 는: 1. `org.springframework.web.socket..` 패키지 → ArchUnit `no_websocket_handler` 규칙이 바이트코드에서 탐지. 2. runtime classpath 에 `spring-websocket` 없어도 JVM 이 class 로드 성공. ```java @EnableWebSocket // annotation-only — no superclass loading at JVM load time public class SpringWebSocketHandlerFixture { } ``` ## 적용 가능한 패턴 `testCompileOnly` fixture 에서 forbidden type 을 참조하는 방법: 1. **annotation** — runtime-safe, bytecode 에 import 남음 ✅ 2. **method return type / parameter type** — class load 시 즉시 resolve 필요 → `testCompileOnly` 에서는 `NoClassDefFoundError` ⚠️ (단, 실제로는 `testImplementation` 로 이미 classpath 에 있는 경우 — e.g. `spring-web` — 는 문제 없음) 3. **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 참조 가능. ## 재발 방지 - `testCompileOnly` dependency 의 fixture 에서 type 을 참조할 때는 annotation 참조 우선. - method/field 참조 시 해당 type 이 `testImplementation` 에 transitively 포함되는지 확인. - `extends` / `implements` 는 `testCompileOnly` type 에 절대 사용 금지. ## 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 로 두었다: ```java @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 하지 않는다: ```java @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 | ✗ |