本文译自「Kotlin Architecture Tests: Why Konture Exists」,原文链接Kotlin Architecture Tests: Why Konture Exists,由Bao Le发布于2026年7月16日。
Kotlin 架构对同一架构有两种不同的理解:Gradle 图决定了哪些组件可以链接,而 Kotlin 源代码模型决定了代码的实际内容。Konture 的存在就是为了测试这两种理解。
考虑以下来自 Android 项目的规则:
展示代码不得在屏幕状态或面向 UI 的 API 中暴露传输 DTO。
这条规则在两个不同的地方都可能失效。
当表示模块直接连接到传输模块时,构建图可能会失败:
// feature/profile/presentation/build.gradle.kts
dependencies {
implementation(project(":core:network"))
}
它在源端也可能失败:
package com.acme.profile.presentation
import com.acme.network.dto.UserDto
data class ProfileUiState(
val user: UserDto,
)
这些故障彼此关联,但并不完全相同。第一种是 Gradle 模块的物理依赖问题,第二种是源代码级别的导入问题。一款优秀的 Kotlin 架构测试工具必须能够识别这两种故障,因为多平台系统同时受这两种因素的影响。
这就是 Konture 存在的理由。
不完整的观点
现有工具很有用。Konture 的目的并非取代编译器、代码检查器、测试运行器或所有架构测试库。问题在于,Kotlin 项目的每种视图都会有所遗漏。
| 视图 | 优势 | 不足 |
|---|---|---|
| JVM 字节码 | 已编译类及其依赖关系 | Kotlin 源代码意图、源代码集、Gradle 项目边界、部分编译器插件的影响 |
| Kotlin 源代码扫描 | 包、导入、声明、修饰符、注解 | 真实的 Gradle 模块图和项目依赖策略 |
| Gradle 图检查 | 模块、源代码集、已声明的项目依赖关系 | 源代码级导入、公共签名、可见性和类型泄漏 |
| 代码检查工具 | 局部风格和单文件质量 | 整体项目结构和架构所有权 |
Kotlin架构贯穿于这些观点之中。
你可以在已提交的示例中看到这一点。“Now in Android”示例包含 36 个 Gradle 项目(位于 settings.gradle.kts 文件中),其中包括 API/实现功能拆分和一个专门的 :konture-test 项目。KotlinConf KMP 示例 包含 9 个 Gradle 项目,涵盖共享核心、后端、Android、桌面、Web、管理和架构测试。单视图工具在这些项目中仍然有用,但它无法自然地看到项目使用的所有边界。
工具对比
实际问题不是“哪个工具最好?”,而是“哪个工具遵循哪种规则?”
| 工具选项 | 优势 | 劣势 | 适用场景 | 与 Konture 结合使用 |
|---|---|---|---|---|
| ArchUnit | 成熟的 JVM 字节码规则和类依赖性检查 | Gradle 项目图、KMP 源集、源代码级 Kotlin 意图 | 系统主要基于 JVM,规则在已编译类中可见 | 你还需要模块依赖策略、Kotlin 可见性或 KMP/平台边界 |
| detekt 自定义规则 | Kotlin PSI、样式、命名、本地声明规则 | Gradle 模块图和跨模块依赖策略 | 该规则类似于 lint:命名、注解、本地源代码约定 | lint 发现的问题需要与模块所有权或公共 API 边界一致 |
| Gradle Doctor 或模块图插件 | 构建健康状况、项目图可见性、依赖性报告 | 源导入、签名、类可见性、API 泄漏 | 你需要图洞察、构建性能报告或依赖性卫生 | 图边缘只是故事的一半,源代码级别的泄漏也至关重要 |
| 自定义 PSI 工具 | 高度特定的 Kotlin 源代码规则 | 维护成本、构建集成、图感知 | 你拥有一个针对特定组织的规则,团队可以拥有该工具 | 自定义规则应与构建图契约并行运行 |
| 运行时或 UI 测试工具,例如 Kakao | 用户流程和 Android UI 行为 | 静态架构 | 你正在验证行为、页面和交互流程 | 结构边界应在成为运行时行为缺陷之前失效 |
| 简单的审查清单 | 判断、细微差别、例外情况 | 在时间压力下的一致性 | 规则仍在不断演变或取决于人为设计的权衡 | 清单项变得足够稳定,可以在 CI 中执行 |
Konture 的赌注很窄:Kotlin 架构需要一个单一的测试表面,能够同时讨论 Gradle 模块和 Kotlin 源代码声明。
促使它发生的失败模式
最昂贵的架构故障很少始于“糟糕的架构”,它们往往始于合理的局部修复:
-
一个功能实现导入了另一个功能实现,因为 API 模块尚未公开某个缺失的契约。
-
一个共享的 KMP 模块在截止日期前接受了一个 Android 导入,之后发现桌面或 iOS 无法再干净地重用该代码。
-
一个公共存储库接口返回一个数据库实体,因为该实体已经包含所需的字段。
-
后端路由直接调用存储库,因为服务层对于一个端点来说过于繁琐。
每一次失败都会产生利息债务。下一次重构必须先偿还隐藏的耦合,才能实现预期的变更。下一个平台目标必须分离原本应该可移植的 API。下一个代码审查员必须从分散的导入和构建文件中重构架构决策。
Konture 的设计初衷就是为了解决这类问题:它既不是纯粹的源代码级规则,也不是纯粹的构建级规则,而是架构级规则,因为它介于两者之间。
字节码固然重要,但它并非 Kotlin 程序的全部。
ArchUnit 已成熟并被证明适用于 JVM 系统。如果你的架构规则可以从编译后的类中得到解答,那么字节码分析通常是一个不错的选择。
现代 Kotlin 项目通常需要比这更多的上下文信息。
Kotlin 源代码结构并非总能与团队想要维护的源代码级设计完美契合。顶层函数和扩展函数会被编译成生成的持有者类。内联函数会将函数体移至调用点。object、委托属性和编译器插件可能会引入一些对运行时很重要但对源代码级设计规则而言却显得冗余的生成结构。
但这并不意味着字节码分析是错误的。而是说,对于诸如以下规则而言,字节码并非最佳的主要分析视角:
:feature:checkout:impl是否依赖于同级实现模块?commonMain是否导入了 Android API?- 公共领域签名是否暴露了持久化类型?
impl包在源代码中是否保持内部状态?
这些是 Kotlin 和 Gradle 架构方面的问题,而不仅仅是 JVM 类方面的问题。
源代码扫描很有用,但文件夹并非构建方式
Kotlin优先的源代码扫描器擅长处理声明、导入、命名、注解和可见性。它们可以表达许多字节码工具难以处理的规则。
但源代码目录并非 Gradle 项目。包名并非模块依赖项。名为 api 的文件夹与通过 api 或 implementation 依赖的其他项目所依赖的模块并不相同。
这种区别在 Android 和 Kotlin 多平台项目中至关重要:
:app
:core:domain
:core:data
:feature:checkout:api
:feature:checkout:impl
:feature:profile:api
:feature:profile:impl
:shared
:androidApp
:iosApp
构建过程已经知道哪些模块存在、哪些源集是生产源集,以及声明了哪些项目依赖项。架构测试应该使用这些信息,而不是仅仅依靠命名约定来重建架构。
代码检查器不是架构测试器
detekt 和 ktlint 非常擅长局部检查。它们并非设计用于回答系统整体问题:
-
此模块是否依赖于被禁止的同级模块?
-
某个功能实现是否对其他实现可见?
-
项目图是否存在循环?
-
公共 API 是否暴露了其他层拥有的类型?
这些不是格式问题,而是所有权和依赖关系问题。
Konture的双视图模型
Konture 将构建视图和源代码视图结合在一起。
构建视图包括:
-
Gradle 模块。
-
源代码集。
-
生产环境 Kotlin 源代码目录。
-
已声明的项目依赖项。
-
已应用的插件上下文。
源视图包括:
-
文件、包和导入。
-
类、接口、函数和属性。
-
可见性和注解。
-
项目类之间的引用。
Konture 同时支持独立的断言(例如 Konture.modules { ... })和分组断言块。当相关的模块和源规则需要作为一个架构契约运行时,请使用 Konture.architecture { ... }:
Konture.architecture {
modules {
that().haveNamePath(":shared")
should().notDependOnModule(":androidApp")
}
classes {
that().resideInAPackage("..shared..")
should().onlyDependOnClassesInAnyPackage(
"..shared..",
"kotlin..",
"java..",
)
}
}
模块规则捕获物理构建依赖关系,源代码规则捕获源代码级别的引用模式。两者结合使用,可以覆盖构建检查或源代码检查单独都无法完全覆盖的边界。
什么是 Konture
Konture 是一个 Kotlin 架构测试库,由两个协调的部分组成:
-
一个 Gradle 插件,用于捕获项目布局、源代码集和模块依赖关系。
-
一个断言库,允许团队像编写普通 Kotlin 测试一样编写架构规则。
它不需要自定义测试运行器。架构测试可以在 JUnit、Kotest、TestBalloon 或项目已使用的其他 Kotlin/JVM 运行器下运行。
Konture 也与架构无关。它不规定必须采用 Clean Architecture、MVVM、六边形架构、特性切片或 DDD。这些都是设计选择。Konture 的任务是使所选设计可执行。
这种区别至关重要。Android 团队可能会保护功能 API 和实现模块。后端团队可能会保护端口和适配器。KMP 团队可能会确保共享代码不依赖于平台。库团队可能会防止实现类型泄露到公共包中。
Konture应该将团队的政策写入代码,而不是凭空捏造。
API设计理念
DSL有意与普通测试非常接近:
-
Konture.modules { ... }用于 Gradle 项目和源集策略。 -
Konture.classes { ... }、Konture.files { ... }、Konture.functions { ... }和Konture.properties { ... }用于源声明。 -
Konture.layered { ... }用于可读的定向包规则。 -
Konture.architecture { ... }将相关的模块和源规则组合成一个契约。 -
诸如
scopeFromPackage和scopeFromModule之类的函数式作用域为自定义谓词提供了逃生通道。
这种结构是经过精心设计的。架构规则由可能不参与架构工具开发的工程师审核。规则的编写方式应像测试用例一样,失败方式也应像测试用例一样,并且应与验证套件的其他部分并存。
扩展点即谓词。当流畅的 DSL 过于抽象时,团队可以直接检查导入、注解、可见性、超类型、文件路径、源集或模块依赖关系,并编写更具体的断言。这样可以将非常规策略保留在项目代码中,而无需 Konture 为每个组织的架构都编写一个关键字。
三个结构性工作
架构测试最有价值的地方在于,它们能够保护那些日后修复成本高昂的决策。在 Kotlin 系统中,这些决策通常可以归纳为以下三个方面。
| 任务 | 威胁 | 构建级规则 | 源代码级规则 |
|---|---|---|---|
| 逻辑隔离 | 层或模块之间的依赖关系被禁止 | :domain 不依赖于 :data;功能实现不依赖于同级实现 | 域包不导入数据、UI、框架或平台包 |
| API 封闭性 | 实现细节成为公共契约 | API 模块与实现模块保持分离 | 公共签名不暴露持久化、传输或框架类型 |
| 机制规范 | 结构更难导航和构建 | 模块图不存在循环 | 文件避免通配符导入、类/文件不匹配或不受控制的生成代码区域 |
关键不在于制定庞大的规则集,而在于保护少数几个能够保证代码库可变更的结构性选择。
Gradle意识即平台工程
Kotlin 团队通常使用模块来管理所有权、编译范围和功能独立性。这使得 Gradle 图成为一个平台层面的问题,而不仅仅是构建文件的细节。
请考虑以下常见政策:
功能实现模块之间不得依赖其他功能实现模块。
如果 :feature:checkout:impl 添加了此依赖项:
implementation ( project ( ":feature:profile:impl" ))
构建可能仍然会通过。这项功能甚至可能更快发布。但模块图现在显示,检出操作与配置文件内部结构耦合在一起。
这会产生实际后果:
-
配置文件实现方式的变更可能会导致下游工作量增加。
-
由于内部变更跨越了功能边界,构建缓存的重用效率降低。
-
由于其他功能现在可能依赖于配置文件内部结构,重构配置文件变得更加困难。
-
代码审查人员必须手动检查构建文件的偏差。
一条支持 Gradle 的规则使边界可执行:
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
这不仅仅是“整洁架构”,它还将构建健康和所有权编码为一个测试。
来源意识保护语义边界
干净的 Gradle 图并不能证明源代码语义的正确性。
例如,:data 可能正确地依赖于 :domain,以便实现领域接口。但是,开发人员仍然可能将持久化模型泄露到面向领域的 API 中:
package com.acme.domain
import com.acme.data.UserEntity
interface UserRepository {
fun getUser(id: UserId): UserEntity
}
单凭构建图可能无法看出问题所在,但源模型可以。
架构测试可以检查导入、包、声明、可见性和签名:
Konture.classes {
that().resideInAPackage("..domain..")
should().onlyDependOnClassesInAnyPackage(
"..domain..",
"kotlin..",
"java..",
)
}
对于外部框架,自定义导入谓词可以明确地表达该策略:
Konture.scopeFromPackage("com.acme.domain")
.assertTrue("Domain must not import framework or persistence APIs") { cls ->
cls.imports.none { fqName ->
fqName.startsWith("android.") ||
fqName.startsWith("androidx.compose.") ||
fqName.startsWith("org.springframework.") ||
fqName.startsWith("jakarta.persistence.")
}
}
scopeFromPackage("com.acme.domain") 为自定义断言选择一个具体的包前缀。相比之下,resideInAPackage("..domain..") 在流畅类规则中使用 Konture 的通配符包匹配。
这是架构治理的源代码层面:不仅包括哪些模块可以链接,还包括哪些概念允许出现在代码的哪些部分。
权衡取舍与失效模式
架构测试应该像其他任何生产安全保障措施一样受到质疑。糟糕的规则只会拖累项目进展。
常见失效模式:
-
规则过于宽泛:禁止在域中使用
kotlinx..可能会意外地阻止协程或序列化的合法使用。 -
隐藏的例外:排除大型遗留包可能会使规则看起来比实际更强。
-
生成的代码噪音:生成的源代码可能需要显式处理,以确保规则能够保护编写的代码。
-
KMP 复杂性:
commonMain、androidMain和iosMain通常需要不同的策略。 -
混合 Java/Kotlin 项目:除非项目有意考虑到 Java 代码,否则 Kotlin 源代码规则可能无法覆盖 Java 代码。
-
规则维护:架构会不断演进。测试必须随着架构的演进而演进,而不是因为意外而阻碍架构的运行。
这些权衡取舍并不会削弱架构测试的必要性,反而为负责任地使用架构测试设定了标准。
例如,一个很有吸引力的 KMP 规则是:
Shared code must not import kotlinx..
这种做法通常过于宽泛。kotlinx.coroutines 可能是一个合法的共享代码依赖项,而 Android 框架导入则不然。更好的规则是更精准的:禁止那些真正违反可移植性的平台或框架包,并允许架构有意使用的跨平台库。
从稳定、高信号强度的规则入手。明确例外情况。证明每条规则都可能失效。将规则变更视为架构变更,而不是为了让持续集成(CI)通过而采取的手段。
来自展示套件的证据
该存储库包含展示套件,可在不同复杂程度上测试 Konture。
这个最小的 Gradle 示例模型模拟了一个经典的 :app、:domain 和 :data 项目。其标准架构测试套件包含 14 个测试,涵盖模块图、源包边界、仓库契约、用例放置、类型泄漏规则和访问规则。它还包含一个负面测试,该测试断言故意错误的模块规则会抛出 AssertionError 异常,这是一种证明规则确实可能失败的有效模式。
规模较大的展示更能代表平台关注的问题:
-
检查过的 Now in Android 架构文件包含 13 个测试,这些测试确保功能模块不依赖于
:app模块,不会绕过存储库访问数据库或网络模块,保持功能 API 模块与功能实现模块的独立性,防止功能实现之间的耦合,以及确保 ViewModel 远离 Android 框架导入。 -
经检查的 KotlinConf KMP 边界/后端文件中的 8 个测试,这些测试确保共享的
:core模块保持为叶子依赖项,客户端应用模块不依赖于后端实现,后端代码不依赖于前端客户端模块,以及后端路由不直接导入存储库或数据库模式。
这些并非玩具式的规则,而是所有权和平台约束的可执行版本:功能解耦、共享模型纯粹性、后端/前端分离、路由服务边界以及 API 接口控制。
轻量级的 Gradle 示例可以直接运行:
./gradlew -p showcases/sample-gradle :konture-test:test
在本仓库中,该命令成功完成,并在生成 Konture 的布局和依赖项元数据后运行专用架构测试模块。
性能和规模
架构测试的成本应该足够低,以便团队能够持续地进行反馈。
Konture 的 Gradle 插件会根据构建结果生成布局元数据,并且断言库会在常规测试任务中运行。这对于大型项目来说会带来一些实际影响:
-
将架构测试放在独立的模块中,避免生产模块继承仅用于测试的依赖项。
-
将规则的作用域限定在它们实际控制的模块和包中,而不是为每个断言扫描整个项目。
-
优先使用少量高信号契约,而不是数十条重叠的样式规则。
-
让 Gradle 处理任务输入和生成的布局元数据的缓存,而不是在每个测试中重新发现项目图。
-
在 CI 中像跟踪其他验证任务一样跟踪架构测试任务的持续时间。
对于包含 100 个以上模块的项目,重要的设计问题不仅仅是“工具能否扫描代码库?”,而是“团队能否理解错误并快速修复?”。一条描述模糊违规的快速规则仍然会浪费审核时间。一条速度稍慢但能识别出被禁止的模块边界、导入或公共签名的规则,通常是更明智的平台投资。
用于人工智能辅助更改的双视图反馈
AI 编码助手往往倾向于优化局部进度:导入可见类、添加缺失依赖项、满足当前测试。Konture 的双视图模型为它们提供了更精确的修复信号。构建图失败表示“你添加或依赖了错误的模块边”;源模型失败表示“此文件导入或暴露了错误的概念”。这些是不同的修复方法,测试输出应该清晰地体现出这种区别。
何时 Konture 适合
如果你关注的规则涵盖 Kotlin 源代码和 Gradle 结构,那么 Konture 是一个不错的选择:
-
Gradle 模块边界和非循环项目图。
-
功能
:api与:impl的分离。 -
表示层、领域层和公共 API 接口与传输层、持久层和框架类型保持独立。
-
公共 API 签名避免使用持久层、传输层或 UI 类型。
-
Kotlin 可见性约定,例如保持实现包为
internal。 -
需要项目级上下文的文件和包约定。
它对格式化、普通样式以及标准代码检查工具已经能够很好地处理的检查项来说用处不大。使用能够准确执行规则的最便宜的工具即可。
当前限制和路线图压力
Konture应该明确说明它不是什么。
它不能替代 Kotlin 编译器、字节码分析、运行时集成测试或依赖项漏洞扫描。它也不是验证反射或运行时依赖注入容器背后隐藏行为的合适场所。混合 Java/Kotlin 项目需要专门的覆盖范围,因为 Kotlin 源代码分析不会自动使 Java 架构可见。生成的源代码和编译器插件输出可能需要排除或单独的规则,以便测试套件能够专注于编写的架构。
构建工具的演进也至关重要。Android Gradle 插件、Kotlin 多平台源代码集建模、Kotlin 编译器变更以及新的代码生成模式都会改变“项目结构”的含义。Konture 的长期价值取决于对这些输入信息的准确把握:Gradle 元数据、Kotlin 源代码解析、源代码集/平台感知以及能够帮助团队修复设计缺陷而非与工具对抗的失败信息。
因此,路线图压力是切实存在的:
-
更深入的 KMP 源代码集和平台感知示例,
-
更清晰地处理生成的代码,
-
更强大的公共 API 泄漏检查,
-
更好的大型规则套件诊断,
-
与构建缓存和 CI 报告更顺畅的集成。
核心思想
架构不应该依赖内存。
如果某个边界重要到需要在每次代码审查中都加以保护,那么它就适合作为可执行测试的候选对象。如果打破这个边界会导致构建速度变慢、API 泄露、团队协作受限,或者增加 AI 辅助变更的风险,那么代码库应该能够明确地指出这些问题。
Konture 的存在是因为 Kotlin 架构不仅仅存在于字节码中,不仅仅存在于源文件中,也不仅仅存在于 Gradle 构建文件中。它存在于它们之间的关系之中。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!