refactor(build,src): testkit 소스셋 이관과 빌드 게이트 정상화, 미추적 빌드 파일 추적
한 커밋인 이유: src/build.gradle 안에서 ca.testkit-publisher 플러그인 제거와
게이트 수정이 얽혀 있다. 플러그인 적용부만 빼면 web·websocket·persistence-jpa·
persistence-mongo·app-bootstrap 이 사라진 testkitPublisher() 와 *Testkit
컨피규레이션을 계속 참조해 설정 단계에서 빌드가 죽는다. 파일 단위로 나눌 수 없다.
1) testkit 소스셋 → Gradle 표준 java-test-fixtures 이관
web, websocket, persistence-jpa, persistence-mongo, httpclient, graphql 과
이들의 testkit 컨피규레이션을 소비하던 app-bootstrap.
자체 제작 ca.testkit-publisher.gradle 77줄이 사라진다.
2) 실행되지 않거나 실패할 수 없던 빌드 게이트 정상화 (E등급)
- strict-test-lane 의 실행 카운터가 skip 을 실행으로 세던 것 수정.
전부 skip 인 레인은 이제 실패한다 (회귀 테스트 2건 추가)
- public-path 스냅샷이 gitignore 된 src/.env 를 읽던 것을
config/security.yml 의 바인딩 기본값으로 교체
- verifyEnvKeys 가 build/ 산출물을 소스로 읽어 삭제된 키를 사용 중으로
오판하던 것 수정 (입력 4,637 → 4,630 파일)
- jpa-evidence 가 git 실패를 "워크트리 깨끗함"으로 읽던 것을 fail-closed 로
- notification-evidence 의 Grade 열 탐지를 헤더 기준으로 교체 +
표 부재 시 fail-closed
- spring70CompatibilityTest 가 레인을 복제하며 잃은 fail-closed 복구
(태스크명 유지 — 워크플로 3곳과 gate-matrix 린트 무손상)
- 메시징 R2 스켈레톤 주변의 도달 불가 검증 45줄을 MSG-015 명시적 실패로 교체
3) git 에 없던 빌드 필수 파일 추적
- src/gradle/libs.versions.toml — src/build.gradle 이 9곳에서 참조하는데
추적되지 않아 깨끗한 체크아웃에서 설정이 실패했다
- app-bootstrap config/*.yml 15개 — application.yml 이 전부 import 한다.
하드코딩된 시크릿은 없고 값은 secret://environment/APP_* 참조다
4) 진행 중이던 구현 작업 반영 (redis/idempotency 구성, startup 검증,
아키텍처 테스트 클래스, notification 콜백 레지스트리 등)
검증:
- 깨끗한 체크아웃에서 ./gradlew help 통과
- :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
→ 20개 클래스 174 tests, 실패 0, 스킵 0 (이전에는 0개 실행)
미해결: verifyOneTypePerFile 은 손대지 않았다(Checkstyle 로 교체 권고).
B/C/D 등급 100여 건과 CI 단계 분리는 별도 작업 —
docs/superpowers/plans/2026-09-16-ci-stage-separation.md 참고.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e34519113b
commit
1535481794
@@ -8,8 +8,8 @@ dependencies {
|
||||
// TestKit needs the Gradle API of the running distribution, which `groovy-gradle-plugin` already
|
||||
// puts on the main source set; the test source set asks for it explicitly.
|
||||
testImplementation gradleTestKit()
|
||||
testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.3'
|
||||
testImplementation libs.spock.core.groovy4
|
||||
testImplementation libs.junit.jupiter
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,20 @@ dependencyResolutionManagement {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
|
||||
// The main build's version catalog, read as a file rather than inherited — an included build
|
||||
// does not see its parent's catalog.
|
||||
//
|
||||
// This is not the dependency the note above warns against. It reads a table of versions, not the
|
||||
// build those versions configure: nothing here is evaluated, no project is resolved, and the two
|
||||
// coordinates this build actually uses (Spock, JUnit) are the same two the leaves use. Pinning
|
||||
// them separately is how build-logic's Spock and the leaves' Spock would drift apart without
|
||||
// anybody noticing, which is the exact failure the catalog exists to make visible.
|
||||
versionCatalogs {
|
||||
libs {
|
||||
from(files('../gradle/libs.versions.toml'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'build-logic'
|
||||
|
||||
@@ -243,10 +243,24 @@ strictTestLanes.lanes.all { StrictTestLaneSpec lane ->
|
||||
// flag reads as if it prevented.
|
||||
//
|
||||
// So the lane counts what it actually executed and refuses zero.
|
||||
//
|
||||
// "Executed" excludes skips, and that distinction is the whole value of the counter.
|
||||
// Gradle reports a skipped test through this same `afterTest` listener, so counting every
|
||||
// callback counted `@Disabled` methods and unmet `Assumptions` as executions — a lane whose
|
||||
// every test was disabled or assumed away incremented the counter and passed the
|
||||
// zero-execution check below, which is the same hollow green the check exists to refuse.
|
||||
// The rest of this repository already treats a skip as a non-result: JUnitEvidence keeps a
|
||||
// skipped case out of the executed-class set, and ca.strict-qualification rejects skips
|
||||
// outright.
|
||||
def executedTests = new java.util.concurrent.atomic.AtomicLong(0L)
|
||||
def skippedTests = new java.util.concurrent.atomic.AtomicLong(0L)
|
||||
def executedSelectors =
|
||||
java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
|
||||
afterTest { descriptor, result ->
|
||||
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
|
||||
skippedTests.incrementAndGet()
|
||||
return
|
||||
}
|
||||
executedTests.incrementAndGet()
|
||||
if (!lane.requiredTests.isEmpty()) {
|
||||
executedSelectors.add(descriptor.className as String)
|
||||
@@ -255,13 +269,21 @@ strictTestLanes.lanes.all { StrictTestLaneSpec lane ->
|
||||
}
|
||||
doLast {
|
||||
if (executedTests.get() == 0L) {
|
||||
// Two different faults produce zero executions and they need different messages:
|
||||
// a selection that matched nothing, and a selection that matched tests which then
|
||||
// all skipped. Saying "its tag matches nothing" about the second would send the
|
||||
// reader to rename a tag that is in fact correct.
|
||||
throw new GradleException(
|
||||
"strict test lane '${lane.name}' in ${owningProjectPath} executed no test" +
|
||||
(lane.tag?.trim()
|
||||
? "; its tag '${lane.tag}' matches nothing in source set '${lane.sourceSet}'"
|
||||
: (lane.requiredTests.isEmpty()
|
||||
? " in source set '${lane.sourceSet}'"
|
||||
: "; its required tests ${lane.requiredTests} matched no executable test")) +
|
||||
(skippedTests.get() > 0L
|
||||
? "; all ${skippedTests.get()} test(s) it selected were " +
|
||||
"skipped (@Disabled or an unmet assumption), and a skip " +
|
||||
"is not a result"
|
||||
: (lane.tag?.trim()
|
||||
? "; its tag '${lane.tag}' matches nothing in source set '${lane.sourceSet}'"
|
||||
: (lane.requiredTests.isEmpty()
|
||||
? " in source set '${lane.sourceSet}'"
|
||||
: "; its required tests ${lane.requiredTests} matched no executable test"))) +
|
||||
" — a lane that runs nothing reports success for whatever it was " +
|
||||
"meant to prove.")
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// A leaf's testkit: its own source set, and optionally a consumable artifact.
|
||||
//
|
||||
// Four leaves declare a testkit and each spells out the same wiring — a source set with main on its
|
||||
// compile classpath, two configurations extending the test ones, and the testkit output added to
|
||||
// every lane that consumes it. The copies already differ in a way worth noticing: the JPA leaf
|
||||
// publishes its testkit as a consumable artifact and the Mongo leaf does not, which is a real
|
||||
// difference in what each leaf offers rather than an oversight to normalise away.
|
||||
//
|
||||
// So publishing is opt-in. A leaf that only wants the source set declares only that, and gains no
|
||||
// task it did not have:
|
||||
//
|
||||
// testkitPublisher {
|
||||
// consumedBy 'test', 'postgresqlIntegrationTest'
|
||||
// publishAs 'jpaTestkit' // omit and nothing is published
|
||||
// }
|
||||
|
||||
class TestkitPublisherExtension {
|
||||
|
||||
/** Source set name. Its sources live in src/<name>/java. */
|
||||
String sourceSetName = 'testkit'
|
||||
|
||||
/** Source sets that compile and run against the testkit. */
|
||||
final List<String> consumers = []
|
||||
|
||||
/** Consumable configuration name, or null when this leaf publishes nothing. */
|
||||
String consumableConfiguration
|
||||
|
||||
/** Names the source sets that compile against the testkit. */
|
||||
void consumedBy(String... names) {
|
||||
consumers.addAll(names as List)
|
||||
}
|
||||
|
||||
/** Publishes the testkit as a consumable artifact under this configuration name. */
|
||||
void publishAs(String configurationName) {
|
||||
this.consumableConfiguration = configurationName
|
||||
}
|
||||
}
|
||||
|
||||
def testkitPublisher = extensions.create('testkitPublisher', TestkitPublisherExtension)
|
||||
|
||||
project.afterEvaluate {
|
||||
def testkit = project.sourceSets.findByName(testkitPublisher.sourceSetName)
|
||||
if (testkit == null) {
|
||||
// Not a leaf with a testkit. The convention is applied everywhere and configured by few.
|
||||
return
|
||||
}
|
||||
|
||||
// Every consumer compiles and runs against the testkit output. Declared per leaf because which
|
||||
// lanes consume a testkit is a leaf's decision, not a convention's.
|
||||
testkitPublisher.consumers.each { String consumerName ->
|
||||
def consumer = project.sourceSets.findByName(consumerName)
|
||||
if (consumer == null) {
|
||||
throw new GradleException(
|
||||
"${project.path} testkitPublisher names consumer source set '${consumerName}', " +
|
||||
"which does not exist")
|
||||
}
|
||||
consumer.compileClasspath += testkit.output
|
||||
consumer.runtimeClasspath += testkit.output
|
||||
}
|
||||
|
||||
if (testkitPublisher.consumableConfiguration == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Publishing is what makes a testkit usable from the composition root, which is the only place
|
||||
// that can see every runtime leaf at once — and therefore the only place an architecture rule
|
||||
// pack can be applied to the production graph rather than to its own fixtures.
|
||||
def testkitJar = tasks.register('testkitJar', Jar) {
|
||||
archiveClassifier = 'testkit'
|
||||
from testkit.output
|
||||
}
|
||||
configurations.create(testkitPublisher.consumableConfiguration) {
|
||||
canBeConsumed = true
|
||||
canBeResolved = false
|
||||
}
|
||||
artifacts.add(testkitPublisher.consumableConfiguration, testkitJar)
|
||||
}
|
||||
@@ -156,6 +156,100 @@ class StrictTestLaneConventionTest {
|
||||
"a tag matching nothing must fail:" + System.lineSeparator() + result.output)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a lane whose every test is skipped fails rather than counting the skips as runs")
|
||||
void aLaneOfNothingButSkipsFails() {
|
||||
// The third way a lane goes hollow, and the one neither guard above catches: the tests are
|
||||
// discovered, the task runs, and every one of them is `@Disabled` or assumed away. Gradle
|
||||
// reports a skipped test through the same `afterTest` listener the lane counts with, so the
|
||||
// counter used to read two skips as two executions and pass — green for a broker, a
|
||||
// datastore or a protocol nobody exercised, which is the precise failure the counter exists
|
||||
// to refuse.
|
||||
buildFile("""
|
||||
dependencies {
|
||||
testImplementation platform('org.junit:junit-bom:5.11.3')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
strictTestLanes {
|
||||
lane('skippedLane') {
|
||||
tag = 'carried'
|
||||
description = 'Every test it selects is skipped.'
|
||||
}
|
||||
}
|
||||
""")
|
||||
Path testSource = projectDir.resolve('src/test/java')
|
||||
Files.createDirectories(testSource)
|
||||
Files.writeString(testSource.resolve('SkippedTest.java'), """
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SkippedTest {
|
||||
@Test
|
||||
@Tag("carried")
|
||||
@Disabled("quarantined")
|
||||
void disabled() {}
|
||||
|
||||
@Test
|
||||
@Tag("carried")
|
||||
void assumedAway() { Assumptions.assumeTrue(false, "no docker here"); }
|
||||
}
|
||||
""".stripIndent())
|
||||
|
||||
def result = runner('skippedLane').buildAndFail()
|
||||
|
||||
assertTrue(result.output.contains('executed no test') && result.output.contains('skipped'),
|
||||
"a lane of nothing but skips must fail and say so:"
|
||||
+ System.lineSeparator() + result.output)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a lane still passes when some of its tests are skipped but one actually ran")
|
||||
void aLaneWithOneRealExecutionPasses() {
|
||||
// The other side of the line the counter draws. Refusing every skip would be a different
|
||||
// policy — ca.strict-qualification's, for lanes whose output is evidence — and imposing it
|
||||
// here would fail every lane that carries one conditional test. Zero executions is the
|
||||
// failure; a skip alongside a run is not.
|
||||
buildFile("""
|
||||
dependencies {
|
||||
testImplementation platform('org.junit:junit-bom:5.11.3')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
strictTestLanes {
|
||||
lane('mixedLane') {
|
||||
tag = 'carried'
|
||||
description = 'One test runs, one is skipped.'
|
||||
}
|
||||
}
|
||||
""")
|
||||
Path testSource = projectDir.resolve('src/test/java')
|
||||
Files.createDirectories(testSource)
|
||||
Files.writeString(testSource.resolve('MixedTest.java'), """
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MixedTest {
|
||||
@Test
|
||||
@Tag("carried")
|
||||
@Disabled("quarantined")
|
||||
void disabled() {}
|
||||
|
||||
@Test
|
||||
@Tag("carried")
|
||||
void ran() {}
|
||||
}
|
||||
""".stripIndent())
|
||||
|
||||
def result = runner('mixedLane').build()
|
||||
|
||||
assertTrue(result.output.contains('BUILD SUCCESSFUL'),
|
||||
"one real execution is enough:" + System.lineSeparator() + result.output)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a lane over the shared test source set must name a tag")
|
||||
void aSharedSourceSetLaneMustNameATag() {
|
||||
|
||||
Reference in New Issue
Block a user