从零开始的架构测试套件

0 阅读17分钟

本文译自「Kotlin Architecture Tests with Konture: A Practical Guide - Part 3/3」,原文链接Kotlin Architecture Tests with Konture: A Practical Guide - Part 3/3,由Bao Le发布于2026年7月17日。

搭建一个专门的架构测试模块,添加 Konture,并构建一套小型结构规则,以保护 Kotlin 项目实际依赖的边界。

最好的首次架构测试通常并不巧妙。

这是团队一直以来都奉行的规则:

Feature implementation modules must not depend on sibling feature 
implementation modules.

这条规则很具体,也很容易解释,但一旦违反就会付出代价。它还能培养正确的习惯:将真实的架构决策编码到代码中,而不是理想化的示意图。

本指南使用 Gradle Kotlin DSL 和 JUnit 5。Konture 本身与测试运行器无关,因此相同的规则可以从 JUnit、Kotest、TestBalloon 或其他 Kotlin/JVM 运行器运行。

目标设置

使用专用的架构测试模块。

0 (2).png

一个独立的模块为架构测试套件提供了项目级视图,而无需向生产模块添加架构测试依赖项。它还简化了持续集成配置:只需在需要结构检查时运行一个任务即可。

在一个较大的项目中,被检查的模块可能如下所示:

:app
:core:domain
:core:data
:feature:checkout:api
:feature:checkout:impl
:feature:profile:api
:feature:profile:impl
:shared
:androidApp
:iosApp

名称并不重要,策略才重要。请在每条规则中使用你实际使用的模块和包。

步骤 1:添加 Konture

在版本目录中声明版本:

[versions]
konture = "0.6.8"
[plugins]
konture = { id = "io.github.baole.konture", version.ref = "konture" }
[libraries]
konture = { group = "io.github.baole", name = "konture", version.ref = "konture" }

在根构建中应用插件:

plugins {
    alias(libs.plugins.konture) apply true
}

该插件生成 Konture 模块感知规则所需的布局元数据。

步骤 2:创建架构测试模块

settings.gradle.kts中注册该模块:

include(":konture-test")

创建 konture-test/build.gradle.kts

plugins {
    kotlin("jvm")
    alias(libs.plugins.konture)
}
dependencies {
    testImplementation(libs.konture)
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.0")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.0")
    testImplementation(project(":app"))
    testImplementation(project(":core:domain"))
    testImplementation(project(":feature:checkout:api"))
    testImplementation(project(":feature:checkout:impl"))
    testImplementation(project(":feature:profile:api"))
    testImplementation(project(":feature:profile:impl"))
}
tasks.test {
    useJUnitPlatform()
}

将示例依赖项列表替换为规则检查的模块。架构测试模块应该能够看到它检查的代码和构建元数据。

步骤 3:从一个构建图规则开始

创建 konture-test/src/test/kotlin/com/acme/ArchitectureGuardrailsTest.kt。

package com.acme
import io.github.baole.konture.Konture
import org.junit.jupiter.api.Test
class ArchitectureGuardrailsTest {
    @Test
    fun `feature implementations must not depend on sibling feature implementations`() {
        Konture.modules {
            that().haveNameMatching(":feature:**:impl")
            should().onlyDependOnModules(
                ":feature:**:api",
                ":core:**",
                ":shared",
            )
        }
    }
}

此规则检查 Gradle 项目图。如果 :feature:checkout:impl 添加了 implementation(project(":feature:profile:impl")),则架构测试失败。

对于更简单的分层项目,请使用实际路径:

Konture.modules {
    that().haveNamePath(":core:domain")
    should().notDependOnModule(":core:data")
    should().notDependOnModule(":app")
}

不要发布占位符名称。架构测试是契约;契约需要具体的目标。

步骤 4:添加循环检查

循环模块依赖会减慢构建速度并削弱所有权边界。

@Test
fun `module graph must not contain cycles`() {
    Konture.assertNoCycles()
}

对于多模块项目来说,这是一个很有用的默认值,因为周期往往会使未来的每一个边界决策都变得更加困难。

第五步:保护域名源代码

清晰的模块图并不能保证清晰的源引用。请添加源级别的包规则:

@Test
fun `domain classes must only depend on domain and standard library types`() {
    Konture.classes {
        that().resideInAPackage("..domain..")
        should().onlyDependOnClassesInAnyPackage(
            "..domain..",
            "kotlin..",
            "java..",
        )
    }
}

如果你的领域层有意依赖于共享的项目代码,请明确说明:

Konture.classes {
    that().resideInAPackage("..domain..")
    should().onlyDependOnClassesInAnyPackage(
        "..domain..",
        "..shared..",
        "kotlin..",
        "java..",
    )
}

规则应该与团队选择的架构相匹配,而不是从示例中借鉴的架构。

第 6 步:禁止在不应该导入的地方导入框架

通过导入比通过项目类依赖项更容易检测到外部框架。

@Test
fun `domain must not import framework or persistence APIs`() {
    Konture.scopeFromPackage("com.acme.domain")
        .assertTrue("Domain must not import framework or persistence APIs") { cls ->
            cls.imports.none { fqName ->
                fqName.startsWith("org.springframework.") ||
                    fqName.startsWith("io.ktor.") ||
                    fqName.startsWith("android.") ||
                    fqName.startsWith("androidx.compose.") ||
                    fqName.startsWith("jakarta.persistence.") ||
                    fqName.startsWith("javax.persistence.")
            }
        }
}

scopeFromPackage("com.acme.domain") 为自定义断言选择一个具体的包前缀。相比之下,resideInAPackage("..domain."") 在流畅的类规则中使用 Konture 的通配符包匹配。

针对项目调整前缀。后端可能会禁止域中使用持久化注解。Android 应用可能会禁止在共享包或域包中使用 Android 和 Compose API。KMP 项目可能会对 commonMain、androidMain 和 iosMain 应用不同的策略。

避免使用过于宽泛的禁令,以免误伤合法的依赖项。例如,禁用所有 kotlinx 组件可能会阻碍协程的正常使用。

第 7 步:强制执行存储库契约

如果你的架构将领域层中的存储库视为契约,请将该规则编码到代码中:

@Test
fun `repositories inside domain must be interfaces`() {
    Konture.classes {
        that().resideInAPackage("..domain..")
        that().haveNameEndingWith("Repository")
        should().beInterfaces()
    }
}

这可以发现一个常见的捷径:

class UserRepository { 
    // concrete persistence behavior in domain 
}

如果你的项目使用了抽象类、端口或其他命名约定,请对这些约定进行编码。规则应该强制执行你的契约模型,而不是“Repository”这个词本身。

第 8 步:将实现包保留在内部

Kotlin 类和成员默认是公开的。在多模块项目中,这种意外的公开可见性会导致意外的 API 暴露。

@Test
fun `implementation classes must remain internal`() {
    Konture.classes {
        that().resideInAPackage("..impl..")
        should().beInternal()
    }
}

这对于将 API 和实现分开的功能模块或库模块尤其有用:

:feature:checkout:api
:feature:checkout:impl

API 模块负责公开合约。实现模块不应成为其他功能的百搭容器。

步骤 9:保护功能模块隔离

如果一开始没有启用功能隔离,请在基本模块规则稳定后再添加。同级功能实现通常不应该直接相互依赖。

@Test
fun `feature implementations must not depend on sibling feature implementations`() {
    Konture.modules {
        that().haveNameMatching(":feature:**:impl")
        should().onlyDependOnModules(
            ":feature:**:api",
            ":core:**",
            ":shared",
        )
    }
}

这样一来,功能实现就可以依赖于功能 API 模块、核心模块和共享模块,从而避免实现之间的耦合。

如果应用采用了不同的模块化策略,请修改允许列表。这里的值并非指模式,而是指使预期的依赖关系图可执行。

步骤 10:使用分层 DSL 实现方向性规则

对于基于包的层规则,分层 DSL 比一长串包谓词更容易阅读。

1 (1).png

@Test
fun `layers must follow inward dependency direction`() {
    Konture.layered {
        val presentation = layer("presentation") definedBy "..presentation.."
        val domain = layer("domain") definedBy "..domain.."
        val data = layer("data") definedBy "..data.."
        where(presentation) {
            mayOnlyAccessLayers(domain)
        }
        where(data) {
            mayOnlyAccessLayers(domain)
        }
        where(domain) {
            mayOnlyAccessLayers()
        }
    }
}

对于端口和适配器,同样的思路可能如下所示:

Konture.layered {
    val domain = layer("domain") definedBy "..domain.."
    val application = layer("application") definedBy "..application.."
    val adapter = layer("adapter") definedBy "..adapter.."
    where(domain) {
        mayOnlyAccessLayers()
    }
    where(application) {
        mayOnlyAccessLayers(domain)
    }
    where(adapter) {
        mayOnlyAccessLayers(application, domain)
    }
}

使用团队实际使用的模型。与实际代码库不符的分层规则很快就会造成阻碍。

第 11 步:谨慎地添加文件级清理

一些信息源惯例可以降低导航成本和评论噪音:

@Test
fun `source files should stay simple and explicit`() {
    Konture.files {
        should().notHaveWildcardImports()
        should().haveOnlyOneClassPerFile()
        should().haveNameMatchingClassName()
    }
}

不要将架构测试变成第二个代码检查工具。如果 detekt、ktlint 或其他格式化工具已经能够很好地执行规则,请使用该工具。

第 12 步:运行套件

运行指定任务:

./gradlew :konture-test:test

或者将其纳入正常的验证流程:

./gradlew check

该仓库中的 Gradle 示例展示了相同的模式:

./gradlew -p showcases/sample-gradle :konture-test:test

该命令针对一个小型 :app、:domain 和 :data 项目运行一个专用的架构测试模块。该测试套件涵盖模块依赖关系、类包边界、存储库契约、用例签名中的类型泄漏,以及一个证明故意错误的模块规则会失败的否定断言。

反例展示了真实违规情况下应有的失败形态:

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

解决方法并非削弱规则,而是恢复预期的架构图,或者仅在架构决策确实发生变化时才修改规则。对于上述功能示例,通常意味着将共享合约移至 :feature:profile:api 模块,并依赖该 API 模块而非 :feature:profile:impl 模块。

当规则执行失败时,像处理其他任何测试失败一样处理它:

  1. 读取违规信息。
  2. 判断编码后的规则是否仍然正确。
  3. 如果代码越界,则修复代码。
  4. 如果架构决策发生变化,则修复规则。
  5. 仅当需要引入异常时才添加显式异常。

不要在持续集成(CI)通过之前悄悄削弱规则。那样会将架构测试从治理工具变成装饰品。

组合相关规则

以上示例使用了诸如 Konture.modules { ... }Konture.classes { ... } 之类的独立断言。当多个规则描述同一边界时,请将它们与 Konture.architecture { ... } 组合在一起,以便模块级和源代码级检查能够被视为一个统一的契约:

@Test
fun `presentation boundary must hide transport models`() {
    Konture.architecture {
        modules {
            that().haveNamePath(":feature:profile:presentation")
            should().notDependOnModule(":core:network")
        }
        classes {
            that().resideInAPackage("..profile.presentation..")
            should().notDependOnClassesInAnyPackage(
                "..network.dto..",
                "..database..",
            )
        }
    }
}

高级模式

一旦入门套件稳定下来,就添加规则,以应对 Kotlin 项目通常会泄露架构的地方。

DTO 和实体表面边界

最昂贵的漏洞通常出现在公开或面向用户界面的签名中。页面状态、演示器契约或功能 API 暴露了传输 DTO,从而将一个实现细节变成了长期存在的依赖项。

@Test
fun `presentation state must not expose transport or persistence types`() {
    val presentationClasses = Konture.scopeFromPackage("com.acme.profile.presentation").classes
    presentationClasses.assertTrue("Presentation API must not expose DTOs or entities") { cls ->
        val publicFunctionTypes =
            cls.functions
                .filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
                .flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
        val publicPropertyTypes =
            cls.properties
                .filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
                .map { it.type }
        (publicFunctionTypes + publicPropertyTypes).none { type ->
            type.endsWith("Dto") ||
                type.endsWith("Entity") ||
                type.contains(".network.") ||
                type.contains(".database.")
        }
    }
}

你可以将其与模块规则结合使用:

Konture.modules {
    that().haveNamePath(":shared")
    should().notDependOnModule(":androidApp")
}

使用构建实际使用的源集名称:commonMain、androidMain、iosMain、desktopMain、jvmMain 或项目特定的中间源集。

DI 图约定

Konture 不应取代运行时依赖注入集成测试。它仍然可以保护结构化依赖注入策略:

@Test
fun `hilt modules must stay in di packages`() {
    Konture.classes {
        that().haveAnnotationOf("dagger.Module")
        should().resideInAPackage("..di..")
    }
}

对于 Koin,类似的策略可能存在于文件或函数级别:

@Test
fun `production koin modules must not live in test packages`() {
    Konture.files {
        that().satisfy { file ->
            file.imports.any { it == "org.koin.dsl.module" }
        }
        should().resideInAPackage { packageName ->
            !packageName.contains(".test.") &&
                !packageName.contains(".fixtures.")
        }
    }
}

这些规则并不能证明DI图的起始位置。它们的作用是防止线路代码扩散到所有权不明确的地方。

生成的代码

生成的代码常常出于正当理由而违反编写代码的规范。务必明确地处理它。

konture {
    excludePackages(
        "..generated..",
        "..buildconfig..",
        "..databinding..",
    )
}

来自 Room、KSP、Compose 资源、protobuf、序列化或依赖注入工具生成的源代码不应在有关公共 API 设计或包所有权的规则中产生误报。如果生成的代码是公共契约的一部分,则应测试公开编写的包装器,而不是生成的实现细节。

遗产隔离

对于遗留代码,不要假装目标架构已经存在。将其隔离。

@Test
fun `new domain code must not depend on legacy persistence`() {
    val newDomain =
        Konture.scopeFromPackage("com.acme.domain").classes.filterNot { cls ->
            cls.packageName.startsWith("com.acme.domain.legacy")
        }
    newDomain.assertTrue("New domain code must not import legacy persistence") { cls ->
        cls.imports.none { it.startsWith("com.acme.legacy.persistence.") }
    }
}

例外情况是可见的、有名称的、可移除的。这比一条总是失效的宽泛规则或一条无人记得的默默排除条款要好得多。

公共 API 接口

当意外的公共 API 造成长期耦合时,架构测试尤其有用。

@Test
fun `public feature api must not expose implementation or persistence types`() {
    val apiClasses = Konture.scopeFromModule(":feature:checkout:api").classes
    apiClasses.assertTrue("Public API must not leak implementation detail") { cls ->
        val publicFunctionTypes =
            cls.functions
                .filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
                .flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
        val publicPropertyTypes =
            cls.properties
                .filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
                .map { it.type }
        (publicFunctionTypes + publicPropertyTypes).none { type ->
            type.contains(".impl.") ||
                type.contains(".data.") ||
                type.endsWith("Entity") ||
                type.endsWith("Dto")
        }
    }
}

对于库来说,这也是一条语义化版本控制规则。如果一个公共签名今天暴露了一个持久化实体,那么明天移除该实体就会导致 API 发生重大变更。

规则设计原则

在扩展套件之前,请先遵循以下原则:

  • 每个测试对应一条策略:失败的测试名称应明确告知开发人员是哪个决策被违反。
  • 证明规则可能失败:临时引入违规行为,运行测试,确认测试失败,然后移除违规行为。
  • 使用真实名称:避免在已提交的规则中使用占位符模块和包。
  • 明确例外情况:生成的代码、迁移包和遗留区域可能需要排除,但这些排除应是经过深思熟虑的。
  • 避免使用宽泛的通配符:只有当团队了解其排除的合法情况时,宽泛的禁令才有用。
  • 结构与样式分离:架构测试应保护边界和所有权,而非格式。

这些示例项目是很好的校准素材。Now in Android 套件演示了功能解耦、ViewModel 框架导入检查以及 :api/:impl 分离。KotlinConf KMP 套件演示了共享核心的纯粹性、后端/前端分离以及路由到服务的边界。使用类似这样的示例来设计规则时,应考虑实际的架构压力,而不是抽象的整洁性。

故障排除

大多数 Konture 失败案例都可归为以下几类。

将第一个违规解读为一个设计问题:该规则是否仍然成立?如果成立,则修复代码。如果不再成立,则更改规则,并在测试名称、ADR 或文档中留下清晰的记录。

这值得吗?

与后期结构修复相比,建筑结构检测费用低廉,但并非免费:

  • 对现有代码库进行首次检查通常会发现遗留的违规行为和误报,需要在强制执行前进行分类处理。
  • 团队必须充分了解领域特定语言 (DSL),以便精确表达策略,而不是编写宽泛且令人困惑的规则。
  • 当架构发生变化时,每条持久规则都会成为维护的难点;规则修改应作为设计变更进行审查。

对于某个特定的规则集,例如功能实现隔离或领域纯净性,许多团队通常可以在一到两个迭代周期内从信息性持续集成 (CI) 过渡到强制性 CI。但这只是一个粗略的估计,并非绝对保证:较旧的代码库和大型迁移区域需要更多时间。

指标和可观测性

将架构套件视为产品健康状况信号,而不仅仅是合格/不合格的门槛。

有用的指标:

  • 架构规则数量,
  • CI 中的架构测试持续时间,
  • 强制执行前各规则的违规次数,
  • 各模块或包的重复违规次数,
  • 显式异常和隔离包的数量,
  • 重大变更区域的模块扇入和扇出,
  • 规则生效后消失的审查评论。

不要过度依赖数字。一个拥有五条切实有效规则的项目可能比一个充斥着五十条形式主义规则的项目更健康。最佳衡量标准是该方案能否及早发现代价高昂的结构性错误,并清晰地解释修复方案。

迁移指南

推广既是一个技术问题,也是一个社会问题。

  1. 盘点目前在评审中已执行的架构决策。
  2. 选择一条共识度高且修复路径清晰的规则。
  3. 通过引入并移除局部违规来验证该规则的失效。
  4. 如果存在现有违规,则在持续集成 (CI) 中以信息性方式运行该规则。
  5. 显式隔离遗留区域,而不是阻塞所有工作。
  6. 当新的违规很少发生且团队理解了该规则的失效原因后,再将其设为必需规则。
  7. 只有在前一条规则变得乏味之后,才添加下一条规则。

当测试阻止了原本不可见的快捷方式时,要做好应对阻力的准备。如果规则明确具体,那么这种讨论是有益的;但如果规则含糊不清,则会浪费时间。最初的规则应与团队已经意识到的痛点紧密相关:例如循环、功能实现耦合、平台泄漏或公共 DTO/实体暴露。

维护与演进

当架构发生变化时,架构测试也应该随之改变。

像对待其他公共合同一样,对重要规则进行版本控制:策略变更时重命名测试用例,迁移工作完成后移除排除项,如果团队需要时间迁移,则在发布窗口期内保留旧规则以供参考。如果某条规则积累了大量例外情况,则应安排规则审查,而不是添加新的过滤器。

良好的规则弃用示例如下:

  • 将旧规则标记为参考规则,
  • 在其旁边添加新规则,
  • 逐步迁移模块,
  • 一旦图与新策略匹配,则删除旧规则及其隔离列表。

这套方案应该描述你现在选择的架构,而不是你两年前希望拥有的架构。

入门套件

以下是一个模块化功能项目的简洁起点:

package com.acme
import io.github.baole.konture.Konture
import org.junit.jupiter.api.Test
class ArchitectureGuardrailsTest {
    @Test
    fun `module graph must not contain cycles`() {
        Konture.assertNoCycles()
    }
    @Test
    fun `feature API modules must not depend on feature implementation modules`() {
        Konture.modules {
            that().haveNameMatching(":feature:**:api")
            should().notDependOnModule(":feature:**:impl")
        }
    }
    @Test
    fun `feature implementations must not depend on sibling feature implementations`() {
        Konture.modules {
            that().haveNameMatching(":feature:**:impl")
            should().onlyDependOnModules(
                ":feature:**:api",
                ":core:**",
                ":shared",
            )
        }
    }
    @Test
    fun `domain classes must only depend on domain and standard library types`() {
        Konture.classes {
            that().resideInAPackage("..domain..")
            should().onlyDependOnClassesInAnyPackage(
                "..domain..",
                "kotlin..",
                "java..",
            )
        }
    }
    @Test
    fun `implementation classes must remain internal`() {
        Konture.classes {
            that().resideInAPackage("..impl..")
            should().beInternal()
        }
    }
}

保持入门套件的精简。让它从真正的痛点中成长:

  • 代码审查中发现的边界违规。
  • 扩大构建影响范围的模块依赖。
  • 导致重构成本增加的 DTO 泄漏。
  • 跨层的 AI 辅助补丁。
  • 难以移除的公共实现类。

架构测试在保护人们已经关心的决策时效果最佳。

成熟套件形状

成熟的套件未必很大。它是由一系列精心设计的方案层层递进而成:

class ArchitectureSuiteTest {
    @Test
    fun `project graph must stay acyclic`() {
        Konture.assertNoCycles()
    }
    @Test
    fun `feature modules expose contracts through api modules`() {
        Konture.architecture {
            modules {
                that().haveNameMatching(":feature:**:api")
                should().notDependOnModule(":feature:**:impl")
            }
            modules {
                that().haveNameMatching(":feature:**:impl")
                should().onlyDependOnModules(
                    ":feature:**:api",
                    ":core:**",
                    ":shared",
                )
            }
        }
    }
    @Test
    fun `domain stays independent from frameworks and persistence`() {
        Konture.architecture {
            modules {
                that().haveNamePath(":core:domain")
                should().notDependOnModule(":core:data")
            }
            classes {
                that().resideInAPackage("..domain..")
                should().notDependOnClassesInAnyPackage(
                    "..data..",
                    "..database..",
                    "..network..",
                    "android..",
                    "androidx.compose..",
                    "org.springframework..",
                )
            }
        }
    }
    @Test
    fun `shared kmp code stays platform independent`() {
        val commonClasses =
            Konture.scopeFromModule(":shared").classes.filter { cls ->
                cls.filePath.contains("/commonMain/")
            }
        commonClasses.assertTrue("commonMain must not import platform APIs") { cls ->
            cls.imports.none { it.startsWith("android.") || it.startsWith("java.awt.") }
        }
    }
}

该套件包含不同的任务:图健康性、功能所有权、域纯度和平台可移植性。每次失败都会告诉开发人员违反了哪个架构决策。

推广指南

对于现有项目,分阶段引入架构测试:

  1. 首先处理一些非争议性规则,例如模块循环和域到数据的依赖关系。
  2. 如果第一次测试发现大量违规,则在本地和持续集成 (CI) 环境中运行测试套件,以提供信息。
  3. 修复或明确隔离遗留的违规规则。
  4. 将高置信度的规则转化为 CI 必需的检查项。
  5. 像审查架构变更一样审查规则变更,而不是像审查格式调整一样审查规则变更。

对于生成的代码、测试用例和遗留系统迁移区域,建议明确排除某些内容:

konture {
    excludePackages("..generated..")
}

这个小写的 konture {} 代码块位于 Gradle 构建配置中,用于配置 Konture 插件。它与测试文件中使用的大写 Konture.* 断言 API 不同。

例外情况应该足够明显,以便未来的维护者能够理解真正的边界。

第一套规则稳定后,针对项目实际存在问题的领域添加规则:

  • 特性模块隔离。
  • KMP 源集可移植性。
  • 公共 API 泄漏。
  • DTO 和实体边界。
  • 路由或控制器依赖方向。
  • 依赖注入约定。
  • 旧版包隔离。

Konture并非针对某种特定架构风格的规范,而是一种使架构可执行的方法。

在本地运行。在持续集成环境中运行。让人工干预和人工智能辅助的修改获得相同的结构性反馈。

当结构至关重要时,将其融入建筑设计之中。

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

保护原创,请勿转载!