你的測試套件會驗證 calculateTotal 在給定正確輸入時回傳 42。但它不會驗證 src/domain/Invoice.ts 是否被允許 import src/infrastructure/Database.ts。編譯器對兩者都接受。你的單元測試對兩者也接受。但其中之一是架構違規,六個月後會讓你花上一整週重構。
這就是盲點。我們為邏輯寫測試,卻假設結構會自動維持。它不會。
什麼是架構測試
架構測試是對程式碼結構的斷言,而非行為。它檢查依賴圖是否符合你們共同決定的設計。當開發者 import 了錯誤的層、建立了循環依賴,或是命名 repository 類別時沒有加上約定的後綴,它就會讓建置失敗。
這些不是 lint 規則。Lint 抓的是風格違規。架構測試抓的是結構違規。這個差別很重要,因為結構具有語意。一個模組 import 了它的上層模組,這不是風格問題,而是設計問題。
大多數團隊把這些規則記錄在 wiki、README,或是 tech lead 釘選的 Slack 訊息裡。文件對新人報到很有用,對強制執行則毫無用處。架構測試把規則從人的記憶搬到建置流程裡,忘記它的代價是建置失敗,而不是生產環境的事故。
測試可以強制執行什麼
最明顯的用途是依賴方向。Domain 不應該 import infrastructure。UI 不應該直接 import data access。這些規則可以清楚地對應到 package 或目錄的邊界。
但架構測試能檢查的不只是 import。以下是真正在生產環境的程式碼庫中重要的模式。
循環依賴。 一個 package 透過另外三個 package 的鏈條間接 import 自己,仍然是循環依賴。你的眼睛在 code review 中看不出來,但一個會遍歷 import 圖的測試可以。
命名慣例。 如果你的團隊決定每個 repository 實作都必須以 Repository 結尾,測試就能強制執行。這聽起來很吹毛求疵,直到有人在同一份程式碼庫裡同時建立了 UserDao 和 UserRepo,讓新進工程師不知道該用哪一個。
禁止依賴特定函式庫。 也許你的 domain 層不能依賴 axios、pg 或 fs。也許你的 frontend 不能 import lodash,因為你們正在統一使用原生方法。架構測試可以斷言某個 package 絕對不會出現在特定模組的依賴樹中。
Annotation 與繼承規則。 在 Java 中,你可以測試 ..domain.. 裡的 class 沒有使用 @Autowired。在 C# 中,你可以測試 Infrastructure 裡的 class 沒有實作來自 Application 的 interface。這些是結構限制,單靠靜態分析沒有領域知識是無法表達的。
怎麼寫
最好的架構測試看起來就像普通的單元測試。它們在你的既有 test runner 裡執行,出現在和其他測試相同的 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 都會執行。這才是重點。你不需要框架,你需要的是一個斷言。
沒人告訴你的權衡
架構測試不是免費的。它們引入了新的建置失敗類型,而新的建置失敗類型總是會帶來摩擦。
它們比單元測試慢。 一個掃描五十萬行程式碼中每個 import 的架構測試需要時間。不是幾小時,是幾秒,有時幾十秒。但這已經比典型的單元測試慢上一個數量級。如果你把它們和快速的單元測試放在同一個 job 裡執行,就會失去讓單元測試有價值的快速回饋迴路。
務實的做法是將架構測試放在獨立的 CI job 中執行,或是把它們標記為整合測試,在快速套件通過之後再執行。規則仍然會阻擋合併,只是不會拖慢你本地 npm test 之類的指令。
規則太寬鬆時會出現誤報。 如果你禁止 domain 層從 node_modules import 任何東西,你就會破壞 date-fns 或 zod 的合法使用。規則需要例外,而例外需要維護。一條有三十個 pathNot 項目的規則不是在強制執行架構,而是在把現在的混亂編碼成規則。
它們可能帶來虛假的信心。 架構測試通過不代表你的設計是好的。它只代表你的設計符合你寫的規則。如果規則是錯的,測試就只是自動化的 cargo culting。
怎麼加入而不搞壞 CI
不要從十條規則開始。從一條開始。選擇讓你最痛苦的依賴方向。也許是 domain import infrastructure。也許是你的 frontend 直接 import backend 程式碼。
寫好測試。在本地執行。計算失敗數。如果數量是零,你要麼紀律極佳,要麼這條規則沒有抓到你想抓的東西。用一個刻意的違規來驗證。
如果數量是五十,你有兩個選擇。在一次英勇的 PR 裡全部修好,或是為現有違規加上例外、只禁止新的違規。第二個選項比較不痛快,但比較可持續。
讓測試在 CI 中失敗。不是警告,是失敗。警告是一種工程師會學會忽略的規則。
常見問題
架構測試會取代 code review 嗎?
不會。它們自動化的是人類不擅長的 code review 環節,例如在二十個檔案之間發現傳遞性 import。但人類在判斷一個新的依賴是否合理這件事上仍然更強。
那微服務呢?
架構測試最適合在單一可部署單元內使用。跨服務的邊界應該用 API 合約和部署隔離來強制執行,而不是 import 圖。
我應該測試命名慣例嗎?
只有在不一致真的會造成困擾時才需要。一個強制 Repository 後綴的測試對十人團隊有用,對單人專案可能只是噪音。
我可以把這用在 monorepo 嗎?
可以。Nx、Bazel 和 Turborepo 都有模組邊界強制執行的功能。如果你已經在使用其中之一,就用它內建的規則。它們執行更快,也能整合依賴圖。如果你沒有在用,獨立的架構測試就是輕量的入門方式。
從一條規則開始
你的程式碼庫已經有隱含的架構規則。它們活在資深工程師的腦袋裡。它們在那位工程師沒放假的 code review 中被強制執行。它們在截止日前的晚上十一點被違反。
把其中一條寫成測試。讓它變紅。修好違規。讓它阻擋 CI。
下一次有人問:「我們可以從 domain 層 import infrastructure 嗎?」答案不會在 wiki 裡。它會在一個失敗的建置中。