关于 unit-test-json-serialization
这项技能介绍如何用 Spring 的 `@JsonTest` 和 Jackson 测试 JSON 序列化与反序列化,适用于 Java 开发者。内容涵盖映射、自定义序列化器、嵌套数据、日期格式及边界情况。
主要功能
- 01测试序列化与反序列化
- 02用 JacksonTester 检查 JSON 路径
- 03覆盖自定义映射与序列化器
- 04测试嵌套对象和日期格式
- 05验证空值、缺失字段及多态类型
适用场景
- 01验证 DTO 的 JSON 输入与输出
- 02测试自定义 Jackson 序列化器和反序列化器
- 03检查日期格式和字段名映射
- 04发现空值与多态处理的回归问题
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ giuseppe-trisciuoglio/ developer-kit --skill unit-test-json-serialization安装后在 Claude Code 中输入/unit-test-json-serialization确认已加载。
详细介绍
使用 @JsonTest 对 JSON 序列化进行单元测试
概述
介绍如何使用 Spring 的 @JsonTest 和 Jackson 对 JSON 序列化与反序列化进行单元测试。涵盖 POJO 映射、自定义序列化器、字段名称映射、嵌套对象、日期/时间格式化和多态类型。
适用场景
- 测试 DTO 的 JSON 序列化/反序列化
- 验证自定义 Jackson 序列化器/反序列化器
- 验证
@JsonProperty、@JsonIgnore和字段名称映射 - 测试日期/时间格式处理(LocalDateTime、Date)
- 测试 null 处理和缺失字段
- 测试多态类型反序列化
说明
- 为测试类添加
@JsonTest注解 → 启用 JacksonTester 自动配置 - 为目标类型自动装配 JacksonTester → 提供类型安全的 JSON 断言
- 测试序列化 → 调用
json.write(object),并使用extractingJsonPath*断言 JSON 路径 - 测试反序列化 → 调用
json.parse(json)或json.parseObject(json),并断言对象状态 - 验证往返转换 → 序列化后再反序列化,验证数据是否一致(前提是对象支持正确的比较)
- 测试边界情况 → null 值、缺失字段、空集合、无效 JSON
- 添加验证检查点:每次断言后,验证数据错误时测试能否给出有意义的失败结果
示例
Maven 配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Gradle 配置
dependencies {
implementation("org.springframework.boot:spring-boot-starter-json")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
基本序列化和反序列化
@JsonTest
class UserDtoJsonTest {
@Autowired
private JacksonTester<UserDto> json;
@Test
void shouldSerializeUserToJson() throws Exception {
UserDto user = new UserDto(1L, "Alice", "[email protected]", 25);
JsonContent<UserDto> result = json.write(user);
result
.extractingJsonPathNumberValue("$.id").isEqualTo(1)
.extractingJsonPathStringValue("$.name").isEqualTo("Alice")
.extractingJsonPathStringValue("$.email").isEqualTo("[email protected]")
.extractingJsonPathNumberValue("$.age").isEqualTo(25);
}
@Test
void shouldDeserializeJsonToUser() throws Exception {
String json_content = "{\"id\":1,\"name\":\"Alice\",\"email\":\"[email protected]\",\"age\":25}";
UserDto user = json.parse(json_content).getObject();
assertThat(user.getId()).isEqualTo(1L);
assertThat(user.getName()).isEqualTo("Alice");
assertThat(user.getEmail()).isEqualTo("[email protected]");
assertThat(user.getAge()).isEqualTo(25);
}
@Test
void shouldHandleNullFields() throws Exception {
String json_content = "{\"id\":1,\"name\":null,\"email\":\"[email protected]\"}";
UserDto user = json.parse(json_content).getObject();
assertThat(user.getName()).isNull();
}
}
自定义 JSON 属性
public class Order {
@JsonProperty("order_id")
private Long id;
@JsonProperty("total_amount")
private BigDecimal amount;
@JsonIgnore
private String internalNote;
}
@JsonTest
class OrderJsonTest {
@Autowired
private JacksonTester<Order> json;
@Test
void shouldMapJsonPropertyNames() throws Exception {
String json_content = "{\"order_id\":123,\"total_amount\":99.99}";
Order order = json.parse(json_content).getObject();
assertThat(order.getId()).isEqualTo(123L);
assertThat(order.getAmount()).isEqualByComparingTo(new BigDecimal("99.99"));
}
@Test
void shouldIgnoreJsonIgnoreFields() throws Exception {
Order order = new Order(123L, new BigDecimal("99.99"));
order.setInternalNote("Secret");
assertThat(json.write(order).json).doesNotContain("internalNote");
}
}
嵌套对象
public class Product {
private Long id;
private String name;
private Category category;
private List<Review> reviews;
}
@JsonTest
class ProductJsonTest {
@Autowired
private JacksonTester<Product> json;
@Test
void shouldSerializeNestedObjects() throws Exception {
Product product = new Product(1L, "Laptop", new Category(1L, "Electronics"));
JsonContent<Product> result = json.write(product);
result
.extractingJsonPathNumberValue("$.category.id").isEqualTo(1)
.extractingJsonPathStringValue("$.category.name").isEqualTo("Electronics");
}
@Test
void shouldDeserializeNestedObjects() throws Exception {
String json_content = "{\"id\":1,\"name\":\"Laptop\",\"category\":{\"id\":1,\"name\":\"Electronics\"}}";
Product product = json.parse(json_content).getObject();
assertThat(product.getCategory().getName()).isEqualTo("Electronics");
}
@Test
void shouldHandleListOfNestedObjects() throws Exception {
String json_content = "{\"id\":1,\"reviews\":[{\"rating\":5},{\"rating\":4}]}";
Product product = json.parse(json_content).getObject();
assertThat(product.getReviews()).hasSize(2);
}
}
日期/时间格式化
@JsonTest
class DateTimeJsonTest {
@Autowired
private JacksonTester<Event> json;
@Test
void shouldFormatDateTimeCorrectly() throws Exception {
LocalDateTime dt = LocalDateTime.of(2024, 1, 15, 10, 30, 0);
json.write(new Event("Conference", dt))
.extractingJsonPathStringValue("$.scheduledAt").isEqualTo("2024-01-15T10:30:00");
}
}
自定义序列化器
public class CustomMoneySerializer extends JsonSerializer<BigDecimal> {
@Override
public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeString(value == null ? null : String.format("$%.2f", value));
}
}
@JsonTest
class CustomSerializerTest {
@Autowired
private JacksonTester<Price> json;
@Test
void shouldUseCustomSerializer() throws Exception {
json.write(new Price(new BigDecimal("99.99")))
.extractingJsonPathStringValue("$.amount").isEqualTo("$99.99");
}
}
多态反序列化
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CreditCard.class, name = "credit_card"),
@JsonSubTypes.Type(value = PayPal.class, name = "paypal")
})
public abstract class PaymentMethod { }
@JsonTest
class PolymorphicJsonTest {
@Autowired
private JacksonTester<PaymentMethod> json;
@Test
void shouldDeserializeCreditCard() throws Exception {
String json_content = "{\"type\":\"credit_card\",\"id\":\"card123\"}";
assertThat(json.parse(json_content).getObject()).isInstanceOf(CreditCard.class);
}
@Test
void shouldDeserializePayPal() throws Exception {
String json_content = "{\"type\":\"paypal\",\"id\":\"pp123\"}";
assertThat(json.parse(json_content).getObject()).isInstanceOf(PayPal.class);
}
}
最佳实践
- 同时测试序列化和反序列化,以实现完整覆盖
- 单独验证 JSON 路径,而不是比较完整的 JSON 字符串
- 明确测试 null 处理——根据
@JsonInclude,null 字段可能会被包含或排除 - 使用
extractingJsonPath*方法精确断言字段 - 测试往返转换:序列化对象,再反序列化 JSON,并验证结果是否一致
- 验证边界情况:空字符串、空集合、深层嵌套结构
- 在单个测试中组合相关断言,以提高清晰度
限制和警告
@JsonTest加载的上下文有限:仅加载与 JSON 相关的 Bean;需要完整 Spring 上下文时,请使用@SpringBootTest- Jackson 版本:确保注解版本与所用的 Jackson 版本一致
- 日期格式:默认使用 ISO-8601;自定义格式请使用
@JsonFormat - null 处理:使用
@JsonInclude(Include.NON_NULL)在序列化时排除 null 值 - 循环引用:使用
@JsonManagedReference/@JsonBackReference防止无限循环 - 不可变对象:使用
@JsonCreator+@JsonProperty进行基于构造函数的反序列化 - 多态类型:
@JsonTypeInfo必须能够正确识别子类型,反序列化才能正常工作
调试工作流
当 JSON 测试失败时,请遵循以下工作流:
| 失败症状 | 常见原因 | 验证方法 |
|---|---|---|
JsonPath 断言失败 | 字段名称不匹配 | 检查 @JsonProperty 的拼写是否与 JSON 键匹配 |
| 预期为 null,但得到了值 | 配置了 @JsonInclude(NON_NULL) | 检查字段或类上的注解 |
| 反序列化返回了错误的类型 | 缺少 @JsonTypeInfo | 在 JSON 中添加类型信息属性,或配置子类型映射 |
| 日期格式不匹配 | 格式字符串不正确 | 确认 @JsonFormat(pattern=...) 与预期字符串匹配 |
| 输出中缺少字段 | 使用了 @JsonIgnore 或 transient 修饰符 | 检查字段是否带有 @JsonIgnore 或 transient 关键字 |
| 嵌套对象为 null | 内层 JSON 缺失或格式错误 | 记录解析后的 JSON;检查内层结构是否与 POJO 匹配 |
JsonParseException | JSON 字符串格式错误 | 验证 JSON 语法;检查是否存在未转义字符 |
修复后的验证检查点:重新运行测试;如果通过,请为相反情况编写一个配套测试(例如,如果修复了 null 处理问题,则添加一个针对非 null 值的测试,以防止回归)。
参考资料
评论
支持 Markdown