Test suite Anda memverifikasi bahwa calculateTotal mengembalikan 42 ketika diberi input yang tepat. Ia tidak memverifikasi bahwa src/domain/Invoice.ts diizinkan untuk mengimpor src/infrastructure/Database.ts. Compiler puas dengan keduanya. Unit test Anda puas dengan keduanya. Tapi salah satunya adalah pelanggaran arsitektur yang akan membuat Anda menghabiskan satu minggu untuk refactoring enam bulan ke depan.

Ini adalah titik buta. Kita menulis tes untuk logika dan menganggap struktur akan mengurus dirinya sendiri. Nyatanya tidak.

Apa sebenarnya architecture test itu

Architecture test adalah asersi tentang struktur kode Anda, bukan perilakunya. Ia memeriksa bahwa dependency graph sesuai dengan desain yang telah Anda sepakati. Ia membuat build gagal ketika seorang developer mengimpor layer yang salah, membuat circular dependency, atau menamai class repository tanpa suffix yang telah Anda standarisasi.

Ini bukan lint rules. Linting menangkap pelanggaran gaya. Architecture test menangkap pelanggaran struktural. Perbedaannya penting karena struktur memiliki semantik. Sebuah module yang mengimpor parent-nya bukan masalah gaya. Itu adalah masalah desain.

Kebanyakan tim mendokumentasikan aturan-aturan ini di wiki, README, atau pesan Slack yang dipin oleh tech lead. Dokumentasi berguna untuk onboarding. Ia tidak berguna untuk enforcement. Architecture test memindahkan aturan dari ingatan manusia ke build pipeline, di mana melupakannya membuat build merah alih-alih production incident.

Apa yang bisa Anda enforce dengan sebuah tes

Use case yang paling jelas adalah dependency direction. Domain tidak boleh mengimpor infrastructure. UI tidak boleh mengimpor data access secara langsung. Aturan-aturan ini dipetakan dengan rapi ke batasan package atau direktori.

Tapi architecture test bisa memeriksa lebih dari sekadar impor. Berikut adalah pola-pola yang sebenarnya penting di production codebases.

Cyclic dependencies. Sebuah package yang mengimpor dirinya sendiri melalui rantai tiga package lain tetaplah cyclic. Mata Anda tidak akan menangkapnya di code review. Sebuah tes yang melintasi import graph akan menangkapnya.

Naming conventions. Jika tim Anda memutuskan bahwa setiap implementasi repository harus diakhiri dengan Repository, sebuah tes bisa mengenforce hal itu. Ini terdengar pedantis sampai seseorang membuat UserDao dan UserRepo di codebase yang sama dan engineer baru tidak bisa membedakan mana yang harus digunakan.

Forbidden dependencies pada library tertentu. Mungkin domain layer Anda tidak diizinkan untuk bergantung pada axios, pg, atau fs. Mungkin frontend Anda tidak diizinkan mengimpor lodash karena Anda sedang menstandarisasi pada metode native. Architecture test bisa mengasumsikan bahwa package tertentu tidak pernah muncul di dependency tree module tertentu.

Aturan annotation dan inheritance. Di Java, Anda bisa menguji bahwa tidak ada class di ..domain.. yang menggunakan @Autowired. Di C#, Anda bisa menguji bahwa tidak ada class di Infrastructure yang mengimplementasikan interface dari Application. Ini adalah structural constraints yang static analysis saja tidak bisa ekspresikan tanpa domain knowledge.

Cara menulisnya

Architecture test terbaik terlihat seperti unit test biasa. Mereka berjalan di test runner yang sudah Anda miliki. Mereka muncul di CI job yang sama dengan tes lainnya. Satu-satunya perbedaan adalah apa yang mereka asersikan.

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..");
}

Ini adalah JUnit test. Ia berjalan dengan mvn test. Ia membuat build gagal. Tidak diperlukan CI job khusus.

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 tidak memiliki ArchUnit equivalent yang matang, tapi import-linter memberi Anda konfigurasi deklaratif yang berfungsi seperti sebuah tes:

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

Jalankan dengan lint-imports di CI. Ia keluar dengan nonzero saat terjadi pelanggaran.

Go: tulis sendiri

Go tidak memiliki library architecture testing yang mainstream. Kebanyakan tim menulis tes kecil yang melintasi 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)
            }
        }
    }
}

Ini hanya dua puluh baris. Ia tinggal di test suite Anda. Ia berjalan di setiap PR. Itulah intinya. Anda tidak memerlukan framework. Anda memerlukan asersi.

Trade-off yang tidak dibicarakan siapa pun

Architecture test tidak gratis. Mereka memperkenalkan kategori baru kegagalan build, dan kategori baru kegagalan build selalu menciptakan gesekan.

Mereka lebih lambat dari unit test. Sebuah architecture test yang memindai setiap impor di codebase 500.000 baris membutuhkan waktu. Bukan jam. Detik, kadang puluhan detik. Tapi itu adalah satu orde lebih lambat dari unit test tipikal. Jika Anda menjalankannya di job yang sama dengan unit test cepat Anda, Anda kehilangan feedback loop yang membuat unit testing berharga.

Pembagian pragmatis adalah menjalankan architecture test di CI job yang didedikasikan, atau menandainya sebagai integration test dan menjalankannya setelah fast suite lolos. Aturan tersebut tetap memblokir merge. Ia hanya tidak melambatkan npm test lokal Anda.

False positive terjadi ketika aturannya terlalu luas. Jika Anda melarang semua impor dari node_modules di domain layer Anda, Anda akan merusak penggunaan yang sah dari date-fns atau zod. Aturan membutuhkan pengecualian, dan pengecualian membutuhkan pemeliharaan. Sebuah aturan dengan tiga puluh entri pathNot bukanlah menenforce arsitektur. Ia sedang meng-encode kekacauan Anda saat ini.

Mereka bisa memberikan kepercayaan diri yang palsu. Lolos architecture test tidak berarti desain Anda bagus. Itu berarti desain Anda cocok dengan aturan yang Anda tulis. Jika aturannya salah, tes-nya hanyalah cargo culting yang terotomasi.

Cara menambahkannya tanpa merusak CI

Jangan mulai dengan sepuluh aturan. Mulailah dengan satu. Pilih dependency direction yang paling banyak menyebabkan rasa sakit. Mungkin itu domain yang mengimpor infrastructure. Mungkin itu frontend Anda yang mengimpor kode backend secara langsung.

Tulis tesnya. Jalankan secara lokal. Hitung kegagalannya. Jika jumlahnya nol, Anda sangat disiplin atau aturannya tidak menangkap apa yang Anda pikir. Verifikasi dengan pelanggaran yang disengaja.

Jika jumlahnya lima puluh, Anda punya pilihan. Perbaiki semua lima puluh dalam satu PR heroik, atau tambahkan pengecualian untuk pelanggaran yang sudah ada dan larang yang baru. Opsi kedua kurang memuaskan dan lebih sustainable.

Buat tesnya membuat CI gagal. Bukan memperingatkan. Gagal. Peringatan adalah aturan yang engineer pelajari untuk diabaikan.

FAQ

Apakah architecture test menggantikan code review?

Tidak. Mereka mengotomasi bagian-bagian code review yang tidak dikuasai manusia, seperti menemukan transitive imports di seluruh dua puluh file. Manusia masih lebih baik dalam menilai apakah dependency baru masuk akal.

Bagaimana dengan microservices?

Architecture test bekerja paling baik di dalam satu deployable unit. Di lintas services, Anda menenforce batasan dengan API contracts dan deployment isolation, bukan import graphs.

Haruskah saya menguji naming conventions?

Hanya jika inkonsistensi menyebabkan kebingungan yang nyata. Sebuah tes yang mengenforce suffix Repository berguna di tim sepuluh developer. Ini mungkin hanya noise di proyek solo.

Bisakah saya menggunakan ini dengan monorepos?

Ya. Nx, Bazel, dan Turborepo semuanya memiliki module boundary enforcement. Jika Anda sudah menggunakan salah satunya, gunakan built-in rules-nya. Mereka berjalan lebih cepat dan terintegrasi dengan dependency graph. Jika tidak, architecture test standalone adalah entry point yang ringan.

Mulai dengan satu aturan

Codebase Anda sudah memiliki aturan arsitektur implisit. Mereka tinggal di kepala engineer senior Anda. Mereka dienforce di code review ketika engineer itu tidak sedang berlibur. Mereka dilanggar jam 11 malam sebelum deadline.

Tuliskan salah satunya sebagai sebuah tes. Buat menjadi merah. Perbaiki pelanggarannya. Buat ia memblokir CI.

Saat lain kali seseorang bertanya, “Bolehkah kita mengimpor infrastructure dari domain layer?” jawabannya tidak akan ada di wiki. Ia akan ada di build yang gagal.