你的测试套件验证了 calculateTotal 在输入正确时返回 42。但它没有验证 src/domain/Invoice.ts 是否被允许导入 src/infrastructure/Database.ts。编译器对两者都满意。你的单元测试对两者都满意。但其中之一是架构违规,六个月后会让你花上一周时间来重构。

这就是盲区。我们为逻辑写测试,却假设结构会自行管好。并不会。

什么是架构测试

架构测试是对代码结构的断言,而非行为。它检查依赖图是否符合你们约定的设计。当开发人员导入了错误的层、创建了循环依赖,或者没有按照你们约定的后缀来命名仓库类时,它会让构建失败。

这些不是 lint 规则。Lint 捕获的是风格违规。架构测试捕获的是结构违规。区别很重要,因为结构具有语义。一个导入了其父模块的模块不是风格问题,而是设计问题。

大多数团队把这些规则记录在维基、README 或技术负责人置顶在 Slack 的消息里。文档对新人入职有用,对执行却毫无用处。架构测试把规则从人的记忆转移到构建流水线里,在这里忘记它的代价是构建变红,而不是生产事故。

你可以用测试强制执行什么

最明显的用例是依赖方向。Domain 不应该导入 Infrastructure。UI 不应该直接导入数据访问层。这些规则可以清晰地映射到包或目录边界。

但架构测试能检查的不仅是导入。以下是真正在生产的代码库中重要的模式。

循环依赖。 一个包通过另外三个包的链条导入自身,仍然是循环的。在代码审查中,你的眼睛不会发现它。一个遍历导入图的测试会。

命名规范。 如果你的团队决定每个仓库实现都必须以 Repository 结尾,测试可以强制执行。这听起来很学究,直到有人在同一代码库里创建了 UserDaoUserRepo,新工程师无法分辨该用哪个。

禁止依赖特定库。 也许你的 domain 层不允许依赖 axiospgfs。也许你的前端不允许导入 lodash,因为你们正在标准化为原生方法。架构测试可以断言特定包永远不会出现在特定模块的依赖树中。

注解和继承规则。 在 Java 中,你可以测试 ..domain.. 中的类都不使用 @Autowired。在 C# 中,你可以测试 Infrastructure 中的类不实现来自 Application 的接口。这些是静态分析本身无法在没有领域知识的情况下表达的结构约束。

如何写一个

最好的架构测试看起来就像普通的单元测试。它们在你现有的测试运行器里运行。它们出现在和其他测试相同的 CI job 中。唯一的区别是它们断言的内容。

Java 配合 ArchUnit:

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@AnalyzeClasses(packages = "com.mycompany")
class ArchitectureTest {

    @ArchTest
    static final ArchRule no_cycles =
        slices()
            .matching("com.mycompany.(*)..")
            .should().beFreeOfCycles();

    @ArchTest
    static final ArchRule domain_is_independent =
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infrastructure..");
}

这是一个 JUnit 测试。它通过 mvn test 运行。它会让构建失败。不需要特殊的 CI job。

C# 配合 NetArchTest:

using NetArchTest.Rules;
using Xunit;

public class ArchitectureTests
{
    [Fact]
    public void Domain_Should_Not_Depend_On_Infrastructure()
    {
        var result = Types.InCurrentDomain()
            .That()
            .ResideInNamespace("MyApp.Domain")
            .ShouldNot()
            .DependOnAny(Types.InNamespace("MyApp.Infrastructure"))
            .GetResult();

        Assert.True(result.IsSuccessful);
    }
}

Python 配合 import-linter:

Python 没有成熟的 ArchUnit 等价物,但 import-linter 提供了一个声明式配置,功能类似于测试:

# .importlinter
[importlinter:contract:domain-independent]
name = Domain does not import infrastructure
type = forbidden
source_modules =
    myapp.domain
forbidden_modules =
    myapp.infrastructure

在 CI 中运行 lint-imports。违规时以非零状态退出。

Go:自己写一个

Go 没有主流的架构测试库。大多数团队写一个遍历 AST 的小型测试:

import (
    "strings"
    "testing"

    "golang.org/x/tools/go/packages"
)

func TestDomainDoesNotImportInfrastructure(t *testing.T) {
    cfg := &packages.Config{
        Mode: packages.NeedImports | packages.NeedFiles,
    }
    pkgs, err := packages.Load(cfg, "./domain/...")
    if err != nil {
        t.Fatal(err)
    }

    for _, pkg := range pkgs {
        for path := range pkg.Imports {
            if strings.Contains(path, "/infrastructure/") {
                t.Errorf("domain package %s imports infrastructure: %s", pkg.PkgPath, path)
            }
        }
    }
}

就二十行。它活在你的测试套件里。它在每个 PR 上运行。这就是重点。你不需要框架。你需要的是一个断言。

没人谈论的权衡

架构测试不是免费的。它们引入了一类新的构建失败,而新的构建失败类别总是会带来摩擦。

它们比单元测试慢。 一个扫描五十万行代码库中每个导入的架构测试需要时间。不是几小时。几秒,有时几十秒。但这比典型的单元测试慢一个数量级。如果你把它们和快速的单元测试放在同一个 job 里运行,你就失去了让单元测试有价值的反馈循环。

务实的做法是把架构测试放在一个专门的 CI job 里,或者把它们标记为集成测试,在快速套件通过后再运行。规则仍然阻塞合并。它只是不会拖慢你本地的 npm test 等效命令。

规则太宽泛时会出现误报。 如果你禁止 domain 层从 node_modules 导入所有内容,你会破坏 date-fnszod 的合法使用。规则需要例外,例外需要维护。一个有三十个 pathNot 条目的规则不是在强制执行架构。它是在编码你当前的烂摊子。

它们可能带来虚假的信心。 通过架构测试不意味着你的设计是好的。它意味着你的设计符合你写的规则。如果规则是错的,测试只是自动化的 cargo culting。

如何在不让 CI 崩溃的情况下引入它们

不要从十条规则开始。从一条开始。挑选那个让你最痛苦的依赖方向。也许是 domain 导入 infrastructure。也许是你的前端直接导入后端代码。

写下测试。本地运行。统计失败数。如果数量是零,你要么非常自律,要么规则没有捕获到你想的东西。用一个故意的违规来验证。

如果数量是五十,你有一个选择。在一个英勇的 PR 里修复全部五十个,或者为现有违规添加例外并禁止新的。第二个选项不那么令人满意,但更可持续。

让测试使 CI 失败。不是警告。是失败。警告是工程师学会忽略的规则。

常见问题

架构测试会取代代码审查吗?

不会。它们自动化了代码审查中人类不擅长的部分,比如发现跨越二十个文件的传递性导入。人类在判断一个新依赖是否有意义上仍然更擅长。

微服务呢?

架构测试在单个可部署单元内效果最好。跨服务时,你通过 API 契约和部署隔离来强制边界,而不是导入图。

我应该测试命名规范吗?

只有在不一致造成真正混乱时才这样做。在十人团队中,强制执行 Repository 后缀的测试是有用的。在单人项目中,它可能只是噪音。

我能把它用在 monorepo 里吗?

可以。Nx、Bazel 和 Turborepo 都有模块边界强制。如果你已经在用其中一个,就用它内置的规则。它们运行更快,并且与依赖图集成。如果你没有,独立的架构测试是一个轻量级的入口。

从一条规则开始

你的代码库已经有了隐式的架构规则。它们存在于你的资深工程师的脑子里。它们在这位工程师没度假时代码审查中被强制执行。它们在截止日期前的晚上 11 点被违反。

把其中一条写成测试。让它变红。修复违规。让它阻塞 CI。

下次有人问:“我们可以从 domain 层导入 infrastructure 吗?” 答案不会在维基里。它会在一个失败的构建里。