Files
llm-wiki/raw/errors/archunit-testcompileonly-class-loading-2026-06-02.md
T

123 lines
6.0 KiB
Markdown

---
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 | ✗ |