Files
clean-architecture-backend-…/docs/adr/ADR-BUILD-001-one-convention-for-shared-test-code.md
T
DongHyeonkaandClaude Opus 5 2a8d34f85c docs: 빌드·CI 감사 계획과 진행 중이던 문서 정리
- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md).
  빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다.
- public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성.
  이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다.
- 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거.

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

7.1 KiB

ADR-BUILD-001: java-test-fixtures is the standard for shared test code

  • Status: Accepted
  • Date: 2026-09-07
  • Scope: every leaf that publishes or consumes shared test code
  • Source: docs/reviews/2026-09-07-app-bootstrap-module-code-review.md BOOT-015

Context

Two conventions do the same job in this repository.

ca.testkit-publisher — a convention plugin — gives a leaf a testkit source set, wires its output onto the lanes that leaf names, and optionally publishes it as a consumable configuration. Five leaves use it: persistence-jpa (published as jpaTestkit), web (webTestkit), websocket (websocketTestkit), persistence-mongo and httpclient (both unpublished).

java-test-fixtures — Gradle's own plugin — gives a leaf a testFixtures source set, puts it on test's classpath automatically, and always publishes it as a variant consumers reach with testFixtures(project(':x')). One leaf uses it: graphql, which additionally fails its build when a fixture is written outside src/testFixtures/java.

Two conventions for one purpose is the defect. A contributor adding shared test code has to know which leaf they are in before they know where the file goes, and the two answers are not interchangeable: a consumer of the first writes project(path: ':x', configuration: 'jpaTestkit') and has to know the configuration's name, while a consumer of the second writes testFixtures(project(':x')) and does not.

Decision

java-test-fixtures is the standard. New shared test code goes in src/testFixtures/java, and a consumer depends on it with testFixtures(project(':x')).

Three reasons, in order of weight:

  1. The consumer side describes itself. testFixtures(project(':adapter:inbound:web')) says what it is. project(path: ':adapter:inbound:web', configuration: 'webTestkit') says where to look, and only after the reader has learned that webTestkit is a testkit rather than a lane.
  2. The enforcement already exists and is copyable. graphql's build fails when a fixture is declared in the wrong place. The same guard applies unchanged to any leaf that adopts the plugin.
  3. It is one fewer local concept. A convention plugin that reimplements a Gradle plugin has to be maintained against it.

What the local plugin does better, and how it is replaced

This is worth writing down, because the review that prompted this ADR recommended the migration before reading ca.testkit-publisher, and the plugin turns out to encode two deliberate decisions rather than being an oversight.

Publishing is opt-in. persistence-mongo and httpclient have a testkit and publish nothing; persistence-jpa publishes. The plugin's own comment names this as "a real difference in what each leaf offers rather than an oversight to normalise away". java-test-fixtures always creates the variant, so the distinction is lost — a leaf that never meant to offer its fixtures will offer them.

Replacement: none at the build level. The distinction moves to review: the fixtures of a leaf that nobody consumes are simply unconsumed. This is a real, accepted loss.

Lane consumption is declared. persistence-jpa says consumedBy 'test', 'postgresqlIntegrationTest'. java-test-fixtures puts fixtures on test only, so every other lane needs the output added explicitly.

Replacement: strictTestLanes' existing compilesAgainst expresses this unchanged — a lane declares compilesAgainst 'main', 'testFixtures'. The first draft of this ADR assumed the DSL would need a change, because sourceSet(name) creates what it is given and testFixtures already exists. The persistence-mongo migration showed otherwise: compilesAgainst only looks a source set up, so naming a plugin-created one works as-is. What the leaf drops is the sourceSet('testkit') declaration, not the lane's.

Migration: done, and what it cost

Five leaves, eleven lanes, two published testkits, all migrated leaf by leaf with the suite run between each. ca.testkit-publisher is deleted.

The order was chosen so a mistake would be cheap: unpublished leaves first, published ones last with their consumer in the same step.

  1. persistence-mongo — one leaf, two lanes, no cross-module consumer; the proof the path works. What it took, per leaf:
    • apply plugin: 'java-test-fixtures' at the top of the leaf build file;
    • git mv src/testkit src/testFixtures;
    • drop sourceSet('testkit') and the whole testkitPublisher block; keep every other lane's compilesAgainst, renaming 'testkit' to 'testFixtures';
    • rename testkitImplementation to testFixturesImplementation, and add what the old source set was inheriting silently. This is the one non-mechanical step: testkit* extended testImplementation, so the fixtures saw every test library the leaf declared. Mongo's needed four more lines (AssertJ, BSON, Spring Data commons, Toxiproxy) — none of which the leaf had ever stated the fixtures depended on;
    • regenerate the leaf's lock state.
  2. httpclient, then websocket — unpublished as well, more lanes.
  3. web and persistence-jpa with app-bootstrap's two consumer declarations, which became testImplementation(testFixtures(project(':…'))).
  4. ca.testkit-publisher deleted, along with its plugins {} entry and its application in the root build.

Two things the migration broke, and what they taught

Both were caught by tests that exist to catch exactly this, which is the argument for having them.

ArchUnit corpora went wrong in opposite directions. httpclient's boundary rules excluded build/classes/java/testkit; after the move the fixtures arrived as a …-test-fixtures.jar on the same classpath, so the exclusion missed them and 258 fixture-to-fixture calls were reported as production depending on the testkit. persistence-jpa's rules included only build/classes/java/main; applying java-test-fixtures makes the module's own test classpath carry the module as a jar rather than as a class directory, so its corpus became empty. The second is the dangerous one — an empty corpus makes every noClasses() rule pass — and it surfaced only because that suite asserts its corpus is non-empty before asserting anything about it.

Fixtures had invisible dependencies. testkit* configurations extended testImplementation, so the fixtures compiled against every test library their leaf declared without ever naming one. Making them explicit took roughly thirty testFixturesImplementation lines across the five leaves — Micrometer, Spring Web, Netty, logback, Jackson, JUnit, AssertJ, Spring Data. None of them were wrong; none of them were stated.

Consequences

  • docs/testing/TESTING_STRATEGY.md §5 records the standard; this ADR records why and at what cost.
  • Until step 5, two conventions remain visible. The strategy document says so explicitly, so a contributor reading it is not left to infer which one is current.
  • The opt-in-publishing distinction is given up. If it later proves load-bearing — a leaf whose fixtures genuinely must not be reachable — the answer is a separate module, not a third convention.