123 lines
6.0 KiB
Markdown
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 | ✗ |
|