
Por qué tests JSON aislados
La capa de mapeo a JSON es el contrato que tu API expone al mundo. Cualquier cambio de versión de Jackson, una nueva anotación @JsonIgnore, un módulo deshabilitado o un format string distinto puede romper integraciones que llevan meses funcionando. Sin tests específicos, esos cambios se descubren tarde: cuando el front recibe una fecha que no entiende o cuando un consumidor downstream se rompe en producción.
Los tests con @WebMvcTest validan el contrato JSON de forma indirecta a través del controller. Funcionan, pero pagan el coste de levantar la capa web. Cuando hay que probar muchos edge cases de serialización (formatos de fecha, polimorfismo, valores nulos, Optional, polish de campos), @JsonTest es 5 veces más rápido y mucho más expresivo.
Un cambio sutil en la configuración de Jackson, como olvidar registrar
JavaTimeModuleo cambiar eldateFormatglobal, puede romper meses de integración con consumidores externos. Los slice tests JSON detectan esos cambios en milisegundos.
Qué carga @JsonTest
@JsonTest autoconfigura solo lo necesario para serialización JSON:
ObjectMappercon la autoconfiguración Jackson de Spring Boot.JacksonTester<T>,JsonbTester<T>,GsonTester<T>(según librerías presentes).- Módulos Jackson registrados (
JavaTimeModule,Jdk8Module, etc.). - Configuración de
spring.jackson.*aplicada.
Lo que no carga: controllers, servicios, repositorios, beans web ni configuración de seguridad. La huella es mínima y el arranque ronda los 200 ms.
graph LR
A[Test con @JsonTest] --> B[ObjectMapper]
A --> C[JacksonTester<T>]
A --> D[Modulos JSR-310, Jdk8]
A --> E[Config spring.jackson.*]
A -.NO carga.-> F[Controllers]
A -.NO carga.-> G[Services]
A -.NO carga.-> H[Repositories]
JacksonTester: serializar y deserializar
JacksonTester<T> se inyecta vía @Autowired (después de inicializarlo en @BeforeEach) y proporciona dos APIs principales: write(obj) para serializar y parse(json) o parseObject(json) para deserializar.
public record EmpleadoDto(
Long id,
String nombre,
String email,
BigDecimal salario,
LocalDate fechaAlta) {}
@JsonTest
class EmpleadoDtoJsonTest {
@Autowired
JacksonTester<EmpleadoDto> json;
@Test
void serializar_produceJsonEsperado() throws Exception {
var dto = new EmpleadoDto(7L, "Alan", "alan@certidevs.com",
new BigDecimal("48000"), LocalDate.of(2024, 9, 15));
var resultado = json.write(dto);
assertThat(resultado).hasJsonPathStringValue("$.nombre");
assertThat(resultado).extractingJsonPathStringValue("$.nombre")
.isEqualTo("Alan");
assertThat(resultado).extractingJsonPathStringValue("$.fechaAlta")
.isEqualTo("2024-09-15");
assertThat(resultado).extractingJsonPathNumberValue("$.salario")
.isEqualTo(48000);
}
@Test
void deserializar_produceObjetoEsperado() throws Exception {
var entrada = """
{
"id": 7,
"nombre": "Alan",
"email": "alan@certidevs.com",
"salario": 48000,
"fechaAlta": "2024-09-15"
}
""";
var resultado = json.parse(entrada);
assertThat(resultado.getObject().nombre()).isEqualTo("Alan");
assertThat(resultado.getObject().fechaAlta())
.isEqualTo(LocalDate.of(2024, 9, 15));
}
}
JacksonTester ofrece comparaciones contra ficheros de referencia, comparación estructural y aserciones tipo jsonPath. Para tests más estrictos:
@Test
void serializar_coincideConFixture() throws Exception {
var dto = new EmpleadoDto(7L, "Alan", "alan@certidevs.com",
new BigDecimal("48000"), LocalDate.of(2024, 9, 15));
assertThat(json.write(dto))
.isEqualToJson("empleado-7.json"); // src/test/resources/.../empleado-7.json
}
El archivo empleado-7.json se busca por convención en src/test/resources/ con la misma ruta de paquete que la clase de test.
Records y formato de fechas
Los records de Java 25 son la forma idiomática de modelar DTOs y se serializan sin configuración adicional. El detalle a vigilar es el formato de las fechas: por defecto Jackson serializa LocalDate como ISO-8601 (2024-09-15) si JavaTimeModule está registrado, lo cual ocurre automáticamente con Spring Boot.
Si la API requiere otro formato (por ejemplo, formato europeo dd/MM/yyyy), se controla con @JsonFormat:
public record FacturaDto(
Long id,
@JsonFormat(pattern = "dd/MM/yyyy")
LocalDate fechaEmision,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
OffsetDateTime fechaPago,
BigDecimal importe) {}
@Test
void factura_serializaConFormatoEuropeo() throws Exception {
var dto = new FacturaDto(1L,
LocalDate.of(2024, 12, 31),
OffsetDateTime.parse("2025-01-15T10:30:00+01:00"),
new BigDecimal("1234.56"));
var resultado = json.write(dto);
assertThat(resultado).extractingJsonPathStringValue("$.fechaEmision")
.isEqualTo("31/12/2024");
assertThat(resultado).extractingJsonPathStringValue("$.fechaPago")
.isEqualTo("2025-01-15T10:30:00+01:00");
}
@Test
void factura_deserializaFormatoEuropeo() throws Exception {
var entrada = """
{
"id": 1,
"fechaEmision": "31/12/2024",
"fechaPago": "2025-01-15T10:30:00+01:00",
"importe": 1234.56
}
""";
var resultado = json.parseObject(entrada);
assertThat(resultado.fechaEmision()).isEqualTo(LocalDate.of(2024, 12, 31));
assertThat(resultado.fechaPago().getOffset()).isEqualTo(ZoneOffset.ofHours(1));
}
Si tus tests fallan con
Cannot construct instance of LocalDate, no String-argument constructor, falta registrarJavaTimeModule. Spring Boot lo hace automáticamente, pero si el test usanew ObjectMapper()sin Spring, hay que registrarlo manualmente.
Optional y null handling
Optional<T> requiere el módulo Jdk8Module y por defecto Jackson lo serializa como null (si está vacío) o como el valor desempaquetado:
public record UsuarioDto(
Long id,
String nombre,
Optional<String> apellido,
Optional<String> telefono) {}
@Test
void usuario_serializaOptionalsCorrectamente() throws Exception {
var dto = new UsuarioDto(1L, "Alan",
Optional.of("García"),
Optional.empty());
var resultado = json.write(dto);
assertThat(resultado).extractingJsonPathStringValue("$.apellido")
.isEqualTo("García");
assertThat(resultado).hasJsonPathValue("$.telefono"); // existe pero null
assertThat(resultado).extractingJsonPathStringValue("$.telefono").isNull();
}
Si prefieres omitir campos null (y Optional.empty()), configúralo con @JsonInclude(JsonInclude.Include.NON_ABSENT):
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record UsuarioDto(
Long id,
String nombre,
Optional<String> apellido,
Optional<String> telefono) {}
@Test
void usuario_omiteOptionalVacio() throws Exception {
var dto = new UsuarioDto(1L, "Alan",
Optional.of("García"),
Optional.empty());
var resultado = json.write(dto);
assertThat(resultado).hasJsonPath("$.apellido");
assertThat(resultado).doesNotHaveJsonPath("$.telefono");
}
Polimorfismo con @JsonTypeInfo
Cuando un endpoint devuelve un tipo abstracto con varias variantes (eventos, tipos de notificación, formas de pago), @JsonTypeInfo añade un discriminador al JSON:
@JsonTypeInfo(use = Id.NAME, property = "tipo")
@JsonSubTypes({
@Type(value = NotificacionEmail.class, name = "EMAIL"),
@Type(value = NotificacionSms.class, name = "SMS"),
@Type(value = NotificacionPush.class, name = "PUSH")
})
public sealed interface Notificacion
permits NotificacionEmail, NotificacionSms, NotificacionPush {}
public record NotificacionEmail(String destinatario, String asunto, String cuerpo)
implements Notificacion {}
public record NotificacionSms(String numero, String texto)
implements Notificacion {}
public record NotificacionPush(String deviceId, String titulo, String mensaje)
implements Notificacion {}
@JsonTest
class NotificacionJsonTest {
@Autowired
JacksonTester<Notificacion> json;
@Test
void serializar_emailIncluyeDiscriminador() throws Exception {
Notificacion n = new NotificacionEmail("alan@certidevs.com",
"Bienvenida", "Hola Alan");
var resultado = json.write(n);
assertThat(resultado).extractingJsonPathStringValue("$.tipo")
.isEqualTo("EMAIL");
assertThat(resultado).extractingJsonPathStringValue("$.asunto")
.isEqualTo("Bienvenida");
}
@Test
void deserializar_smsResuelveTipoCorrecto() throws Exception {
var entrada = """
{
"tipo": "SMS",
"numero": "+34600111222",
"texto": "Tu código es 1234"
}
""";
Notificacion resultado = json.parseObject(entrada);
assertThat(resultado).isInstanceOf(NotificacionSms.class);
assertThat(((NotificacionSms) resultado).numero()).isEqualTo("+34600111222");
}
}
Las sealed interfaces de Java 25 combinan especialmente bien con polimorfismo Jackson: el compilador asegura que la lista de subtipos en @JsonSubTypes es exhaustiva.
Custom serializers y deserializers
Para casos donde @JsonFormat no llega (por ejemplo, money con código ISO o IDs cifrados), un custom serializer es la salida limpia:
public record MoneyDto(BigDecimal amount, String currency) {}
public class MoneySerializer extends JsonSerializer<MoneyDto> {
@Override
public void serialize(MoneyDto value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeString("%s %s".formatted(value.amount(), value.currency()));
}
}
public class MoneyDeserializer extends JsonDeserializer<MoneyDto> {
@Override
public MoneyDto deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
var text = p.getValueAsString().split(" ");
return new MoneyDto(new BigDecimal(text[0]), text[1]);
}
}
@Configuration
public class JacksonConfig {
@Bean
public Module moneyModule() {
var module = new SimpleModule();
module.addSerializer(MoneyDto.class, new MoneySerializer());
module.addDeserializer(MoneyDto.class, new MoneyDeserializer());
return module;
}
}
@JsonTest
@Import(JacksonConfig.class)
class MoneyJsonTest {
@Autowired
JacksonTester<MoneyDto> json;
@Test
void money_serializaComoStringConCurrency() throws Exception {
var dto = new MoneyDto(new BigDecimal("1234.56"), "EUR");
var resultado = json.write(dto);
assertThat(resultado.getJson()).isEqualTo("\"1234.56 EUR\"");
}
@Test
void money_deserializaDesdeStringConCurrency() throws Exception {
var resultado = json.parseObject("\"1234.56 EUR\"");
assertThat(resultado.amount()).isEqualByComparingTo("1234.56");
assertThat(resultado.currency()).isEqualTo("EUR");
}
}
@Import(JacksonConfig.class) registra el módulo solo para este test (@JsonTest por defecto no escanea @Configuration no relacionados).
Robustez frente a JSON malformado
Los tests no solo valídan caminos felices: también verifican que la deserialización falla previsiblemente ante entrada inválida.
@Test
void deserializar_jsonConTipoIncorrecto_lanzaInvalidFormatException() {
var entradaInvalida = """
{
"id": 7,
"nombre": "Alan",
"salario": "no-soy-un-numero",
"fechaAlta": "2024-09-15"
}
""";
assertThatThrownBy(() -> json.parseObject(entradaInvalida))
.hasCauseInstanceOf(InvalidFormatException.class);
}
@Test
void deserializar_jsonConCampoExtra_loIgnora() throws Exception {
var entradaConExtra = """
{
"id": 7,
"nombre": "Alan",
"email": "alan@certidevs.com",
"salario": 48000,
"fechaAlta": "2024-09-15",
"campoQueNoExiste": "valor"
}
""";
var resultado = json.parseObject(entradaConExtra);
assertThat(resultado.nombre()).isEqualTo("Alan");
}
Si quieres que los campos extra fallen la deserialización, configura spring.jackson.deserialization.fail-on-unknown-properties=true y prueba que la excepción se lanza.
Estos tests son la mejor defensa contra cambios silenciosos en versiones de Jackson o módulos. Si una actualización rompe la deserialización, el slice test lo detecta antes del despliegue.
Buenas prácticas
- Para cada DTO público de la API, escribe al menos un test de serialización y otro de deserialización. Es la documentación viva del contrato JSON.
- Usa
isEqualToJson("fixture.json")para contratos estables y aserciones jsonPath para campos individuales en evolución. - Si el formato de fechas debe ser específico (europeo, ISO con offset), valídalo con
extractingJsonPathStringValue. - Importa configuración Jackson custom con
@Importpara no contaminar tests que no la necesitan. - Combina
@JsonTestcon records y sealed interfaces de Java 25: producen DTOs concisos y exhaustivos. - Cuando uses
@JsonTypeInfo, prueba todas las ramas del polimorfismo. Una rama no testada es la que se romperá. - Mantén fixtures JSON pequeños (un objeto por archivo). Fixtures gigantes son frágiles y difíciles de mantener.
- Si la aplicación expone JSON Schema, considera generar tests que verifiquen que un objeto serializado cumple el schema.
- No uses
@JsonTestpara validar lógica de negocio: el test debe centrarse exclusivamente en el contrato JSON.
@JsonTestes el slice más infravalorado de Spring Boot. Es rápido, expresivo y la única forma realmente eficiente de blindar el contrato JSON de tu API contra cambios silenciosos en Jackson o en la configuración delObjectMapper. Si tienes integraciones con consumidores externos, esta suite es la red de seguridad.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Aplicar @JsonTest para probar serialización Jackson sin levantar el contexto web. Usar JacksonTester