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

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
archunit
gradle
testCompileOnly
fixture
NoClassDefFoundError
2026-06-02

ArchUnit fixture + testCompileOnly — NoClassDefFoundError at JUnit load time

Parent

현상

SpringWebSocketHandlerFixtureTextWebSocketHandler 를 extends 하도록 작성. build.gradletestCompileOnly '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 로드 성공.
@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, OnMessagejakarta.websocket.* base 패키지 클래스 없음. @ServerEndpointjakarta.websocket.server 에 있어서 annotation-only 참조 가능.

재발 방지

  • testCompileOnly dependency 의 fixture 에서 type 을 참조할 때는 annotation 참조 우선.
  • method/field 참조 시 해당 type 이 testImplementation 에 transitively 포함되는지 확인.
  • extends / implementstestCompileOnly 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 로 두었다:

@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() 류)가 이를 즉시 resolvetestCompileOnly 라 런타임 부재 → 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