Todos os artigos

// Knowledge.log — 技術記事

AssertJ no Spring Boot 4.1: falhas que apontam o campo certo

Use AssertJ com ProblemDetail e MockMvcTester para localizar campos divergentes em testes Spring Boot 4.1, sem adicionar dependências redundantes.

Um teste compara dois DTOs e recebe algo próximo de expected: <X> but was: <Y>. Agora começa a investigação: abrir os objetos, alinhar visualmente os campos e descobrir se a diferença está no title, em um endereço aninhado ou naquele id gerado que nem fazia parte do comportamento testado. A mensagem está tecnicamente correta. Útil já é outra conversa.

Com AssertJ, podemos comparar o objeto recursivamente e fazer a falha informar o caminho do campo, o valor atual e o esperado. O mesmo recurso funciona bem para respostas RFC 9457 representadas por ProblemDetail: em vez de descobrir apenas que os objetos diferem, o teste mostra que detail ou instance divergiu.

O resultado prático será substituir uma comparação opaca por uma comparação que descreve o defeito. Também vamos aplicar a abordagem a um teste MVC e manter jsonPath onde ele continua sendo a ferramenta mais direta.

Versões e pré-requisitos

O conjunto usado aqui é Spring Boot 4.1.1, Spring Framework 7.0.9 e Java 25 LTS. O Boot 4.1.1 aceita Java de 17 até 26, então Java 25 é a LTS mais nova dentro da matriz oficial de requisitos.

No teste, spring-boot-starter-test 4.1.1 já fornece AssertJ 3.27.7 e Mockito 5.23.0. Não adicione outra dependência para assertj-core: além de redundante, ela permite que uma versão fixada manualmente se afaste do conjunto gerenciado pelo Boot. O starter também traz JSONPath, usado pelos testes MVC clássicos.

O recorte web usa os pacotes do Boot 4:

import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

Quando o teste depender do tratamento automático de erros no formato RFC 9457, habilite:

spring.mvc.problemdetails.enabled=true

Sem essa propriedade — ou sem um @ControllerAdvice equivalente — não há motivo para esperar que qualquer erro da aplicação vire o ProblemDetail definido pelo contrato. O teste não corrige configuração por telepatia, apesar de alguns pipelines parecerem apostar nisso.

Comece pelo contrato do ProblemDetail

Um ProblemDetail possui cinco membros padronizados pela RFC 9457: status, type, title, detail e instance. No objeto Java, type e instance são URI; status é inteiro; os demais são textos. Propriedades de extensão ficam no mapa retornado por getProperties() e são serializadas como membros JSON de nível superior.

Para um objeto criado diretamente, as asserções explícitas são uma boa escolha quando cada campo pede uma regra diferente:

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
    HttpStatus.NOT_FOUND,
    "No project found for id 'spring-unknown'"
);
problem.setType(URI.create("https://example.org/problems/unknown-project"));
problem.setTitle("Unknown project");
problem.setInstance(URI.create("/projects/spring-unknown"));

assertThat(problem.getStatus()).isEqualTo(404);
assertThat(problem.getType())
    .isEqualTo(URI.create("https://example.org/problems/unknown-project"));
assertThat(problem.getTitle()).isEqualTo("Unknown project");
assertThat(problem.getDetail()).contains("spring-unknown");
assertThat(problem.getInstance())
    .isEqualTo(URI.create("/projects/spring-unknown"));

Isso já produz mensagens melhores do que uma comparação genérica. Porém, quando existe um objeto esperado completo, usingRecursiveComparison() evita cinco comparações repetidas e reúne as diferenças por campo:

assertThat(actualProblem)
    .usingRecursiveComparison()
    .comparingOnlyFields("status", "type", "title", "detail", "instance")
    .isEqualTo(expectedProblem);

Se title estiver errado, a mensagem identifica algo como field/property 'title' differ, seguido por actual value e expected value. Em objetos aninhados, o caminho também é aninhado, por exemplo customer.address.number. Não é apenas uma saída mais bonita: é a diferença entre corrigir o contrato e ficar encarando dois toString() extensos até um caractere confessar.

comparingOnlyFields funciona como uma lista de campos relevantes para aquele teste. Ao informar "customer", seus subcampos entram na comparação; para um recorte mais estreito, use caminhos como "customer.address.number".

ignoringFields remove campos instáveis ou irrelevantes. Um DTO persistido pode ter id e timestamps gerados, por exemplo:

assertThat(savedProject)
    .usingRecursiveComparison()
    .ignoringFields("id", "createdAt", "updatedAt")
    .isEqualTo(requestedProject);

A decisão precisa seguir o contrato do teste. Ignorar um campo porque ele falhou transforma diagnóstico em decoração; ignorá-lo porque é gerado fora do comportamento observado mantém o teste focado.

Há uma armadilha de nome parecido: withEqualsForFields não seleciona campos. Ele recebe um BiPredicate com uma regra customizada de igualdade para locais específicos. Para aceitar uma tolerância numérica, o uso correto é:

BiPredicate<Double, Double> closeEnough =
    (actual, expected) -> Math.abs(actual - expected) <= 0.5;

assertThat(actualCharacter)
    .usingRecursiveComparison()
    .withEqualsForFields(closeEnough, "height")
    .isEqualTo(expectedCharacter);

Portanto, use comparingOnlyFields para escolher o que comparar, ignoringFields para excluir e withEqualsForFields apenas quando aquele campo tiver uma noção própria de igualdade.

Essa comparação não exige que você altere equals() e hashCode() apenas para acomodar um cenário de teste. Isso importa em DTOs de entrada e saída com formas parecidas, mas não idênticas: por padrão, a comparação recursiva consegue percorrer os campos mesmo quando os tipos dos objetos não são iguais. Se a igualdade também precisar exigir o mesmo tipo em toda a árvore, acrescente withStrictTypeChecking() e torne essa decisão explícita no teste.

Outro ganho aparece quando mais de um campo diverge. Uma sequência de chamadas assertEquals normalmente encerra o método na primeira falha; o próximo defeito só aparece depois da primeira correção. Colocando os campos relevantes na mesma comparação recursiva, a mensagem pode listar todas as diferenças encontradas naquele recorte. Isso não significa comparar tudo indiscriminadamente. Significa declarar uma fronteira — os cinco membros do ProblemDetail, por exemplo — e receber um diagnóstico completo dentro dela.

Descrições também ajudam a localizar a intenção quando a suíte é grande. Um .as("RFC 9457 problem body") antes de usingRecursiveComparison() acrescenta contexto à falha sem substituir o relatório por campo. Evite mensagens customizadas genéricas que escondam os valores produzidos pelo AssertJ; escrever “resposta inválida” apenas troca uma mensagem pobre por outra com mais convicção.

Leve a comparação para o MockMvcTester

No Spring Framework 7, MockMvcTester oferece asserções AssertJ sobre a chamada MVC. Com AssertJ no classpath, o Boot o configura em testes com @WebMvcTest. Mockito continua responsável pelo colaborador simulado; AssertJ verifica o resultado. Uma biblioteca não substitui a outra.

O teste abaixo supõe que ProjectController retorna o problema descrito quando o serviço não encontra o projeto:

@WebMvcTest(ProjectController.class)
class ProjectErrorTests {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    ProjectService projects;

    @Test
    void returnsRfc9457BodyForUnknownProject() {
        given(projects.find("spring-unknown"))
            .willReturn(Optional.empty());

        ProblemDetail expected = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            "No project found for id 'spring-unknown'"
        );
        expected.setType(URI.create(
            "https://example.org/problems/unknown-project"
        ));
        expected.setTitle("Unknown project");
        expected.setInstance(URI.create("/projects/spring-unknown"));

        assertThat(mvc.get().uri(
                "/projects/{id}", "spring-unknown"))
            .hasStatus(HttpStatus.NOT_FOUND)
            .hasContentTypeCompatibleWith(
                MediaType.APPLICATION_PROBLEM_JSON)
            .bodyJson()
            .extractingPath("$")
            .convertTo(ProblemDetail.class)
            .usingRecursiveComparison()
            .comparingOnlyFields(
                "status", "type", "title", "detail", "instance")
            .isEqualTo(expected);
    }
}

O convertTo(ProblemDetail.class) usa os conversores HTTP de Jackson presentes no contexto MVC. Se você criar um MockMvcTester em modo standalone, precisará registrar esses conversores. Em um slice @WebMvcTest, a infraestrutura já faz parte do contexto configurado.

Também é possível testar partes do JSON sem convertê-lo:

assertThat(mvc.get().uri("/projects/{id}", "spring-unknown"))
    .bodyJson()
    .extractingPath("$.status")
    .asNumber()
    .isEqualTo(404);

A conversão é especialmente útil quando a intenção é validar o conjunto de campos como um objeto. JSONPath é melhor quando queremos localizar um membro, filtrar uma coleção ou preservar um teste existente que já expressa bem o contrato.

AssertJ e jsonPath podem conviver

O estilo clássico com MockMvc e Hamcrest continua suportado:

mockMvc.perform(get("/projects/{id}", "spring-unknown"))
    .andExpect(status().isNotFound())
    .andExpect(jsonPath("$.status").value(404))
    .andExpect(jsonPath("$.title").value("Unknown project"));

Para novos testes orientados a objetos, o MockMvcTester combinado com AssertJ costuma oferecer um fluxo mais consistente. Não é necessário reescrever de uma vez toda a suíte. A integração oficial permite reutilizar matchers existentes por meio de matches():

assertThat(mvc.get().uri("/projects/{id}", "spring-unknown"))
    .matches(status().isNotFound());

Há também uma ponte para um ResultActions produzido pelo estilo clássico:

assertThat(mockMvc.perform(get("/projects/{id}", "spring-unknown")))
    .hasStatus(HttpStatus.NOT_FOUND);

A recomendação firme é condicional: para testes novos que convertem a resposta em DTO ou ProblemDetail, a DevDojo adotaria MockMvcTester com comparação recursiva; manteria Hamcrest jsonPath quando o teste depende de consultas JSON expressivas ou quando migrar um caso estável só trocaria sintaxe por sintaxe.

Faça o teste falhar de propósito

Antes de confiar na melhoria, altere temporariamente o title esperado para "Project missing" e execute:

./mvnw -q test

A falha deve citar o caminho title e exibir os valores atual e esperado. Em seguida, mude também detail para confirmar que uma única comparação recursiva lista os dois caminhos divergentes. Depois desfaça as alterações; deixar um teste quebrado como documentação interativa tende a gerar uma reunião menos interativa no dia seguinte.

Confira ainda a árvore de dependências:

./mvnw dependency:tree \
  -Dincludes=org.assertj:assertj-core

O resultado deve mostrar uma única versão, 3.27.7, trazida pelo spring-boot-starter-test. Se aparecer outra, remova a declaração manual antes de investigar problemas estranhos de API.

Revise também três pontos quando o teste não produzir o corpo esperado:

  • confirme spring.mvc.problemdetails.enabled=true ou a presença do seu @ControllerAdvice;
  • use os pacotes de @WebMvcTest e @MockitoBean do stack Boot 4;
  • decida se instance faz parte do contrato estável. O Spring pode preenchê-lo a partir do caminho da requisição quando ele não foi definido.

O próximo passo é escolher um teste de erro real, substituir apenas a comparação do objeto e provocar uma divergência em um campo aninhado. Se a saída apontar o caminho certo sem exigir inspeção manual dos objetos, a mudança já entregou valor. Testes de contrato entre serviços, como os apresentados no conteúdo sobre Pact com Spring Boot, resolvem outro problema; aqui o ganho está no diagnóstico preciso dentro da própria suíte.

javaspring-boottesting

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos