Files
clean-architecture-backend-…/docs/testing/TESTING_STRATEGY.md
T
DongHyeonkaandClaude Opus 5 ef947e5bb0 refactor(build,ci): 현재 상태 검증을 걷어내고 불변조건만 남기는 검증 표면 축소
외부 리뷰("현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를
다시 검증하는 구조까지 생겼다")를 설계 문서로 정리하고 코드로 반영한다.
설계·판단 근거는 docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md.

삭제
- .github/ci-gate-matrix.yml(1,025줄) + verify-gate-matrix.sh(568줄):
  Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제
- verify-gradle-wrapper.sh(799줄): workflow 바이트 해시 잠금.
  wrapper 검증은 gradle/actions/wrapper-validation(full SHA 핀)에 위임
- DeveloperExperienceContractTest 등의 CI YAML mutation 테스트:
  애플리케이션 test suite가 GitHub Actions YAML 파서를 검증하던 계층 역전
- 문서 drift 파서: verifyReadmeCommands, verifyRunbookReferences,
  verifyDocumentedLeafCount, verifyTestSourceSetRegistry
- 빈 레지스트리를 지키던 커스텀 YAML 파서: verifyTrivyignore,
  verifyQuarantineSunset, flaky-quarantine.yaml
- verifyConfigurationPropertiesProcessor, verifyOneTypePerFile:
  각각 ca.spring-config convention과 Checkstyle OneTopLevelClass가 대체
- 정상 입력으로도 성공할 수 없던 messaging always-fail task
- ModuleRegistry의 JSON 필드 집합 정확 일치, sample-portfolio negative guard

이동
- java/quality/spring 공통 설정을 configure(subprojects) 블록에서
  ca.java-conventions / ca.quality-conventions / ca.java-library /
  ca.spring-library convention plugin으로
- 아키텍처 검증을 ca.architecture로, JPA·messaging qualification을
  gradle/qualification/ 아래로, verifyEnvKeys를 :app-bootstrap 소유로

완화
- Git revision은 releaseCheck·아카이브 생성에서만 요구. 일반 빌드는 SNAPSHOT
- SpotBugs/FindSecBugs는 로컬 check에서 빼고 qualityCheck 레인으로

task 계층
- leaf check는 그 leaf만. architectureCheck / qualityCheck /
  configContractCheck / integrationCheck / ci / releaseCheck로 이름 분리

CI
- _reusable-gradle.yml 신규. checkout + wrapper validation + JDK/캐시 공통화
- fileserver-release.yml -> fileserver-certification.yml (CD가 아니라 certification)
- GitHub Actions = CI + artifact, Argo CD = CD 경계를 docs/ci-cd/boundary.md로 고정

순증감 +3,274 / -7,483.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:33:19 +09:00

180 lines
11 KiB
Markdown

# 테스트 전략 — 레벨 정의와 소스셋 매핑 (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 표 파서였고, `<!-- registry:begin -->` 마커가 사라지면 계약이 산문으로 되돌아가는 것을
막으려고 마커 존재 자체까지 검사했다). 레인을 추가하면 이 표도 같이 고친다. 아래 옛 설명은 표를
어떻게 읽어야 하는지에 대한 기준으로 남긴다: 표에 없는 소스셋을
추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다.
<!-- registry:begin -->
| 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 |
<!-- registry:end -->
`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` 를 썼다면 왜 필요한지
설명할 수 있어야 한다).