# 테스트 전략 — 레벨 정의와 소스셋 매핑 (SSOT) - 기준 일자: 2026-09-07 - 상태: **활성 문서.** 아래 §3 표는 사람이 유지한다. `verifyTestSourceSetRegistry` 가 이 문서의 §3 표와 실제 Gradle 소스셋 선언의 불일치를 빌드 실패로 만든다. - 근거 리뷰: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` (BOOT-014, BOOT-015, BOOT-016) ## 1. 이 문서가 존재하는 이유 이 저장소는 이미 테스트 레벨 계약을 **기계로** 강제하고 있었다 — `TestTaxonomyArchitectureTest` 가 contract/architecture 트리의 Testcontainers 의존을 금지하고, slice 애노테이션 혼합을 막고, fixture 유출을 잡는다. 없던 것은 **사람이 읽을 수 있는 정의**였다. 그 결과 계약은 "패키지 이름"에만 걸려 있었고 "어느 소스셋이 컴파일하는가"에는 걸려 있지 않았다. Testcontainers 를 쓰는 통합 테스트 9개가 `app-bootstrap/src/test` 안에 있었고, `@Testcontainers` 5개 중 가드가 있는 것은 하나뿐이었다. 즉 `./gradlew :app-bootstrap:test` — 이 저장소가 leaf 별 기본 명령으로 권장하는 바로 그 명령 — 이 Docker 데몬을 요구했다 (BOOT-014). 그래서 규칙을 두 가지 방식으로 동시에 고정한다. 사람은 이 문서를 읽고, 빌드는 §3 표를 읽는다. ## 2. 레벨 정의 레벨은 **이름이 아니라 "어디까지 실제로 붙여서 검증하는가"**로 정의한다. `smoke`, `regression`, `acceptance` 같은 말은 범위가 아니라 목적이라 레벨이 될 수 없다 — 하나의 E2E 테스트가 동시에 smoke 이고 regression 일 수 있다. | 레벨 | 무엇을 검증 | 외부 시스템 | 소스셋 | | --- | --- | --- | --- | | **unit** | 클래스·함수·도메인 규칙 | 없음 | `src/test` | | **slice** | 프레임워크 한 계층 | 인메모리/모의 | `src/test` | | **contract** | 모듈 경계의 형태와 약속 | 없음 | `src/test` | | **architecture** | 코드 의존 관계, 테스트 분류 자체 | 없음 | `src/test` | | **integration** | 실제 인프라와의 연결 | 실제 DB/브로커/스토리지 | `src/integrationTest` 또는 leaf 전용 레인 | | **qualification** | 벤더·프로토콜·배포 형상 | 실제 벤더 런타임 | leaf 전용 레인 | | **build-qualification** | 빌드·조립 계약 자체 | 없음 (별도 클래스패스) | leaf 전용 레인 | | **performance** | 지연·처리량 | 실제에 가까움 | leaf 전용 레인 | ### 2.1 소스셋을 나누는 기준은 하나다 > **테스트 코드는 production 패키지 구조를 그대로 미러링한다. 별도 소스셋으로 분리하는 것은 > 실행 환경·의존성·클래스패스가 달라지는 경우뿐이다.** `unit/`, `service/`, `repository/`, `regression/` 같은 폴더는 만들지 않는다. 서로 다른 분류 축을 한 디렉터리에 섞으면 `UserServiceTest` 가 어디에 속하는지 아무도 답할 수 없게 된다. 이 저장소의 `src/test` 는 이미 production 패키지를 미러링하고 있으며 그 상태를 유지한다. ### 2.2 build-qualification 은 폴더 취향이 아니다 `app-bootstrap` 의 세 레인은 "테스트를 분류하려고" 나눈 것이 아니라 **하나의 소스셋으로 표현할 수 없는 클래스패스 차이** 때문에 존재한다. 합치면 검증 자체가 성립하지 않는다. - `sampleOffTest` — `src/test` 와 **같은 소스 파일**을 `sample-portfolio` 없는 클래스패스로 다시 컴파일한다. "샘플을 지워도 템플릿이 성립하는가"의 증명이며, 같은 파일을 두 클래스패스로 컴파일하는 것이 그 정의다. - `conditionalTransportTest` — GraphQL/gRPC/WebSocket 을 **테스트 전용으로만** 클래스패스에 올린다. 이 의존을 `testImplementation` 으로 옮기면 "기본 클래스패스에는 없다"는 증명 대상 명제가 그 순간 거짓이 된다. - `functionalTest` — Gradle TestKit 이 별도 Gradle 빌드를 띄운다. ## 3. 소스셋 레지스트리 (기계 검증 대상) 이 표를 읽어 실제 `sourceSets` 선언과 대조하던 `verifyTestSourceSetRegistry` 는 2026-09에 삭제했다 (Markdown 표 파서였고, `` 마커가 사라지면 계약이 산문으로 되돌아가는 것을 막으려고 마커 존재 자체까지 검사했다). 레인을 추가하면 이 표도 같이 고친다. 아래 옛 설명은 표를 어떻게 읽어야 하는지에 대한 기준으로 남긴다: 표에 없는 소스셋을 추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다. | Gradle 경로 | 소스셋 | 레벨 | | --- | --- | --- | | `:adapter:inbound:graphql` | `testFixtures` | fixtures | | `:adapter:inbound:web` | `jettyCompatTest` | qualification | | `:adapter:inbound:web` | `nginxProxyTest` | qualification | | `:adapter:inbound:web` | `testFixtures` | fixtures | | `:adapter:inbound:web` | `webfluxContractTest` | qualification | | `:adapter:inbound:websocket` | `jettyWebSocketTest` | qualification | | `:adapter:inbound:websocket` | `nginxWebSocketTest` | qualification | | `:adapter:inbound:websocket` | `testFixtures` | fixtures | | `:adapter:outbound:httpclient` | `httpClientPerformanceTest` | performance | | `:adapter:outbound:httpclient` | `jmh` | performance | | `:adapter:outbound:httpclient` | `testFixtures` | fixtures | | `:adapter:outbound:objectstorage` | `objectStorageAwsQualificationTest` | qualification | | `:adapter:outbound:objectstorage` | `objectStorageMinioContractTest` | integration | | `:adapter:outbound:objectstorage` | `objectStorageMinioFaultTest` | integration | | `:adapter:outbound:persistence-jpa` | `jpaPlatformPerformanceTest` | performance | | `:adapter:outbound:persistence-jpa` | `postgresqlIntegrationTest` | integration | | `:adapter:outbound:persistence-jpa` | `testFixtures` | fixtures | | `:adapter:outbound:persistence-mongo` | `mongoPerformanceTest` | performance | | `:adapter:outbound:persistence-mongo` | `testFixtures` | fixtures | | `:app-bootstrap` | `conditionalTransportTest` | build-qualification | | `:app-bootstrap` | `functionalTest` | build-qualification | | `:app-bootstrap` | `integrationTest` | integration | | `:app-bootstrap` | `sampleOffTest` | build-qualification | | `:messaging:messaging-kafka` | `jmh` | performance | | `:messaging:messaging-rabbit` | `jmh` | performance | | `:messaging:messaging-testkit` | `jmh` | performance | | `:sample-portfolio` | `posterImageMigrationTest` | qualification | | `:shared-contract` | `edgeRateLimitContractTest` | contract | `src/test` 는 모든 leaf 가 갖는 기본 소스셋이므로 표에 적지 않는다. ## 4. 판단표 — 새 테스트를 어디에 쓰는가 대상 코드가 정해지면 위치와 방식이 기계적으로 결정되어야 한다. | 대상 | 레벨 | 협력자 | 위치 | | --- | --- | --- | --- | | 도메인 엔티티·값 객체 | unit | 없음 | 해당 leaf `src/test` | | 유스케이스 | unit | 손으로 만든 Fake (Mockito 아님) | `application-core/src/test` | | 시작 검증기 (`*Validator`) | unit | `MockEnvironment` | `app-bootstrap/src/test` | | `@Configuration` 조립 | slice | `ApplicationContextRunner` | `app-bootstrap/src/test` | | 컨트롤러 | slice | `@WebMvcTest` + 모의 유스케이스 | `adapter/inbound/web/src/test` | | JPA 리포지토리 매핑 | integration | Testcontainers PostgreSQL | `postgresqlIntegrationTest` | | 아웃박스·멱등성 행 수명주기 | integration | Testcontainers PostgreSQL | `app-bootstrap/src/integrationTest` | | 브로커 발행/수신 | integration | 실제 브로커 | leaf 전용 레인 | | 에러 응답 스키마 | contract | 없음 (스냅샷) | `app-bootstrap/src/test/.../contract` | | 의존 방향·패키지 경계 | architecture | 없음 (ArchUnit) | `app-bootstrap/src/test/.../architecture` | ### 4.1 금지 - `src/test` 안에서 `org.testcontainers` 의존 — `TestTaxonomyArchitectureTest` 가 막는다 (BOOT-014). - 필요 없는 `@SpringBootTest`. 조립을 검증할 것이 아니면 `ApplicationContextRunner` 나 순수 단위 테스트로 충분하다. - slice 애노테이션 혼합 (`@WebMvcTest` + `@DataJpaTest`) — Spring 이 지원하지 않는다. - production 코드가 test fixture 에 의존하는 것. - 픽스처를 `TestUtil`·`CommonUtil` 같은 이름으로 묶는 것. 역할을 드러내는 이름 (`fixture/`, `fake/`, `container/`, `assertion/`) 을 쓴다. ## 5. 공용 테스트 지원 코드 — `testFixtures` **표준은 `java-test-fixtures` 하나다 (ADR-BUILD-001).** 공용 테스트 지원 코드는 `src/testFixtures/java` 에 두고, 다른 leaf 는 `testFixtures(project(':x'))` 로 소비한다. 두 관례가 공존하던 상태(BOOT-015)는 해소됐다. `ca.testkit-publisher` 컨벤션 플러그인과 그것을 쓰던 `testkit` 소스셋 5개는 모두 이관됐고, 플러그인 자체도 제거됐다. 이관하면서 드러난 사실 하나는 기록해 둘 값어치가 있다: `testkit*` 구성이 `testImplementation` 을 상속했기 때문에 fixture 들은 각 leaf 가 선언한 모든 테스트 라이브러리를 **말없이** 보고 있었다. `testFixturesImplementation` 으로 옮기면서 그 표면이 드러났고, 다섯 leaf 에서 도합 30개가 넘는 의존을 명시적으로 적어야 했다. `test` 가 아닌 lane 은 fixture 를 소비한다고 선언해야 한다 — `java-test-fixtures` 는 `test` 만 자동으로 배선한다: ```groovy strictTestLanes { sourceSet('postgresqlIntegrationTest') { compilesAgainst 'main', 'testFixtures' } } ``` 디렉터리는 역할을 드러내는 형태를 권고한다 (`fixture/`, `fake/`, `container/`, `assertion/`). `TestUtil`·`CommonUtil` 같은 무의미한 이름 묶음은 금지한다. ## 6. CI 단계 매핑 폴더만 나누고 CI 에서 한꺼번에 돌리면 분리의 의미가 없다. ``` 커밋 / IDE → unit · slice · contract · architecture (`test`) Pull Request → + integration (integration 레인) 머지 / 스테이징 → + build-qualification (functionalTest, sampleOffTest, conditionalTransportTest) 야간 / 스케줄 → + qualification · performance ``` `check` 에는 인프라 레인을 붙이지 않는다. 이것은 이 저장소가 이미 따르고 있는 관례이며 (`persistence-jpa` 의 `postgresqlIntegrationTest` 도 `check` 에 붙어 있지 않다), Docker 없는 환경에서 `check` 가 실패하지 않게 하는 유일한 방법이다. ## 7. LLM 에이전트에게 적용할 때 이 저장소는 에이전트 협업을 전제로 설계되어 있다. 테스트 생성을 맡길 때는 다음 순서를 강제한다. 1. 이 테스트가 §2 의 어느 레벨인지 판정하고 근거를 적는다. 2. §3 표에서 해당 소스셋을 찾는다. 3. 이미 존재하는 fixture 를 먼저 검색한다. 4. 테스트를 작성한다. 5. 판정한 레벨보다 큰 레벨로 작성하지 않았는지 확인한다 (`@SpringBootTest` 를 썼다면 왜 필요한지 설명할 수 있어야 한다).