unit-test-json-serialization について
Spring の `@JsonTest` と Jackson を使った JSON のシリアライズ・デシリアライズのテスト方法をまとめたスキルです。Java 開発者向けに、マッピングやカスタムシリアライザー、ネスト構造、日付形式、境界ケースを扱います。
主な機能
- 01シリアライズとデシリアライズのテスト
- 02JacksonTester による JSON パス検証
- 03カスタムマッピングとシリアライザーの確認
- 04ネストオブジェクトと日付形式のテスト
- 05null、欠落フィールド、多態型の検証
活用シーン
- 01DTO の JSON 入出力の検証
- 02カスタム Jackson シリアライザーとデシリアライザーのテスト
- 03日付形式とフィールド名マッピングの確認
- 04null と多態型処理の回帰検出
インストール
コマンドをコピーしてターミナルで実行します。
$
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 をデシリアライズして、結果が一致することを確認します
- 空文字列、空のコレクション、深くネストされた構造など、エッジケースを検証します
- わかりやすさのため、関連するアサーションを1つのテストにまとめます
制約と警告
@JsonTestが読み込むコンテキストは限定的です:JSON 関連の Bean のみが対象です。Spring コンテキスト全体を使う場合は@SpringBootTestを使用します- Jackson のバージョン:アノテーションのバージョンが、使用中の Jackson のバージョンと一致していることを確認します
- 日付形式:デフォルトは ISO-8601 です。カスタムパターンには
@JsonFormatを使用します - null の処理:シリアライズ時に null を除外するには、
@JsonInclude(Include.NON_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 対応