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".
Já 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=trueou a presença do seu@ControllerAdvice; - use os pacotes de
@WebMvcTeste@MockitoBeando stack Boot 4; - decida se
instancefaz 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.