# 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.