Testando o Núcleo
O que a arquitetura compra em testes
Seção intitulada “O que a arquitetura compra em testes”Quando as dependências apontam para dentro, cada anel se testa com ferramentas proporcionais ao seu custo:
graph TD
E2E["Poucos: ponta a ponta<br/>HTTP real + banco real"]
ADP["Alguns: adapters isolados<br/>JPA com Testcontainers, controller com MockMvc"]
UC["Muitos: casos de uso com fakes<br/>nucleo inteiro, sem Spring"]
DOM["Base: dominio puro<br/>entidades, VOs, invariantes"]
E2E --> ADP --> UC --> DOM
A base larga só existe porque o núcleo não importa framework: se o domínio dependesse do JPA, todo teste de invariante pagaria o preço de subir contexto. Estes quatro níveis são a pirâmide de Cohn vista pelas camadas da arquitetura: “unitário” desdobra-se em domínio e caso de uso; integração e E2E permanecem. Os exemplos abaixo usam o domínio bancário de Exemplo Hexagonal com Spring.
Nível 1 — domínio puro
Seção intitulada “Nível 1 — domínio puro”Invariantes testadas com construtor e assert. Nenhuma anotação, nenhum mock:
class ContaTest {
@Test void debitarReduzOSaldo() { Conta conta = new Conta("001", "Ana", Dinheiro.de(new BigDecimal("100")));
conta.debitar(Dinheiro.de(new BigDecimal("40")));
assertEquals(new BigDecimal("60"), conta.saldo().valor()); }
@Test void debitarAlemDoSaldoEhRecusado() { Conta conta = new Conta("001", "Ana", Dinheiro.de(new BigDecimal("100")));
assertThrows(SaldoInsuficienteException.class, () -> conta.debitar(Dinheiro.de(new BigDecimal("150")))); }}Cada teste é um cenário de negócio que o especialista reconheceria — não uma checagem defensiva de null ou lista vazia.
Nível 2 — caso de uso com fake
Seção intitulada “Nível 2 — caso de uso com fake”Um fake é uma implementação simples e funcional da driven port — na terminologia hexagonal, um adapter secundário de teste. E o próprio teste é um ator do lado driving: o “Testes” que aparece no diagrama do hexágono não é decoração.
class ContaRepositoryEmMemoria implements ContaRepository {
private final Map<String, Conta> contas = new HashMap<>();
@Override public Optional<Conta> porNumero(String numero) { return Optional.ofNullable(contas.get(numero)); }
@Override public Conta salvar(Conta conta) { contas.put(conta.numero(), conta); return conta; }}class TransferenciaServiceTest {
private final ContaRepositoryEmMemoria contas = new ContaRepositoryEmMemoria(); private final TransferenciaService service = new TransferenciaService(contas);
@Test void transferenciaMoveOValorEntreAsContas() { contas.salvar(new Conta("001", "Ana", Dinheiro.de(new BigDecimal("100")))); contas.salvar(new Conta("002", "Bia", Dinheiro.de(new BigDecimal("10"))));
service.transferir(new ComandoTransferencia("001", "002", new BigDecimal("30")));
assertEquals(new BigDecimal("70"), contas.porNumero("001").orElseThrow().saldo().valor()); assertEquals(new BigDecimal("40"), contas.porNumero("002").orElseThrow().saldo().valor()); }
@Test void transferenciaSemSaldoNaoAlteraAsContas() { contas.salvar(new Conta("001", "Ana", Dinheiro.de(new BigDecimal("20")))); contas.salvar(new Conta("002", "Bia", Dinheiro.de(new BigDecimal("10"))));
assertThrows(SaldoInsuficienteException.class, () -> service.transferir(new ComandoTransferencia("001", "002", new BigDecimal("50"))));
assertEquals(new BigDecimal("20"), contas.porNumero("001").orElseThrow().saldo().valor()); assertEquals(new BigDecimal("10"), contas.porNumero("002").orElseThrow().saldo().valor()); }}Repare no que não está aqui: @SpringBootTest, Mockito, H2. O TransferenciaService é um objeto comum montado pelo construtor — o @Transactional na classe é inerte fora do container e não atrapalha. Esse teste atravessa driving port, application service, domínio e driven port: o hexágono inteiro, em milissegundos.
Nível 3 — a Regra da Dependência como build quebrado
Seção intitulada “Nível 3 — a Regra da Dependência como build quebrado”Code review não garante fronteira: uma dependência errada entra despercebida num import. ArchUnit verifica os pacotes a cada build:
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.exemplo.banco")class RegraDaDependenciaTest {
@ArchTest static final ArchRule dominioNaoConheceNada = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAnyPackage( "..application..", "..infrastructure..", "org.springframework..", "jakarta.persistence..");
@ArchTest static final ArchRule aplicacaoNaoConheceInfraestrutura = noClasses() .that().resideInAPackage("..application..") .should().dependOnClassesThat().resideInAnyPackage("..infrastructure..");}Se você adotou a exceção pragmática do @Transactional em application (ver Exemplo Hexagonal com Spring), a segunda regra proíbe apenas ..infrastructure... Num núcleo purista, acrescente org.springframework.. à lista — e o build passa a policiar a pureza automaticamente.
E os adapters?
Seção intitulada “E os adapters?”Adapters se testam com a tecnologia que embrulham, de forma isolada e fina: o adapter JPA contra um banco real descartável (Testcontainers), o controller com MockMvc verificando status e JSON, o consumer contra um broker embarcado. Poucos testes, focados em tradução — a regra de negócio já foi provada níveis abaixo e não se retesta aqui.
Anti-padrões e erros comuns
Seção intitulada “Anti-padrões e erros comuns”@SpringBootTestpara regra de negócio: subir o contexto inteiro para testar uma invariante que um construtor resolveria — exatamente o imposto que a arquitetura existia para evitar.- Mockar o domínio:
when(pedido.total()).thenReturn(...)— entidades e VOs se instanciam, não se mockam. - Teste de caso de uso verificando SQL: se o teste conhece a tecnologia do adapter, a fronteira vazou para o teste.
- Retestar regra no teste do controller: a borda cobre tradução (status, formato); a regra o domínio já provou.
- Fake esperto demais: um fake que valida, filtra e ordena vira uma segunda implementação a manter. Mantenha-o burro como uma coleção.
Relações
Seção intitulada “Relações”- Fundamentos completos no módulo Testes e TDD: Fundamentos da Automação de Testes, Test Doubles - Stub, Spy, Mock e Fake, Tipos de Teste - Unit, Integration e E2E
- A promessa que esta nota cumpre: Arquitetura Hexagonal, Clean Architecture, A Regra da Dependência
- As costuras que tornam o fake possível: Ports, Adapters, DIP - Dependency Inversion Principle
- Código testado aqui: Exemplo Hexagonal com Spring
- Pratique em Exercícios Práticos
- Índice: Home
Perguntas de revisão
Seção intitulada “Perguntas de revisão”1. Por que o teste do caso de uso dispensa Spring e Mockito?
Resposta
Porque o núcleo depende só de portas (interfaces) recebidas pelo construtor. Basta instanciar o service com um fake da driven port — em teste, a injeção de dependência é um new.
2. O que é um fake em memória, na terminologia hexagonal?
Resposta
Um adapter secundário de teste: uma implementação funcional e simples da driven port. O próprio teste, por sua vez, atua como ator primário (lado driving) — a simetria do hexágono aplicada.
3. O que a regra ArchUnit garante que o code review não garante?
Resposta
Verificação contínua e automática: qualquer import que viole a Regra da Dependência quebra o build, em vez de depender de um revisor notar a linha no diff.
4. Qual camada da pirâmide deve concentrar a maioria dos testes e por quê?
Resposta
Domínio e casos de uso (com fakes): são rápidos, estáveis e cobrem as regras de negócio. Adapters e ponta a ponta ficam com poucos testes, focados em tradução e integração, porque são caros e lentos.