Votre suite de tests vérifie que calculateTotal retourne 42 quand on lui donne la bonne entrée. Elle ne vérifie pas que src/domain/Invoice.ts est autorisé à importer src/infrastructure/Database.ts. Le compilateur est content dans les deux cas. Vos tests unitaires sont contents dans les deux cas. Mais l’un des deux est une violation architecturale qui vous coûtera une semaine de refactoring dans six mois.

C’est l’angle mort. On écrit des tests pour la logique et on suppose que la structure se gère toute seule. Ce n’est pas le cas.

Ce qu’est réellement un test d’architecture

Un test d’architecture est une assertion sur la structure de votre code, pas sur son comportement. Il vérifie que le graphe de dépendances correspond au design sur lequel vous vous êtes mis d’accord. Il fait échouer le build quand un développeur importe la mauvaise couche, crée une dépendance circulaire, ou nomme une classe de repository sans le suffixe que vous avez standardisé.

Ce ne sont pas des règles de lint. Le linting attrape les violations de style. Les tests d’architecture attrapent les violations structurelles. La différence compte parce que la structure a une sémantique. Un module qui importe son parent n’est pas un problème de style. C’est un problème de design.

La plupart des équipes documentent ces règles dans un wiki, un README, ou un message Slack épinglé par le tech lead. La documentation est utile pour l’onboarding. Elle est inutile pour l’application. Un test d’architecture déplace la règle de la mémoire humaine dans le pipeline de build, où l’oublier coûte un build rouge au lieu d’un incident en production.

Ce que vous pouvez imposer avec un test

Le cas d’usage évident est la direction des dépendances. Domain ne devrait pas importer infrastructure. UI ne devrait pas importer l’accès aux données directement. Ces règles se mappent proprement aux frontières de package ou de répertoire.

Mais les tests d’architecture peuvent vérifier bien plus que les imports. Voici des patterns qui comptent vraiment dans des codebases en production.

Dépendances cycliques. Un package qui s’importe lui-même à travers une chaîne de trois autres packages est toujours cyclique. Vos yeux ne l’attraperont pas lors d’une code review. Un test qui traverse le graphe d’imports, si.

Conventions de nommage. Si votre équipe a décidé que chaque implémentation de repository doit se terminer par Repository, un test peut l’imposer. Ça fait pédant jusqu’à ce que quelqu’un crée UserDao et UserRepo dans la même codebase et que les nouveaux ingénieurs ne sachent pas lequel utiliser.

Dépendances interdites sur des librairies spécifiques. Peut-être que votre couche domaine n’est pas autorisée à dépendre de axios, pg, ou fs. Peut-être que votre frontend n’est pas autorisé à importer lodash parce que vous standardisez sur les méthodes natives. Un test d’architecture peut affirmer qu’un package spécifique n’apparaît jamais dans l’arbre de dépendances d’un module spécifique.

Règles d’annotation et d’héritage. En Java, vous pouvez tester qu’aucune classe dans ..domain.. n’utilise @Autowired. En C#, vous pouvez tester qu’aucune classe dans Infrastructure n’implémente une interface de Application. Ce sont des contraintes structurelles que l’analyse statique seule ne peut pas exprimer sans connaissance métier.

Comment en écrire un

Les meilleurs tests d’architecture ressemblent à des tests unitaires ordinaires. Ils tournent dans votre test runner existant. Ils apparaissent dans le même job CI que vos autres tests. La seule différence est ce sur quoi ils affirment.

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

C’est un test JUnit. Il tourne avec mvn test. Il fait échouer le build. Aucun job CI spécial requis.

C# avec 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 avec import-linter :

Python n’a pas d’équivalent ArchUnit mature, mais import-linter vous donne une config déclarative qui fonctionne comme un test :

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

Exécutez-le avec lint-imports dans la CI. Il exit avec un code non-nul en cas de violation.

Go : écrivez le vôtre

Go n’a pas de librairie mainstream de test d’architecture. La plupart des équipes écrivent un petit test qui parcourt l’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)
            }
        }
    }
}

C’est vingt lignes. Il vit dans votre suite de tests. Il tourne à chaque PR. C’est le point. Vous n’avez pas besoin d’un framework. Vous avez besoin d’une assertion.

Les compromis dont personne ne parle

Les tests d’architecture ne sont pas gratuits. Ils introduisent une nouvelle catégorie d’échec de build, et les nouvelles catégories d’échec de build créent toujours de la friction.

Ils sont plus lents que les tests unitaires. Un test d’architecture qui scanne chaque import dans une codebase de 500 000 lignes prend du temps. Pas des heures. Des secondes, parfois des dizaines de secondes. Mais c’est un ordre de grandeur plus lent qu’un test unitaire typique. Si vous les faites tourner dans le même job que vos tests unitaires rapides, vous perdez la boucle de feedback qui rend le testing unitaire précieux.

La division pragmatique est de faire tourner les tests d’architecture dans un job CI dédié, ou de les taguer comme tests d’intégration et de les faire tourner après que la suite rapide a passé. La règle bloque toujours le merge. Elle ne ralentit juste pas votre npm test local équivalent.

Les faux positifs arrivent quand la règle est trop large. Si vous interdisez tous les imports depuis node_modules dans votre couche domaine, vous casserez les usages légitimes de date-fns ou zod. Les règles ont besoin d’exceptions, et les exceptions ont besoin de maintenance. Une règle avec trente entrées pathNot n’impose pas l’architecture. Elle encode votre bazar actuel.

Ils peuvent donner une fausse confiance. Passer un test d’architecture ne veut pas dire que votre design est bon. Ça veut dire que votre design correspond aux règles que vous avez écrites. Si les règles sont mauvaises, les tests ne sont que du cargo culting automatisé.

Comment les ajouter sans casser la CI

Ne commencez pas avec dix règles. Commencez par une. Choisissez la direction de dépendance qui vous a causé le plus de douleur. Peut-être que c’est domain qui importe infrastructure. Peut-être que c’est votre frontend qui importe du code backend directement.

Écrivez le test. Faites-le tourner localement. Comptez les échecs. Si le compte est zéro, vous êtes soit très discipliné, soit la règle n’attrape pas ce que vous pensez. Vérifiez avec une violation délibérée.

Si le compte est cinquante, vous avez le choix. Corrigez les cinquante dans une PR héroïque, ou ajoutez des exceptions pour les violations existantes et interdisez les nouvelles. La deuxième option est moins satisfaisante et plus durable.

Faites échouer le test en CI. Pas d’avertissement. D’échec. Un avertissement est une règle que les ingénieurs apprennent à ignorer.

FAQ

Les tests d’architecture remplacent-ils la code review ?

Non. Ils automatisent les parties de la code review où les humains sont mauvais, comme repérer des imports transitifs à travers vingt fichiers. Les humains sont toujours meilleurs pour juger si une nouvelle dépendance a du sens.

Et les microservices ?

Les tests d’architecture fonctionnent le mieux dans une unité déployable unique. Entre services, vous imposez des frontières avec des contrats d’API et de l’isolation de déploiement, pas avec des graphes d’imports.

Devrais-je tester les conventions de nommage ?

Seulement si l’incohérence cause une vraie confusion. Un test qui impose les suffixes Repository est utile dans une équipe de dix développeurs. C’est probablement du bruit dans un projet solo.

Puis-je utiliser ça avec des monorepos ?

Oui. Nx, Bazel, et Turborepo ont tous une enforcement de frontières de modules. Si vous en utilisez déjà un, utilisez ses règles built-in. Elles tournent plus vite et s’intègrent avec le graphe de dépendances. Si ce n’est pas le cas, un test d’architecture standalone est le point d’entrée léger.

Commencez par une règle

Votre codebase a déjà des règles d’architecture implicites. Elles vivent dans la tête de votre ingénieur senior. Elles sont imposées en code review quand cet ingénieur n’est pas en vacances. Elles sont violées à 23 h avant une deadline.

Écrivez l’une d’elles comme un test. Rendez-la rouge. Corrigez les violations. Faites-la bloquer la CI.

La prochaine fois que quelqu’un demandera : « Est-ce qu’on est autorisé à importer infrastructure depuis la couche domaine ? », la réponse ne sera pas dans un wiki. Elle sera dans un build en échec.