Pular para o conteúdo

Testando o Núcleo

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.

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.

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.

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.

  • @SpringBootTest para 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.

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.