- 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>
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.mdBOOT-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:
- 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 thatwebTestkitis a testkit rather than a lane. - 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. - 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' existingcompilesAgainstexpresses this unchanged — a lane declarescompilesAgainst 'main', 'testFixtures'. The first draft of this ADR assumed the DSL would need a change, becausesourceSet(name)creates what it is given andtestFixturesalready exists. Thepersistence-mongomigration showed otherwise:compilesAgainstonly looks a source set up, so naming a plugin-created one works as-is. What the leaf drops is thesourceSet('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.
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 wholetestkitPublisherblock; keep every other lane'scompilesAgainst, renaming'testkit'to'testFixtures'; - rename
testkitImplementationtotestFixturesImplementation, and add what the old source set was inheriting silently. This is the one non-mechanical step:testkit*extendedtestImplementation, 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.
httpclient, thenwebsocket— unpublished as well, more lanes.webandpersistence-jpawithapp-bootstrap's two consumer declarations, which becametestImplementation(testFixtures(project(':…'))).ca.testkit-publisherdeleted, along with itsplugins {}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.