Dev (Back & Front)

6 jul, 2026

Testes de arquitetura para Java com ArchUnit: Pare de confiar no seu diagrama, comece a testá-lo.

Publicidade

Seu diagrama de arquitetura indica que os controladores nunca acessam o repositório diretamente. Seu código-fonte discorda.

Acontece silenciosamente. Alguém está com pressa, injeta uma CourseRepositorylinha direta em um controlador para pular um método de serviço, e o pull request é aprovado porque os testes passam e a alteração é pequena. O diagrama na wiki ainda mostra um fluxo limpo de três camadas. O código já não corresponde a ele. Seis meses e quarenta atalhos depois, a “arquitetura em camadas” é uma sugestão, e ninguém consegue dizer quais regras ainda são válidas.

A organização em camadas é apenas uma das formas que esse problema assume. Se você organizar seu código por pacote, por funcionalidade, em vez de por camada, a mesma erosão ocorre nas fronteiras entre funcionalidades: o pacote de infraestrutura compartilhada começa a importar classes de funcionalidades, os controladores começam a retornar entidades JPA em vez de DTOs, e o que deveria ser um conjunto de limites claros se transforma silenciosamente em uma confusão. A boa notícia é que a mesma ferramenta resolve todos esses problemas, e você não precisa de pacotes de camadas para impor regras de camadas.

O teste de arquitetura é a forma de preencher essa lacuna. Em vez de documentar suas regras em um diagrama que ninguém lê, você as codifica como testes executáveis ​​que falham na compilação no momento em que alguém as viola. A regra deixa de ser uma convenção que as pessoas devem memorizar e se torna um sensor que é executado a cada commit.

Antes de prosseguirmos, é importante fazer a seguinte distinção, que se conecta aos meus posts anteriores sobre engenharia de harnesses e testes de mutação com PIT : ArchUnit não é uma nova categoria de teste que você adiciona como um complemento. É uma função de aptidão , um sensor de feedback computacional que observa sua base de código e informa se ela ainda respeita a estrutura que você projetou. Enquanto o PIT protege o comportamento do seu código, o ArchUnit protege sua forma.

O ArchUnit é a biblioteca de testes de arquitetura mais utilizada para a JVM. Ele funciona como testes JUnit comuns, não precisa de um executor específico e lê as classes compiladas para inferir sobre pacotes, dependências e nomenclatura. Este artigo mostra como configurar o ArchUnit em um projeto Spring Boot com Maven e JUnit 5, escrever regras que realmente se mostram úteis, adotá-lo em um código legado sem se afogar em violações e executá-lo em CI.

Quer o código? Todas as regras deste post vêm de um projeto real, a API Spring Boot do meu exemplo CRUD full-stack: loiane/crud-angular-spring

Neste post, abordaremos:

  • Por que os diagramas de arquitetura se deterioram e quais medidas de teste de arquitetura utilizam esse recurso.
  • Como o ArchUnit lê seu bytecode para entender a estrutura
  • Configurando o ArchUnit em um projeto Spring Boot + Maven + JUnit 5 (incluindo a pegadinha do Surefire que ignora silenciosamente todas as regras)
  • As categorias de regras que abrangem a maioria das necessidades reais são: camadas, dependências, nomenclatura, ciclos e disciplina DTO.
  • Impor regras de camada em uma base de código com estrutura de pacote por recurso, onde não há pacotes de camada para apontar.
  • O fato de o mesmo agente de IA redigir ambos os lados de um contrato é exatamente o problema que o ArchUnit identifica.
  • Ao ler os resultados, incluindo o relatório HTML que mostra exatamente o que uma regra detectou, é importante analisar o caso.
  • Adotando o ArchUnit em uma base de código legada com freeze, o portão unidirecional que o mantém honesto
  • Executando testes de arquitetura no GitHub Actions como parte do processo de compilação normal.

Por que os diagramas de arquitetura se deterioram

Um diagrama responde a uma pergunta: qual era a nossa intenção com a estrutura? Mas nunca responde à pergunta que realmente importa no sexto mês: qual é a estrutura neste momento?

A lacuna é estrutural, não acidental. Um diagrama é um instantâneo tirado durante a fase de projeto. O código muda todos os dias. Nada conecta os dois, então eles se distanciam no momento em que o primeiro atalho é implementado. O diagrama é uma orientação preditiva sem nenhum sensor de feedback acoplado, e orientação sem sensor é apenas uma esperança.

Considere uma regra de camadas padrão do Spring Boot: controladores dependem de serviços, serviços dependem de repositórios e nada se propaga para trás. A regra diz que um controlador deve chamar um serviço, nunca um repositório diretamente. Aqui está o atalho que a quebra, e nada no compilador ou no conjunto de testes o impede:

@RestController
public class CourseController {

    private final CourseRepository repository;

    public CourseController(CourseRepository repository) {
        this.repository = repository;
    }
}

Isso compila. Os testes unitários passam. A revisão de código pode detectar o problema, caso o revisor conheça a regra e esteja prestando atenção em uma tarde de sexta-feira. Os testes de arquitetura o detectam sempre, de forma determinística, antes da mesclagem.

A ideia formal vem da arquitetura evolutiva: uma função de aptidão de arquitetura é uma verificação objetiva e automatizada de que uma determinada característica arquitetural ainda se mantém. Uma regra de camadas expressa como um teste ArchUnit é uma função de aptidão. Ela converte uma regra que você espera que as pessoas sigam em uma regra que a compilação impõe .

Como funciona o ArchUnit

O ArchUnit importa suas classes compiladas e constrói um modelo em memória da sua base de código: classes, pacotes, métodos, campos e as dependências entre eles. Em seguida, você escreve regras com base nesse modelo usando uma API fluente, e cada regra é executada como um teste JUnit normal.

Não há reescrita de bytecode nem um executor separado. O ArchUnit lê os .classarquivos no classpath de teste, portanto, vê exatamente o que o compilador produziu, incluindo dependências que você não escreveu explicitamente. Como é apenas o JUnit, uma violação de arquitetura aparece como um teste com falha no mesmo relatório que todos os outros.

Três coisas que vale a pena internalizar de antemão:

Conceito O que significa
Importar O ArchUnit examina um conjunto de pacotes e constrói um JavaClassesmodelo uma vez por classe de teste.
Regra Uma expressão fluente como noClasses().that()...should()...essa ou se sustenta ou falha.
Violação Um par específico de classe e dependência que viola uma regra, relatado com a linha exata.

Ao contrário dos testes de mutação, a análise de arquitetura é barata. Importar alguns milhares de classes e avaliar um punhado de regras leva um ou dois segundos. Esse simples fato altera a localização do sensor: o PIT deve ser executado por trás de um perfil Maven lento, mas o ArchUnit é rápido o suficiente para ser executado na mvn testfase normal de cada build. Mais detalhes sobre isso na seção de CI.

Configurando o ArchUnit em um projeto Spring Boot

O Projeto de Exemplo

O exemplo é a API Spring Boot de loiane/crud-angular-spring , a aplicação CRUD completa que utilizo neste blog e nos meus vídeos. O backend reside na crud-springpasta e, propositadamente, não está organizado por camadas (pacotes). Está organizado por funcionalidade: tudo relacionado aos cursos reside em um único pacote, com a infraestrutura compartilhada mantida separada.

crud-spring/src/
  main/java/com/loiane/
    course/                   the feature package
      Course.java  Lesson.java
      CourseController.java  CourseService.java  CourseRepository.java
      dto/         (records + MapStruct-style mapper)
      enums/       (Category, Status + JPA converters)
    exception/                business exceptions
    shared/                   controller advice, custom validators
  test/java/com/loiane/
    architecture/
      ArchitectureTest.java   every rule in this post

Essa estrutura é importante para este post. A maioria dos tutoriais do ArchUnit pressupõe a existência de pacotes ..controller.., como `package.json` ..service.., `package.json` e ..repository..`package.json`, nos quais você pode definir regras. Bases de código reais, organizadas por pacote e recurso, não possuem esses pacotes, e as regras abaixo mostram como impor a mesma organização em camadas com predicados de classe — nomes e anotações — em vez de nomes de pacotes. Se o seu projeto for organizado por pacote e camada, as mesmas regras se tornam mais simples, não mais complexas.

Pré-requisitos

  • Java 25. O ArchUnit lê arquivos de classe padrão e acompanha as versões recentes do JDK.
  • Maven 3.9+ (o repositório funciona com uma instalação padrão do Maven; mvn -f crud-spring/pom.xml testexecuta tudo).
  • O projeto é executado no Spring Boot 4.1 com JUnit 5 sendo implementado transitivamente através do spring-boot-starter-teststarter. As regras são idênticas no Spring Boot 3.x; apenas os nomes dos starters diferem.

A única dependência

Adicione a versão JUnit 5 do ArchUnit como uma dependência de teste. Ela inclui a biblioteca principal, além da @AnalyzeClassesintegração @ArchTest.

<dependency>
  <groupId>com.tngtech.archunit</groupId>
  <artifactId>archunit-junit5</artifactId>
  <version>1.4.1</version>
  <scope>test</scope>
</dependency>

Essa é toda a configuração. Não há plugin, perfil ou objetivo separado. Os testes de arquitetura são testes JUnit comuns, então mvn testjá estão sendo executados.

Sua primeira regra

Crie uma classe de teste anotada com `@Request` @AnalyzeClassespara declarar os pacotes a serem importados e, em seguida, expresse as regras como staticcampos anotados com `@Request` @ArchTest. O ArchUnit importa as classes uma única vez e avalia cada @ArchTestcampo em relação a esse modelo.

package com.loiane.architecture;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@AnalyzeClasses(packages = "com.loiane", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {

    @ArchTest
    static final ArchRule servicesShouldNotDependOnControllers = noClasses()
            .that().haveSimpleNameEndingWith("Service")
            .should().dependOnClassesThat().haveSimpleNameEndingWith("Controller");
}

Observe que não há nome de pacote na regra. Isso ocorre porque o código-fonte é organizado por recursos CourseServicee todos os recursos CourseControllerresidem no mesmo pacote; portanto, uma regra baseada em pacotes jamais conseguiria separá-los. A convenção de nomenclatura é o marcador de camada, e a regra se baseia nele.

Execute-o como qualquer outro teste:
mvn test

Se um serviço importar um controlador, este teste falhará com uma mensagem que especifica a classe exata e a dependência exata. A DoNotIncludeTestsopção de importação mantém suas classes de teste fora da análise, de modo que um auxiliar de teste que acessa várias camadas não produza falsos positivos.

A pegadinha infalível

Existe uma armadilha de configuração que pode te fazer pensar que tudo funciona quando, na verdade, nada funciona. O Surefire 3.5.3 falha ao executar @ArchTestregras declaradas como campos ( TNG/ArchUnit#1442 ). Regras declaradas como métodos ainda são executadas, mas campos são o estilo idiomático e todas as regras neste post os utilizam. Sua mvn testexecução permanece verde, mas o relatório indica que Tests run: 0, para cada classe de teste de arquitetura, as regras nunca são executadas e, portanto, nunca detectam nada.

Este é o equivalente em ArchUnit ao problema de ausência de asserção silenciosa: uma compilação que é aprovada justamente porque não verifica nada. Se a sua versão do Spring Boot gerencia uma versão afetada do Surefire, fixe-a em uma versão comprovadamente estável até que a regressão seja resolvida.

<properties>
  <!-- Surefire 3.5.3 silently reports "Tests run: 0" for @ArchTest fields -->
  <maven-surefire-plugin.version>3.5.2</maven-surefire-plugin.version>
</properties>

O sinal é sempre o mesmo: se adicionar uma regra que você sabe que está sendo violada ainda produzir uma compilação verde com zero testes, o Surefire não está executando suas regras do ArchUnit. Verifique a Tests runcontagem, não apenas a cor da compilação.

As categorias de regras que justificam sua permanência.

O ArchUnit pode expressar quase qualquer regra estrutural, mas, na prática, algumas categorias abrangem a grande maioria do que um código-fonte Spring Boot precisa: dependências de camadas, regras de limites, convenções de nomenclatura e anotações, disciplina de DTO, ciclos e higiene geral. Cada regra abaixo está ativa no crud-angular-springArchitectureTest e é executada em todas as compilações. Comece por aqui.

Dependências de camadas sem pacotes de camadas

O contrato central de camadas tem duas partes: as camadas inferiores nunca dependem das camadas superiores, e os repositórios só são acessíveis a partir de serviços. Em uma base de código com estrutura de pacotes por funcionalidade, ambas as partes são expressas por meio de predicados de nomenclatura.

@ArchTest
static final ArchRule repositoriesShouldNotDependOnServicesOrControllers = noClasses()
        .that().haveSimpleNameEndingWith("Repository")
        .should().dependOnClassesThat().haveSimpleNameEndingWith("Service")
        .orShould().dependOnClassesThat().haveSimpleNameEndingWith("Controller");

@ArchTest
static final ArchRule repositoriesOnlyAccessedByServices = classes()
        .that().haveSimpleNameEndingWith("Repository")
        .should().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Service")
        .orShould().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Repository");

A segunda regra é a que elimina o atalho da introdução. No momento CourseControllerem que um parâmetro é CourseRepositoryinjetado, a regra falha e nomeia o parâmetro do construtor, o campo e todos os locais de chamada.

Se o seu projeto utiliza pacotes de camadas (o layout clássico `<layer>` controllerservicerepository<layer>`, `<layer>`), a API integrada do ArchUnit layeredArchitecture()expressa todo o contrato em uma única declaração:

@AnalyzeClasses(
    packages = "com.example.app",
    importOptions = ImportOption.DoNotIncludeTests.class
)
class LayeredArchitectureTest {

    @ArchTest
    static final ArchRule layer_dependencies_are_respected = layeredArchitecture()
        .consideringAllDependencies()
        .layer("Controller").definedBy("..controller..")
        .layer("Service").definedBy("..service..")
        .layer("Repository").definedBy("..repository..")
        .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
        .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
}

Este é o mesmo sensor, apenas com um predicado diferente. A layeredArchitecture()API lê o contrato quase como prosa, e uma violação reporta tanto a classe infratora quanto a regra da camada que ela violou. Para uma base de código com estrutura de pacotes por funcionalidade como esta crud-angular-spring, não há pacotes de camada para apontar, então a abordagem de predicado de nomenclatura acima é a mais adequada — mas ambas expressam a mesma intenção arquitetural.

Regras de Limite: Código Compartilhado Permanece Genérico

O modelo de pacote por funcionalidade traz consigo seu próprio contrato de limites: a infraestrutura compartilhada pode ser usada pelas funcionalidades, mas nunca deve depender delas . No dia em que uma funcionalidade sharedimporta uma courseclasse, ela deixa de ser compartilhada e se torna um apêndice oculto de uma única funcionalidade.

@ArchTest
static final ArchRule sharedShouldNotDependOnFeatures = noClasses()
        .that().resideInAPackage("com.loiane.shared..")
        .should().dependOnClassesThat().resideInAPackage("com.loiane.course..");

Este é um código-fonte com uma única funcionalidade, portanto, uma única regra o cobre. À medida que as funcionalidades se multiplicam, o mesmo padrão se repete: cada novo pacote de funcionalidade é adicionado à lista de proibidos, ou você opta pelo estilo de lista de permitidos com onlyHaveDependentClassesThat()uma regra por funcionalidade. Para um grande monolito modular, a API de módulos do ArchUnit pode substituir a repetição por funcionalidade por uma única regra baseada em uma anotação em cada funcionalidade package-info.java, mas não recorra a esse mecanismo até que as regras explícitas se tornem ilegíveis.

Convenções de nomenclatura e anotação

As regras de nomenclatura parecem superficiais até você perceber que são elas que tornam as outras regras confiáveis. Cada regra de camada acima se baseia em nomes de classe que terminam em ` Controller.`, Service`.` ou ` Repository.`, então esses nomes precisam permanecer consistentes. Essas regras tornam a convenção autoaplicável:

@ArchTest
static final ArchRule controllersAreAnnotatedAndPlacedCorrectly = classes()
        .that().haveSimpleNameEndingWith("Controller")
        .and().resideOutsideOfPackage("com.loiane.shared..")
        .should().beAnnotatedWith(RestController.class)
        .andShould().resideInAPackage("com.loiane.course..");

@ArchTest
static final ArchRule servicesAreAnnotatedWithService = classes()
        .that().haveSimpleNameEndingWith("Service")
        .should().beAnnotatedWith(Service.class);

@ArchTest
static final ArchRule repositoriesAreSpringDataInterfaces = classes()
        .that().haveSimpleNameEndingWith("Repository")
        .should().beInterfaces()
        .andShould().beAssignableTo(Repository.class);

Observe a resideOutsideOfPackage("com.loiane.shared..")exceção na primeira regra: o pacote compartilhado contém ` ApplicationControllerAdvice<nome_do_pacote>`, que termina em “Advice”, mas reside ao lado do código adjacente ao controlador. Predicados de escopo como esse são a maneira de manter as regras baseadas em nomes precisas em vez de confusas.

Disciplina DTO: Controladores nunca expõem entidades

Essa é a minha regra favorita em toda a classe de testes, porque ela impõe um contrato que a revisão de código constantemente ignora: controladores REST aceitam e retornam DTOs, nunca entidades JPA. Expor uma entidade vaza seu modelo de persistência para a sua API, arrasta problemas de carregamento lento e serialização para a camada web e acopla cada cliente da API ao seu esquema de banco de dados.

@ArchTest
static final ArchRule controllersDoNotExposeEntities = noMethods()
        .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
        .and().arePublic()
        .should().haveRawReturnType(Course.class)
        .orShould().haveRawReturnType(Lesson.class)
        .orShould().haveRawParameterTypes(Course.class)
        .orShould().haveRawParameterTypes(Lesson.class);

No momento em que alguém muda CourseControllerpara retornar uma Courseentidade em vez de um ` CourseDTOstring` — o atalho clássico quando um novo campo é necessário na resposta — esta regra falha e aponta para o método exato. Em uma base de código maior, substitua a lista de classes explícita por um predicado, assim areAnnotatedWith(Entity.class), novas entidades serão cobertas automaticamente.

Detecção de ciclo

Ciclos de pacotes são o veneno lento de uma base de código. Eles tornam impossível raciocinar sobre módulos isoladamente e impossíveis de extrair posteriormente. A slices()API do ArchUnit divide a base de código em fatias por pacote e garante que elas não formem ciclos.

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

Neste projeto, as fatias são courseexception, e shared. A regra garante que, independentemente do crescimento desses pacotes, sharedcoursenunca poderão depender um do outro em ambas as direções.

Higiene Geral

Mais duas regras da classe de teste se pagam imediatamente, sem custo de configuração:

@ArchTest
static final ArchRule noFieldInjection = fields()
        .should().notBeAnnotatedWith(Autowired.class)
        .because("constructor injection is required");

@ArchTest
static final ArchRule noJavaUtilLogging = noClasses()
        .should().dependOnClassesThat().resideInAPackage("java.util.logging..");

A regra de injeção de campo incentiva toda a equipe a usar injeção de construtor sem precisar de um único comentário na revisão de código. O ArchUnit também fornece versões prontas dessas regras em GeneralCodingRulesNO_CLASSES_SHOULD_USE_FIELD_INJECTIONNO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGINGNO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS) se você preferir as versões de uma linha; escrevê-las manualmente, como aqui, permite adicionar .because(...)mensagens personalizadas.

Lendo os resultados

Como as regras de arquitetura são testes JUnit comuns, seus resultados aparecem onde quer que seus resultados de teste já apareçam. Uma execução bem-sucedida é silenciosa: cada regra é um teste verde. O resultado interessante é uma falha.

Para transformar os resultados brutos em algo que um revisor possa ler em um navegador, adicione o seguinte maven-surefire-report-plugine gere um relatório HTML:

mvn -Dmaven.test.failure.ignore=true surefire-report:report

O relatório é gerado target/reports/surefire.html. Cada classe de teste de arquitetura aparece com suas regras, e qualquer regra que falhe é sinalizada com sua mensagem completa de violação. Aqui está o resumo por classe após alguém injetar um repositório diretamente em um controlador:


Ao analisar a regra com falha, é possível ver exatamente o que o ArchUnit detectou, incluindo a classe, o campo, o parâmetro do construtor e o número da linha.

Essa é a vantagem de um sensor computacional. A mensagem não é “há algo errado com sua arquitetura”. É “este controlador possui um parâmetro de construtor, um campo e uma chamada de método que acessam o repositório, exatamente nessas linhas”. Um revisor, ou um agente que lê a falha, sabe precisamente o que corrigir.

Por que isso importa ainda mais quando uma IA escreve o código?

Venho construindo essa ideia ao longo de toda a série sobre desenvolvimento orientado a especificações , e o teste de arquitetura é onde isso se torna mais evidente.

Quando um humano escreve um controlador na segunda-feira e um serviço na quinta-feira, as convenções de camadas residem na mente dessa pessoa e permanecem razoavelmente consistentes. Quando um agente de IA gera um controlador, um serviço e um repositório em uma única sessão, ele otimiza para fazer o recurso imediato funcionar, não para preservar os limites que você projetou três sprints atrás. O agente irá injetar um repositório em um controlador ou retornar a entidade JPA em vez do DTO, se esse for o caminho mais curto para um teste bem-sucedido. Não se trata de descuido. Simplesmente não há um sensor que indique a existência do limite.

Este é exatamente o tipo de falha que a estrutura do harness prevê. Uma instrução copilot-instructions.mdque diz “os controladores devem passar pelos serviços” é um guia inferencial de feedforward : ela direciona o modelo, mas é uma sugestão que o modelo pode ignorar silenciosamente sob pressão . Uma regra do ArchUnit é um sensor de feedback computacional : ela observa o que o agente realmente produziu e falha a construção se o limite foi ultrapassado. Você quer ambos, mas apenas um deles não pode ser descartado.

A estratégia é a mesma que descrevi para o gate TDD no kit de quatro agentes : pegar uma solicitação educada que o agente poderia ignorar e convertê-la em uma verificação determinística que a compilação se recusa a pular. O ArchUnit é essa conversão para estrutura. É o sensor que detecta um agente se desviando da arquitetura à meia-noite, e faz isso independentemente de o código ter sido escrito por uma pessoa, uma dupla ou por um prompt.

Adotando o ArchUnit em uma base de código legada

Eis o verdadeiro problema. O projeto crud-angular-spring adotou essas regras quando ainda era pequeno, então a primeira execução foi bem-sucedida. Ao adicionar a mesma regra de camadas sensata a uma base de código que está em execução há três anos, a primeira execução mvn testreporta duzentas violações. Bloquear a compilação por todas elas no primeiro dia cria tanto atrito que a equipe remove a regra até sexta-feira. Essa é a mesma armadilha de adoção que descrevi para os limites de PIT: um conjunto de regras muito restritivo em relação à dívida técnica existente acaba sendo abandonado.

A resposta do ArchUnit é freeze: encapsular uma regra em FreezingArchRule.freeze(...)um repositório registra as violações atuais em um armazenamento na primeira execução e, em seguida, a compilação só falha em caso de novas violações. A dívida existente é congelada, não ignorada, e cada violação corrigida é removida automaticamente do armazenamento, de modo que a linha de base apenas diminui com o tempo.

import static com.tngtech.archunit.library.freeze.FreezingArchRule.freeze;

@ArchTest
static final ArchRule repositoriesOnlyAccessedByServices = freeze(
    classes()
        .that().haveSimpleNameEndingWith("Repository")
        .should().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Service")
        .orShould().onlyBeAccessed().byClassesThat().haveSimpleNameEndingWith("Repository")
);

A primeira execução cria um repositório de violações (por padrão, um diretório chamado archunit_store) que você envia para o repositório. A partir daí:

  • Uma nova violação resulta na falha da compilação. A regra protege você daqui para frente.
  • Corrigir uma violação reduz o tamanho da loja. A situação atual só pode melhorar.
  • O estoque de congelados é o registro honesto da dívida que você ainda precisa pagar.

Para permitir que o ArchUnit crie o armazenamento na primeira execução, defina a propriedade uma única vez:

# src/test/resources/archunit.properties
freeze.store.default.allowStoreCreation=true

Esta é uma porta de mão única: a linha de base de violações permitidas só pode diminuir, nunca aumentar. É isso que permite que uma equipe real adote testes de arquitetura em uma base de código real sem um sprint de limpeza de duas semanas antes da primeira compilação bem-sucedida. Congele a dívida, bloqueie novas violações e reduza a linha de base à medida que você trabalha em cada área.

Executando o ArchUnit no GitHub Actions

Como os testes de arquitetura são testes JUnit comuns, eles são executados como parte do processo de CI mvn test, o que significa que já estão cobertos por qualquer sistema de CI que execute seus testes unitários. Essa é a vantagem de um sensor rápido: ele não precisa de um estágio próprio no pipeline.

O repositório crud-angular-spring vai um passo além e atribui aos testes de arquitetura um job nomeado próprio no fluxo de trabalho de build , ao lado do build do Maven, do build do Angular e do job de mutação do PIT. O job é trivial porque os testes são apenas JUnit:

architecture-java:
  name: Maven Architecture Tests (ArchUnit)
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v7
    - name: Set up JDK 25
      uses: actions/setup-java@v5
      with:
        java-version: '25'
        distribution: 'temurin'
        cache: maven
    - name: Run ArchUnit tests
      run: mvn -B test -Dtest=ArchitectureTest --file crud-spring/pom.xml

Algumas decisões que merecem destaque:

  • Uma regra de arquitetura com falha causa falha no teste JUnit, o que resulta na falha da tarefa e, consequentemente, em um alerta vermelho para o pull request. Nenhuma configuração adicional de controle de acesso é necessária.
  • As regras também são executadas dentro da Maven Buildtarefa principal, já que fazem parte do conjunto de testes normal. A tarefa dedicada existe para sinalização, não para cobertura: quando a arquitetura sofre alterações, a solicitação de pull exibe uma marca de seleção vermelha com o nome “Testes de Arquitetura” em vez de uma falha de compilação genérica, e o revisor sabe que a estrutura foi quebrada, não o comportamento.
  • Se você adotar a versão freeze, faça o commit do archunit_storediretório. O CI lê a linha de base congelada do repositório, portanto, uma nova execução aplica exatamente a mesma linha de dependência que sua máquina.

Mantendo as regras confiáveis

Um conjunto de regras só é útil se for honesto. Algumas práticas impedem que o ArchUnit se transforme em ruído que a equipe contorna:

Exclua as classes de teste da análise. Use ` importOptions = ImportOption.DoNotIncludeTests.class. Os fixtures de teste abrangem legitimamente várias camadas, e analisá-los produz violações que treinam a equipe a ignorar falhas.

Defina o escopo do código gerado. Mapeadores MapStruct, tipos Q do QueryDSL e clientes OpenAPI são gerados em seus pacotes e podem acionar regras de nomenclatura e dependência. Exclua-os com um ImportOptionpredicado personalizado .that().areNotAnnotatedWith(Generated.class).

Prefira algumas regras claras a muitas regras vagas. A mesma lição dos mutadores PIT: um pequeno conjunto de regras que a equipe entende e em que confia é melhor do que um conjunto extenso que todos acabam freezedescartando. A classe de teste crud-angular-spring tem onze regras, e cada uma delas protege um contrato com um nome: camadas, direção do código compartilhado, nomenclatura, disciplina de DTO, ciclos, estilo de injeção. Adicione regras quando uma violação real as justificar.

Escreva a mensagem de erro para o próximo desenvolvedor. O ArchUnit permite anexar um motivo personalizado com `<request>` .because("constructor injection is required"), assim como a regra de injeção de campo acima. Essa frase faz toda a diferença entre um desenvolvedor corrigir o design e um desenvolvedor desabilitar o teste.

Conclusão

Um diagrama de arquitetura é um ponto de partida necessário. No entanto, não é garantia suficiente de que sua estrutura ainda se mantém. O teste de arquitetura é o que preenche essa lacuna. Ele revela não apenas qual era a forma pretendida para o sistema, mas também se o código entregue esta semana ainda a respeita.

O ArchUnit é a maneira mais prática de adicionar testes de arquitetura a um projeto Java atualmente. Com uma única dependência de teste e algumas regras, você pode codificar suas restrições de camadas, dependências, nomenclatura e ciclos como funções de aptidão executáveis, adotá-las em um código legado freezee executá-las em CI como parte da compilação já existente, sem nenhuma etapa extra no pipeline. E, como demonstra o projeto crud-angular-spring, você não precisa de pacotes de camadas para isso: predicados de nomenclatura e anotações impõem os mesmos contratos em um código com estrutura de pacotes por recurso.

Um caminho razoável para a adoção:

  1. Adicione a archunit-junit5dependência. Escreva uma regra de camadas. Execute-a localmente para verificar o resultado.
  2. Envolva a regra para freezeque as violações existentes sejam definidas como linha de base, e não como bloqueio. Confirme as alterações no store.
  3. Adicione a detecção de ciclos e a regra de não injeção de campo. Essas medidas trazem resultados imediatos.
  4. Adicione regras de nomenclatura para que os predicados da sua camada baseada em nomes permaneçam confiáveis ​​à medida que a base de código cresce.
  5. Adicione a regra de disciplina DTO para que seu contrato de API deixe de depender da atenção do revisor.
  6. Deixe que o funcionamento normal mvn testda CI faça cumprir as regras. Limite os pull requests à mesma build que você já está executando.
  7. Pague o valor da linha de base congelada à medida que tocar em cada área. Ela se move apenas em uma direção: para baixo.

Enquanto o PIT informava se seus testes detectariam um bug comportamental, o ArchUnit informa se sua base de código ainda mantém a estrutura que você projetou. Juntos, eles são dois sensores no mesmo sistema: um para o que o código faz, outro para como o código é construído. Em um mundo onde um agente pode escrever uma funcionalidade inteira antes do almoço, ambos representam a diferença entre um sistema confiável e um diagrama em que você acreditava.

Referências

Boa programação!