让架构边界变成可执行的测试

本文译自「Kotlin Architecture Tests: What They Are and Why They Matter - Part 1/3」,原文链接medium.com/proandroidd...,由Bao Le发布于2026年7月16日。

一个 Kotlin 项目可以编译通过,通过单元测试,满足代码检查工具的要求,但其结构仍然可能变得难以修改。架构测试正是为了弥补这一缺陷而存在的。

大多数验证工具回答的是局部问题。

编译器会检查代码是否符合 Kotlin 规范。代码检查工具会检查文件是否遵循本地的风格和质量规则。单元测试会检查函数或组件的行为是否符合预期。

这些检查是必要的。但它们与检查系统是否仍然保持团队所依赖的结构并不相同。

考虑一个依赖于数据层实现的领域用例:

kotlin 复制代码
package com.acme.domain
import com.acme.data.SqlUserRepository
class GetUserUseCase(
    private val repository: SqlUserRepository,
)

这段代码可以编译。单元测试也可以通过。ktlint 可能没有任何有用的信息。

问题在于结构:领域层现在了解了一个持久化细节。原本旨在保持可变更性的边界变成了一个需要人们记住的约定。

架构测试将这个约定转化为一个可执行的规则:

scss 复制代码
Konture.classes {
    that().resideInAPackage("..domain..")
    should().onlyDependOnClassesInAnyPackage(
        "..domain..",
        "kotlin..",
        "java..",
    )
}

这就是核心思想。架构测试并不能证明软件的正确性,它们只能证明特定的结构决策仍然有效。

绿色构建的错觉

绿色构建表明代码仓库满足了你要求的检查。

但它并不能说明预期的架构在变更后仍然有效。

如果符号位于类路径中,编译器会接受禁止的依赖项。如果有人声明了依赖项,Gradle 会构建一个违反你设计的模块图。除非被测行为发生变化,否则单元测试不会因为一个特性模块导入了另一个特性模块的实现细节而失败。

这就是为什么架构违规在代码审查中常常看起来很普通:

  • 控制器直接调用代码仓库,因为这比添加应用程序服务更快。

  • 领域模型接受网络 DTO,因为该 DTO 已经包含正确的字段。

  • 特性实现模块导入了另一个特性实现模块,因为 API 模块尚未公开所需的接口。

  • Kotlin 类在 impl 包中默认保持公共属性,方便其他模块复用。

  • AI 代码助手会添加 Gradle 依赖项,因为它能使当前文件编译通过。

这些更改并非出于恶意或疏忽。大多数结构性偏差源于一些看似合理但累积起来代价高昂的局部优化。

架构测试能够尽早揭示这些累积成本。

结构性漂移有其形状

大多数架构的漂移并不显着。它通常从拉取请求中看起来合理的一个边缘开始。

第二个图仍然可以编译。产品行为可能仍然是正确的。损坏稍后出现:

  • 配置文件重构现在需要结帐上下文,
  • 不相关的功能更改会使更多构建工作失效,
  • 审核者必须考虑更广泛的爆炸半径,
  • 下一个快捷方式感觉不那么不寻常,因为第一个快捷方式已经存在。

正确的指标是特定于项目的,但有用的指标是具体的:禁止的模块边缘数量、每月违反规则的数量、模块扇入和扇出、功能更改后的重建范围以及对同一边界的重复审查评论。当架构测试将这些观察结果转化为失败的示例而不是风格论证时,它们就会变得有说服力。

架构测试检查什么

良好的架构测试可以保护影响变更速度、模块独立性、公共 API 形状和审查负载的决策。它们通常分为几类。

1. 依赖方向和层隔离

分层系统取决于方向。在清洁架构、端口和适配器以及许多以领域为中心的设计中,外层可能依赖于内部,但核心不应依赖于 UI、数据库、传输框架或平台 API。

典型规则:

  • 域包不得导入持久性、传输、Android、Compose、Spring 或 Ktor 服务器 API。
  • 应用程序服务可能依赖于域合约,而不是直接依赖于 Web 控制器。
  • UI 模块应该使用表示状态,而不是数据库或网络实体。

编译器会看到有效的类型。架构测试对给定层中哪些有效类型不可接受进行编码。

2. Gradle 模块边界

在模块化 Kotlin 项目中,Gradle 项目依赖项是架构的一部分。

典型规则:

  • :core:domain 不得依赖于 :core:data:app
  • 功能实现模块不得依赖于同级功能实现模块。
  • API模块可能被广泛依赖;实施模块应保留在其 API 后面。
  • Gradle 项目图不应包含循环。

此类别对于设计和构建性能很重要。不必要的模块边缘会扩大重新编译范围,降低缓存的有用性,并使本地更改影响不相关的功能。

3. 公共 API 和类型泄漏

一些架构失败与私有实现中的导入无关。它们与模块公开的内容有关。

典型规则:

  • 公共域 API 不应公开数据库实体或网络 DTO。
  • 公共功能 API 包应该公开合约和稳定模型,而不是实现类。
  • 持久性或框架注释不应泄漏到干净的业务接口中。
  • 库模块应该将实现包保留在"内部",除非它们是故意公开的。

Kotlin 默认的公共可见性使得这很容易出错。一旦另一个模块根据意外的公共类型启动,删除它就会成为重大更改。

4. 跨层调用

有些系统依赖中间层进行验证、授权、事务、日志记录或编排。直接调用可以绕过这些政策所在的位置。

典型规则:

  • 控制器调用应用程序服务,而不是直接调用存储库。
  • 可组合项调用 ViewModel 或演示者,而不是 Retrofit 服务。
  • 路由处理程序调用用例,而不是 SQL 适配器。
  • UI 模块不调用基础设施模块。

应谨慎使用这些规则。当中间层承担真正的责任时,它们就很有价值。当层级仅因为图表所示而存在时,他们就是官僚机构。

5. 依赖注入和接线约定

DI 配置是可执行形式的架构。它决定哪个实现支持哪个合约。

一些接线策略属于集成测试。其他的可以从结构上检查:

  • DI 模块采用经批准的封装。
  • 功能模块不会覆盖核心绑定。
  • 适配器实现绑定到域接口而不是直接使用。
  • 仅测试绑定不会泄漏到生产源集中。

有用的规则是捕获真实类别的生产或维护故障的规则,而不是仅仅反映偏好的规则。

6. 文件和来源卫生

并非每个结构测试都需要深刻。一些规则可以保持导航的可预测性并减少审核噪音:

  • 每个文件一个主要类。
  • 文件名与主类名匹配。
  • 没有通配符导入。
  • 生成或迁移包被明确排除。

这些规则不应重复格式化程序或 linter 已经可以很好处理的内容。当架构测试需要整个项目上下文时,它们是最有价值的。

架构测试不应该检查哪些内容

当架构测试试图控制一切时,它们就会变得脆弱。

它们不应取代:

  • 类型安全编译器。
  • 空白和样式的格式化程序。
  • 用于普通单文件气味的短绒检查。
  • 行为单元测试。
  • 真实接线和运行时行为的集成测试。
  • 代码审查,以判断、命名和设计意图不够稳定,无法进行编码。

糟糕的架构测试会冻结实现细节并将其称为设计。好的边界可以保护多个工程师已经依赖的边界。

架构测试如何变得有害

架构测试就是治理。糟糕的治理比没有治理更糟糕,因为它教会人们绕过系统。

常见故障模式:

  • 脆弱的规则:测试对当今的文件夹布局进行编码,而不是持久的边界。
  • 虚假安全性:通过的套件被视为架构良好的证据。
  • 团队摩擦:规则阻止合法的功能工作,但没有人知道谁拥有异常流程。
  • 过度测试生成的代码:Room、KSP、Compose、序列化或 DI 生成的源会产生编写代码规则无意判断的噪音。
  • 广泛的软件包禁令:一条规则阻止了太多内容,因此工程师添加了排除项,直到该规则不再具有任何意义。
  • 没有负面证据:该套件从未因故意违规而失败。

解药不是更大的套房。它是一个更小、更明确的套件。每条规则都应该列出它所保护的决策、破坏它的成本以及预期的修复路径。

何时不使用它们

架构测试并不是自动值得的。

对于一个很小的代码库来说,他们通常还为时过早,因为每个人仍然可以在头脑中掌握结构。在每周都会发现模块和包边界的早期产品中,它们可能会很吵。它们可能不太适合高度动态或反射的代码,其中有意义的依赖关系是运行时连接而不是可见的源结构。如果第一个套件尝试强制执行目标架构而不是团队实际迁移的架构,那么它们还可能会减慢具有严重流失的整体架构。

在这些情况下,更轻的工具可能就足够了:

  • 简短的架构决策记录,
  • 模块所有权说明,
  • 接下来的一些更改的审核清单,
  • 一份非阻塞报告,在执行之前对违规行为进行计数。

当边界足够稳定且可以强制实施且成本足够高而无法打破时,请使用架构测试。

失败的生活文档

架构图、自述文件、入门文档和审查清单都有帮助。他们都没有失败 CI。

架构测试为规则提供了持久的形式:

kotlin 复制代码
@Test
fun `domain must not depend on data or app modules`() {
    Konture.modules {
        that().haveNamePath(":domain")
        should().notDependOnModule(":data")
        should().notDependOnModule(":app")
    }
}

测试名称记录了规则。断言定义了规则。失败输出告诉开发人员哪里违反了规则。

这改变了评论对话。存储库无需要求审阅者在时间压力下记住每个边界,而是可以报告:

ruby 复制代码
Architecture violation(s) detected:
Module :domain should not depend on :data, but a dependency was found.

团队仍在决定规则是否正确。该测试无需手动重新发现相同的违规行为。

组织影响

技术效果是边界强制。组织效应是共享记忆。

对于新工程师来说,架构测试会压缩入职时间。它们显示了允许哪些依赖项、哪些 API 是有意公开的以及存在异常的位置。对于经验丰富的工程师来说,他们会减少重复的审核评论,因此代码审核可以专注于设计判断,而不是监管相同的导入。

对于团队来说,权衡是自主性与一致性。平台或架构团队不应使用测试来集中每个本地决策。更好的模式是对一小组跨团队合约进行编码,并让功能团队拥有其余部分。当规则发生变化时,将这种变化视为架构决策:更新测试、更新 ADR 或文档(如果存在),并使迁移路径明确。

从规模上看,架构测试作为治理的一部分效果最好,而不是作为治理的替代品:

  • ADR 解释了边界存在的原因。
  • 架构测试检查边界是否仍然成立。
  • CI 报告跨越边界的位置。
  • 审核者决定是否应更改边界或代码。

展示中的具体示例

Konture 存储库包括一个小型 Gradle 展示,它使用与上面示例相同的形状::app:domain:data 和专用的 :konture-test 模块。

它的架构套件不仅检查一条快乐路径规则。它结合了多种结构检查:

  • 模块图没有循环。
  • :domain 不依赖于 :data:app
  • :data 仅依赖于 :domain
  • ..domain.. 中的类仅依赖于domain、Kotlin 或Java 包。
  • 域中的存储库声明是接口。
  • 用例签名不会泄漏".data."或".app."类型。

一项测试故意证明错误的模块规则会引发"AssertionError"。故意错误的断言说":data"应该只依赖于":app";示例项目具有依赖于":domain"的":data",因此该规则失败,其形状与真实的架构回归相同:

vbnet 复制代码
Architecture violation(s) detected:
Module :data depends on :domain, which is not allowed by pattern(s): :app

这很重要。从未失败的结构性规则可能不会检查团队认为正在检查的事情。

示例套件是可执行的:

ruby 复制代码
./gradlew -p showcases/sample-gradle :konture-test:test

在此存储库中,该命令成功运行架构测试模块并生成 Konture 用于评估模块感知规则的布局和依赖项元数据。

为什么这对于人工智能辅助开发很重要

AI编码助手擅长本地完成。他们可以导入可见的类,添加缺少的依赖项,并进行狭窄的测试通过。

建筑通常是全球背景。

此类说明有助于:

erlang 复制代码
Keep domain independent from data.
Do not add sideways feature dependencies.
Map network DTOs before they reach UI state.

但及时的指示并不是强制执行。他们是指导。

架构测试为人类和代理提供相同的反馈循环:

  1. 变革跨越边界。
  2. 对于具体模块、文件、导入或类型,测试失败。
  3. 开发人员或代理使用预期的抽象来修复设计。

这不是魔法,也不能代替复习。这是一种使结构规则对已经更改代码的工具可见的方法。

Kotlin 架构的未来压力

对结构性反馈的需求可能会增加,而不是减少。

Compose Multiplatform 使 UI 代码可移植,但它也带来了新的问题:哪些 UI 抽象属于共享代码,哪些保持特定于平台。 Kotlin 2.x 和编译器插件密集型堆栈继续模糊编写的源代码与生成或转换的代码之间的界限。人工智能代理可以比审阅者手动检查每个模块边缘更快地生成大型的、局部合理的补丁。

这并不意味着每个团队都需要更多规则。这意味着重要的规则需要是可执行的、狭窄的且易于修复的。面向未来的架构套件并不是最大的套件。它抓住了人类和特工最有可能意外跨越的界限。

当规则值得 CI 执行时

并不是每个好主意都应该阻碍构建。当其中大部分为真时,规则是 CI 的良好候选者:

  • 团队可以解释破坏它的成本。
  • 该规则在正常功能工作中是稳定的。
  • 违规行为通常是错误,而不是合法的设计选择。
  • 失败消息指出了可行的修复方法。
  • 异常很少见,可以明确命名。
  • 该规则捕获编译器、linter 或单元测试未捕获的内容。

如果一条规则在正常开发过程中不断失败,则它可能过于宽泛。如果一条规则需要许多安静的排除,它可能会假装架构比实际情况更干净。如果没有人能解释它为什么存在,它就不应该阻止交付。

从保护已知难题的规则开始:模块循环、域到数据依赖关系、功能实现耦合、公共 API 泄​​漏以及平台 API 泄​​漏到共享 KMP 代码中。

实际回报

架构测试可帮助团队保留使未来更改成本更低的结构:

  • 它们使领域逻辑独立于框架和持久性。
  • 它们可以防止意外的 Gradle 边缘扩大重新编译范围。
  • 它们保护 API 模块免遭泄露实现细节。
  • 他们有意提高公众知名度。
  • 他们将重复的审核意见转化为对特定模块、文件、导入或类型失败的检查。

目标不是僵化的架构。目标是显式架构。

一旦团队选择了结构,构建就应该有助于保护它。

下一篇文章重点介绍本入门仅暗示的部分:为什么仅源规则会错过 Gradle 依赖漂移、为什么仅 Gradle 规则会错过源级别类型泄漏,以及为什么 Kotlin 架构测试同时需要两个视图。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
hai_android2 小时前
SendChannel 与 ReceiveChannel 通信机制
android
数据治理自习室4 小时前
AI 应用评测体系(GraphRAG)
android·大数据·人工智能·kotlin
爱笑鱼4 小时前
Android 系统启动机制(五):system_server 是 init 启动的,还是 Zygote fork 出来的?
android
智购科技无人售货机工厂5 小时前
2026自动售货机防拆机物理安全设计:从安全螺丝到结构互锁的工程实践~YH
android·网络·驱动开发·python·单片机·安全·云原生
开开心心就好5 小时前
电子教鞭工具支持画框写字插图片功能齐全
android·开发语言·前端·javascript·人工智能·pdf·html
Dovis(誓平步青云)5 小时前
拍视频前先把镜头想清楚:做一个分镜取景辅助器
android·java·服务器·javascript·人工智能
峥嵘life6 小时前
Android16 系统 APEX 模块说明
android·大数据·开发语言
2601_962065497 小时前
PHP For 循环
android·java·php
码农coding7 小时前
android12 WindowManagerService窗口的添加过程
android