テストスイートは、calculateTotalが正しい入力を与えられたときに42を返すことを検証する。しかし、src/domain/Invoice.tssrc/infrastructure/Database.tsをimportしてよいかどうかは検証しない。コンパイラはどちらも問題ない。ユニットテストもどちらも問題ない。だが、一方はアーキテクチャ違反であり、6か月後に1週間のリファクタリングを強いることになる。

これは盲点だ。我々はロジックのためのテストを書き、構造は勝手に整うと考えている。そうはならない。

アーキテクチャテストとは実際には何か

アーキテクチャテストは、コードの振る舞いではなく構造に関するアサーションだ。依存グラフが合意した設計と一致するかをチェックする。開発者が間違ったレイヤーをimportしたり、循環依存を作ったり、標準化したサフィックスなしでリポジトリクラスを命名したりすると、ビルドを失敗させる。

これらはlintルールではない。Lintはスタイル違反を捕捉する。アーキテクチャテストは構造違反を捕捉する。この違いは重要だ。なぜなら構造にはセマンティクスがあるからだ。親をimportするモジュールはスタイルの問題ではない。設計の問題だ。

多くのチームはこれらのルールをWikiやREADME、あるいはテックリードがピン留めしたSlackメッセージに記載する。ドキュメントはオンボーディングには有用だ。だが強制力はない。アーキテクチャテストはルールを人間の記憶からビルドパイプラインに移す。そこでは、忘れることは本番インシデントではなく赤いビルドを生む。

テストで強制できること

最も明白なユースケースは依存の方向だ。Domainがinfrastructureをimportすべきではない。UIがdata accessを直接importすべきではない。これらのルールはパッケージやディレクトリの境界にきれいにマッピングされる。

しかし、アーキテクチャテストはimport以上のことをチェックできる。本番コードベースで実際に重要なパターンをいくつか挙げる。

循環依存。 3つの他のパッケージを経由して自分自身をimportするパッケージも、それは依然として循環依存だ。コードレビューで目で捉えることはできない。importグラフを走査するテストならできる。

命名規約。 チームがすべてのリポジトリ実装の末尾にRepositoryをつけると決めたなら、テストで強制できる。堅苦しく聞こえるかもしれないが、誰かが同じコードベースにUserDaoUserRepoを作り、新しいエンジニアがどちらを使えばいいかわからなくなるまで気づかない。

特定ライブラリへの依存禁止。 たとえば、domainレイヤーがaxiospgfsに依存してはいけないかもしれない。フロントエンドがlodashをimportしてはいけないかもしれない。なぜならネイティブメソッドに統一しているからだ。アーキテクチャテストは、特定のパッケージが特定のモジュールの依存ツリーに決して現れないことをアサートできる。

アノテーションと継承のルール。 Javaでは、..domain..内のクラスが@Autowiredを使わないことをテストできる。C#では、Infrastructure内のクラスがApplicationのインターフェースを実装しないことをテストできる。これらは、ドメイン知識なしには静的解析だけでは表現できない構造的制約だ。

書き方

最高のアーキテクチャテストは普通のユニットテストのように見える。既存のテストランナーで動く。他のテストと同じCIジョブに存在する。唯一の違いは、何をアサートするかだ。

Java with 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ジョブは不要だ。

C# with 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 with 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)
            }
        }
    }
}

20行だ。テストスイートに存在する。すべてのPRで実行される。それが重要な点だ。フレームワークは必要ない。アサーションが必要だ。

誰も語らないトレードオフ

アーキテクチャテストは無料ではない。新しいカテゴリのビルド失敗を生み出し、新しいビルド失敗のカテゴリは常に摩擦を生む。

ユニットテストより遅い。 50万行のコードベースですべてのimportをスキャンするアーキテクチャテストは時間がかかる。時間単位ではない。秒単位、時には十数秒だ。しかし、それは典型的なユニットテストよりも1桁遅い。高速なユニットテストと同じジョブで実行すれば、ユニットテストを価値あるものにするフィードバックループを失う。

実用的な分離は、専用のCIジョブでアーキテクチャテストを実行するか、インテグレーションテストとしてタグ付けして高速スイート通過後に実行することだ。ルールは依然としてマージをブロックする。ただ、ローカルのnpm test相当を遅くしないだけだ。

ルールが広すぎると偽陽性が生じる。 domainレイヤーでnode_modulesからのすべてのimportを禁止すれば、date-fnszodの正当な使用を破壊する。ルールには例外が必要で、例外にはメンテナンスが必要だ。30のpathNotエントリを持つルールはアーキテクチャを強制していない。現在の混乱をエンコードしているだけだ。

偽の安心感を与えることがある。 アーキテクチャテストが通過しても、設計が優れているとは限らない。書いたルールに設計が一致しているだけだ。ルールが間違っていれば、テストは単なる自動化されたカーゴカルトに過ぎない。

CIを壊さずに導入する方法

10のルールから始めてはいけない。1つから始めろ。最も痛みを伴った依存の方向を選べ。domainがinfrastructureをimportすることかもしれない。フロントエンドがバックエンドコードを直接importすることかもしれない。

テストを書け。ローカルで実行せよ。失敗の数を数えろ。数がゼロなら、あなたは非常に規律正しいか、ルールが思っていることを捕捉していないかのどちらかだ。意図的な違反で検証せよ。

数が50なら、選択肢がある。1つの英雄的なPRですべてを修正するか、既存の違反に例外を追加して新たなものを禁止するかだ。2番目の選択肢は満足感は低いが、より持続可能だ。

テストがCIを失敗させろ。警告ではなく。失敗だ。警告は、エンジニアが無視することを学ぶルールだ。

FAQ

アーキテクチャテストはコードレビューを代替するか?

いいえ。20ファイルにまたがる推移的なimportを見つけるなど、人間が苦手とするコードレビューの部分を自動化するだけだ。新しい依存が妥当かどうかを判断するのは、依然として人間の方が得意だ。

マイクロサービスではどうか?

アーキテクチャテストは単一のデプロイ可能なユニット内で最も効果を発揮する。サービス間では、importグラフではなくAPIコントラクトとデプロイメントの分離で境界を強制する。

命名規約をテストすべきか?

一貫性の欠如が実際の混乱を引き起こす場合にのみだ。Repositoryサフィックスを強制するテストは10人の開発者チームでは有用だ。個人プロジェクトではノイズに過ぎないだろう。

モノレポで使えるか?

はい。Nx、Bazel、Turborepoはすべてモジュール境界の強制機能を持つ。すでに使っているなら、そのビルトインルールを使え。より高速に動作し、依存グラフと統合される。使っていないなら、スタンドアロンのアーキテクチャテストが軽量な入り口だ。

1つのルールから始めろ

あなたのコードベースにはすでに暗黙のアーキテクチャルールが存在する。それらはシニアエンジニアの頭の中にある。そのエンジニアが休暇でない時のコードレビューで強制されている。締め切り前の夜11時に違反されている。

そのうちの1つをテストとして書き留めろ。赤にせよ。違反を修正せよ。CIをブロックさせろ。

次に誰かが「domainレイヤーからinfrastructureをimportしてもいいのか?」と尋ねた時、答えはWikiにはない。失敗するビルドの中にある。