- 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>
120 lines
7.1 KiB
Markdown
120 lines
7.1 KiB
Markdown
# 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.
|