From cebcf93248aea170cce1d078694686e5a250071f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 5 Sep 2026 03:27:59 +0900 Subject: [PATCH] =?UTF-8?q?test(platform):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=86=8C=EC=8A=A4=EC=9D=98=20data=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=B0=B8=EC=A1=B0=EB=A5=BC=20konsist=20?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=89=EB=8A=94=EB=8B=A4=20(#1898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1432 가 «test 소스 확장은 #930 이후로» 미뤄 둔 2차. #930 은 닫혔고 마지막 남은 경계 넘는 직접 조립도 PR #1595 에서 사라져 규칙을 켜면 바로 초록이다. PresentationLayerDependencyKonsistTest 에 «테스트 소스도 다른 모듈의 data 구현을 직접 참조하지 않는다» 를 더한다 — core:data·core:datastore 자신의 테스트만 빼고, 나머지 모듈의 test·androidTest·testFixtures 가 core.data.repoimpl·core.datastore 를 import 하면 위반이다. 테스트가 구현을 직접 조립하면 그 구현의 생성자·visibility·DI 조립이 테스트 사정에 묶인다 — PR #1595 첫 리비전이 그렇게 UserRepositoryImpl 을 public·수동 조립으로 남겼다. AfternoteKonsistScope 에 testFiles 를 두고, docs/convention/production-visibility.md 에 «프로덕션은 테스트에 맞추지 않는다» 절을 적는다. 변이 실측: setting 테스트에 UserRepositoryImpl import 를 심으면 이 규칙만 빨개지고 나머지 두 규칙은 초록이다. --- docs/convention/production-visibility.md | 16 ++++++ .../konsist/AfternoteKonsistScope.kt | 3 ++ .../PresentationLayerDependencyKonsistTest.kt | 54 +++++++++++++++++-- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/convention/production-visibility.md b/docs/convention/production-visibility.md index 3ec1e9ee05..363f16243b 100644 --- a/docs/convention/production-visibility.md +++ b/docs/convention/production-visibility.md @@ -84,3 +84,19 @@ strict가 그대로 적용된다. 전환 PR에서는 warning 개수만 줄이는 것이 아니라 각 선언의 실제 프로덕션 사용처를 확인한다. 테스트가 직접 참조하던 선언은 공개 범위를 명시하는 대신 테스트를 공개 동작 기준으로 옮긴다. + +## 프로덕션은 테스트에 맞추지 않는다 + +생성자 시그니처·visibility·DI 조립 방식은 프로덕션의 요구로만 정한다. «테스트가 이렇게 쓰고 있어서» 는 +근거가 아니다 — 설계 판단에 그 문장이 나오면 프로덕션을 올바른 모양으로 바꾸고 테스트를 따라오게 한다. +못 따라오는 테스트는 그 층에 맞게 다시 쓴다: 같은 모듈이면 구현을 조립하고, 다른 모듈이면 `testFixtures` +의 Fake 나 Hilt 그래프를 쓴다. PR 본문·KDoc 에 «테스트가 … 라서 그대로 둔다» 류 문장이 나오면 설계가 +틀린 신호다. + +`PresentationLayerDependencyKonsistTest` 가 다른 모듈의 `test`·`androidTest`·`testFixtures` 소스가 +`core.data.repoimpl`·`core.datastore` 를 import 하는 것을 막는다(#1898). PR #1595 첫 리비전이 +`app` androidTest·`feature:setting` 테스트의 직접 조립을 이유로 `UserRepositoryImpl` 을 public·수동 +조립으로 남겼던 것이 이 규칙의 발단이다. 테스트만 참조하는 새 main 함수는 +`validate-test-only-production-declarations.mjs`(#1895)가, 모듈 안에서 테스트 때문에 넓어진 visibility 는 +위 `ProductionVisibilityKonsistTest` 가 막는다. + diff --git a/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt b/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt index 46e0283035..6dccf71d41 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/AfternoteKonsistScope.kt @@ -75,6 +75,9 @@ internal object AfternoteKonsistScope { /** [productionFiles] 와 같은 기준을 임의의 스코프에 적용한다. 재발 가드가 새로 읽은 스코프에 쓴다. */ fun productionFilesOf(target: KoScope): List = target.files.filterNot { it.sourceSetName.isTestSourceSet() } + /** 테스트 소스셋(test·androidTest·testFixtures·screenshotTest…)의 파일 — [productionFiles] 의 여집합. */ + val testFiles: List get() = scope.files.filter { it.sourceSetName.isTestSourceSet() } + /** 캐시를 타지 않고 지금의 [scanRoots] 를 다시 읽는다. 재발 가드([ScanScopeKonsistTest])가 쓴다. */ fun scanFresh(): KoScope = Konsist.scopeFromExternalDirectories(scanRoots().map { it.path }) diff --git a/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt index eab211e88d..80596178c4 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/PresentationLayerDependencyKonsistTest.kt @@ -1,5 +1,6 @@ package com.afternote.konsist +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration import com.lemonappdev.konsist.api.ext.list.withPackage import org.junit.Test @@ -15,10 +16,16 @@ import org.junit.Test * `core:datastore` 의존까지 지운 의도적 계층 이행이었는데 setting 만 빠졌다. 이후 아무 * 검사도 이를 알리지 않았다. * - * ### 검사 대상은 main 소스뿐이다 - * 테스트 소스는 `UserRepositoryImpl` 을 직접 조립하는 자리가 남아 있는데(#930 과 얽혀 - * `internal` 로 닫지 못하는 그 조립이다), 그건 프로덕션 계층 우회와 성격이 다르다. - * 한 번에 막으면 #930 이 선행돼야 하므로 1차는 프로덕션 소스로 한정한다. + * ### 테스트 소스도 본다 (#1898, #1432 의 2차) + * 1차는 프로덕션 소스로 한정했다 — 테스트 소스가 `UserRepositoryImpl` 을 직접 조립하는 자리가 남아 + * 있었고(#930 과 얽혀 `internal` 로 닫지 못하던 그 조립), 그것을 함께 막으려면 #930 이 선행돼야 했다. + * #930 이 닫히고 마지막 직접 조립도 PR #1595 에서 사라져 이제 테스트 소스에도 같은 규칙을 건다. + * + * 테스트가 다른 모듈의 구현을 직접 조립하면 그 구현의 생성자·visibility·DI 조립이 **테스트 사정에 + * 묶인다** — PR #1595 첫 리비전이 app androidTest·feature:setting 테스트의 직접 조립 때문에 + * `UserRepositoryImpl` 을 public·수동 조립으로 남겼던 것이 그 실사례다. 프로덕션은 테스트에 맞추지 + * 않는다. 테스트는 자기 층에서 한다 — 같은 모듈이면 구현을 조립하고, 다른 모듈이면 testFixtures 의 + * Fake 나 Hilt 그래프를 쓴다. 그래서 `core:data`·`core:datastore` **자신의** 테스트만 대상에서 뺀다. * * ### [KNOWN_LAYER_BYPASSES] * 도입 시점에 남아 있던 잔여. 수복은 담당 이슈 몫이고 이 가드는 「다음에 또 생겨도 모른다」 @@ -44,6 +51,23 @@ class PresentationLayerDependencyKonsistTest { } } + @Test + fun `테스트 소스도 다른 모듈의 data 구현을 직접 참조하지 않는다`() { + val violations = testSourceLayerBypasses() + + check(violations.isEmpty()) { + buildString { + appendLine("테스트 소스가 다른 모듈의 data 계층 구현을 직접 참조한다 (${violations.size}건).") + appendLine("테스트가 구현을 직접 조립하면 그 구현의 생성자·visibility·DI 조립이 테스트 사정에 묶인다 —") + appendLine("프로덕션은 테스트에 맞추지 않는다 (#1898).") + appendLine() + violations.sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("테스트는 자기 층에서 한다 — 같은 모듈이면 구현을 조립하고, 다른 모듈이면 testFixtures 의 Fake 나 Hilt 그래프를 쓴다.") + } + } + } + @Test fun `해소된 항목은 경고로 알린다`() { val stale = KNOWN_LAYER_BYPASSES - layerBypasses() @@ -70,13 +94,30 @@ class PresentationLayerDependencyKonsistTest { /** * 프로덕션 소스의 presentation 파일. [AfternoteKonsistScope.productionFiles] 가 test 소스셋을 빼, - * 테스트 소스의 조립 코드가 섞이지 않는다. + * 테스트 소스는 [testSourceLayerBypasses] 가 따로 본다. */ private fun presentationFiles() = AfternoteKonsistScope .productionFiles .withPackage("com.afternote..presentation..") + /** + * 테스트 소스셋 파일 중 `core:data`·`core:datastore` **자신의** 것은 뺀다 — 자기 구현을 조립하는 + * 자리다. 그 밖(feature·app·core 나머지)의 test·androidTest·testFixtures 가 구현 패키지를 import 하면 위반이다. + */ + private fun testSourceLayerBypasses(): Set = + AfternoteKonsistScope + .testFiles + .filterNot { file -> DATA_LAYER_MODULE_PREFIXES.any { file.normalizedProjectPath().startsWith(it) } } + .flatMap { file -> + file.imports + .map { it.name } + .filter { FORBIDDEN_DATA_IMPORT.matches(it) } + .map { "${file.normalizedProjectPath()}: $it" } + }.toSet() + + private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') + private companion object { /** * `core:datastore` 전부와 `core:data` 의 구현 패키지. @@ -95,5 +136,8 @@ class PresentationLayerDependencyKonsistTest { */ val KNOWN_LAYER_BYPASSES = setOf("PassKeyViewModel: com.afternote.core.datastore.UserProfileDataSource") + + /** 구현이 사는 모듈 — 이 모듈들의 테스트는 자기 구현을 조립하는 것이 맞다. */ + val DATA_LAYER_MODULE_PREFIXES = listOf("core/data/", "core/datastore/") } }