JSONで永続化したデータを安全に保つテスト手法

この記事は KINTOテクノロジーズ Advent Calendar 2025 の12日目の記事です🎅🎄
はじめに
my routeのAndroidアプリを開発している長谷川です。
開発をしていると、何らかの理由でメンテナンス性を犠牲にしてしまうことってありますよね。「今動けばいいや」という感じで。
この記事では、アプリがローカルにデータを保存(永続化)する際に陥りがちな罠と、それを防ぐテスト手法を紹介します。ここでは例として Android を取り上げていますが、ローカル永続化を扱うあらゆるアプリケーションに共通する問題であり、同様のアプローチで対策できます。
ケーススタディ:チケット機能の実装
シンプルなチケットの永続化
とあるアプリの開発では、お得なチケット機能があります。チケットには以下のような情報があります。
data class Ticket(
val id: String,
val name: String,
)
このチケットはオフラインでも使える必要があります。オフライン状態ではサーバーから情報を取得できないため、ローカルに永続化する必要があります。
AndroidのSQLiteデータベースを扱いやすくするRoomライブラリを使うと、シンプルなデータ構造なら、アノテーションをいくつかつけるだけで永続化することができます。
@Entity
data class Ticket(
@PrimaryKey
val id: String,
val name: String,
)
現実は複雑...
しかし、実際のチケット情報はもっと複雑です。
- 価格情報
- 有効期限
- 利用条件
AndroidのRoom (SQLite) などのRDB (リレーショナルデータベース) では、このような複雑なオブジェクトをそのまま保存できません。通常は以下のような対応が必要です。
- テーブル設計を工夫してリレーションを作る
TypeConverter(複雑な型を基本型に変換する仕組み) を使って変換する
しかし、これらの対応は結構大変です...
「あ〜、今はそんなこと考えている時間ないなぁ!」
楽な方法:全部JSONにする!
そこで思いつくのが、全部JSONにしてしまうという方法です。
// ビジネスロジックで使う実際のデータクラス
@Serializable // kotlinx.serializationを使用
data class Ticket(
val id: String,
val name: String,
// ... その他複雑なプロパティ (価格、有効期限など)
)
// データベースに保存する用のクラス
@Entity
data class TicketForDB(
@PrimaryKey
val id: String,
val json: String, // Ticketを丸ごとJSON文字列として格納
)
// 保存
fun write(ticket: Ticket) {
db.write(
TicketForDB(
id = ticket.id,
json = Json.encodeToString(ticket) // オブジェクト → JSON文字列
)
)
}
// 復元
fun read(id: String): Ticket {
val ticketForDB = db.read(id)
return Json.decodeFromString<Ticket>(ticketForDB.json) // JSON文字列 → オブジェクト
}
やった〜!複雑なデータ構造を持つチケット情報を簡単に保存できた!
この方法なら
- 複雑なTypeConverterを書く必要なし
- テーブル設計で悩まなくていい
- 開発スピードが速い
数ヶ月後...チケット機能の改善
時は流れ、新機能の開発が始まります。
「一つのチケットで複数人が利用できる機能を追加しよう!」
今までは一つのチケットで一人しか利用できませんでしたが、グループ利用に対応するため、maxUsersPerTicketというパラメータを追加しました。
@Serializable
data class Ticket(
val id: String,
val name: String,
val maxUsersPerTicket: Int, // 追加!
// ... その他のプロパティ
)
やったね!これで便利な新機能も簡単に開発できた!
テストも通ったし、リリース準備OK!
リリース後、問題発生...
リリース後しばらくして、
「クラッシュ報告が来ています!オフラインでチケットが使えないという問い合わせが多数...」
何が起きたのか?
原因は、新しく追加したmaxUsersPerTicketプロパティです。
問題の流れ:
[v1.0] チケット保存
{"id":"abc123", "name":"Sample Ticket"}
↓
[v2.0にアップデート]
↓
[復元を試みる]
maxUsersPerTicketが必要だが、JSONにない
↓
クラッシュ!
永続化されたJSONにはmaxUsersPerTicketがありません。しかし、新しいTicketクラスはmaxUsersPerTicketを必須としています。JSONライブラリは値を決められず、エラーになります。
// この時、永続化されたJSONには maxUsersPerTicket が存在しない
val ticket = Json.decodeFromString<Ticket>(json)
// → Field 'maxUsersPerTicket' is required for type Ticket
どうすれば良かったのか?
解決策1:RDBでリレーショナルモデリングを頑張る
RDBはJSONとして保存するよりも、圧倒的に型に強いです。
丁寧にテーブル設計を行っていれば
- スキーマ変更時にRoomのコンパイルエラーで気づける
- 型安全性が保証される
- データの整合性を保ちやすい
一方でデメリットもあります。
- 複雑なデータ構造の表現が難しい (RDBは表形式)
- テーブル設計に時間がかかる
- TypeConverterやリレーションの管理が煩雑
- 将来的なメンテナンス性も不透明
→ 準備が大変な割に、メンテナンス性が必ずしも高くなるとは限らない
解決策2:JSONを使いつつ、ユニットテストで安全性を確保する
JSONの手軽さを維持しつつ、ユニットテストで問題を早期発見する方法です。
課題
将来の開発者 (または未来のあなた) が、何も知らずにTicketのプロパティを追加/削除/変更しても気づけるようにするには?
解決方法
過去のJSON形式をテストで保存し、デシリアライズをテストする
まず、現在のバージョンで保存されるJSONをfixtureファイル (テストで使う固定サンプルデータ) として保存します。
// fixture.json (テストリソースとして保存)
{
"id": "abc123",
"name": "Sample Ticket"
}
テストコード
以下の2つのテストを追加します。
@Test
fun `古いバージョンのJSONをデシリアライズできる`() {
// 過去に永続化されたJSONが、現在のコードで読み込めることを保証
val jsonString = loadJson("fixture.json")
val deserialized = runCatching {
Json.decodeFromString<Ticket>(jsonString)
}
// 失敗したら、新しいプロパティに初期値が必要など、問題に気づける
assert(deserialized.isSuccess) {
"デシリアライズに失敗しました: ${deserialized.exceptionOrNull()}"
}
}
@Test
fun `デシリアライズ後に再シリアライズすると元のJSONと一致する`() {
// fixtureファイルの更新漏れを検知
val jsonString = loadJson("fixture.json")
val deserialized = Json.decodeFromString<Ticket>(jsonString)
val serialized = Json.encodeToString(deserialized)
// プロパティを削除したのにfixtureを更新していないケースなどを検知
assert(jsonString == serialized) {
"JSONが一致しません。fixture.json の更新が必要かもしれません"
}
}
このテストが守ってくれるもの
- 古いJSONとの互換性:過去のバージョンのJSONが読み込めることを保証
- fixtureの更新漏れ防止:データ構造が変わったら気づける
実際にテストを動かしてみよう
ケース1:プロパティを追加した場合
問題が発生した例と同じく、数ヶ月後、複数人利用機能が追加されたとします。
@Serializable
data class Ticket(
val id: String,
val name: String,
val maxUsersPerTicket: Int, // 追加
)
先ほどのユニットテストを実行してみます。使用しているJSONライブラリにもよりますが、概ね以下のようなエラーでテストが落ちます。
Field 'maxUsersPerTicket' is required
これで将来の開発者やAIはこのデータクラスが永続化されていて、追加されるパラメータには初期値が必要なことが分かります。なぜならすでに永続化されたデータには新しい値が当然考慮されていないからです。
次にプロパティの名前が変更されたとします。
ある開発者がnameという名前は抽象的だ!と言い出して、displayNameに変更しました。
@Serializable
data class Ticket(
val id: String,
val displayName: String, // 変更
)
先ほどのユニットテストを実行してみます。同じように以下のようなエラーにより、問題に気づくことができます。
Field 'displayName' is required
次に、Ticketから名前が不要になりました。
@Serializable
data class Ticket(
val id: String,
// val displayName: String, // 削除
)
たいていのプロジェクトではJSONライブラリの設定で、ignoreUnknownKeysのような、知らない値が来たら無視するための設定を有効にしていることが多いと思います。
したがってこれによるクラッシュが発生することは少ないでしょう。
しかし永続化されているであろうfixtureファイルの更新を漏らしたくないです。
先ほどのユニットテストを実行してみます。
以下のようなエラーにより、fixtureファイルの更新漏れを防ぐことができるでしょう。
"JSONが一致しません。fixture.json の更新が必要かもしれません"
(上記のテストではどのプロパティに差分があるかまでは分からないので、実際にはobjectとして比較する案が考えられる)
まとめ
本記事では、永続化したデータを安全に扱うためのテスト手法を紹介しました。
今日から始められること
- 現在永続化しているデータのJSON例をfixtureとして保存
- そのJSONをデシリアライズするテストを書く
- CIに組み込む
一度ユーザーの端末に保存されたデータは、簡単には消せません。それは「技術的負債」になりやすい部分ですが、裏を返せば、ここを堅牢に保つことこそがアプリの長期的な信頼性に繋がります。
また、今回紹介したFixtureを用いたテストは、JSONライブラリの置き換え (例:GsonからMoshi/Kotlin Serializationへの移行) などにおいても極めて強力な武器になります。同じFixtureに対してテストが通れば、ライブラリや環境が変わっても「以前と同じようにデータを復元できる」ことが保証されるからです。
「今動くコード」を書くのは当然として、私たちはそこから一歩進んで、「ライブラリが変わっても、担当者が変わっても、少しでも安全に動き続けるコード」を残せるエンジニアでありたいものです。
関連記事 | Related Posts
We are hiring!
【フロントエンドエンジニア(リードクラス)】KINTO中古車開発G/東京
KINTO開発部KINTO中古車開発グループについて◉KINTO開発部 :58名 - KINTOバックエンドG:17名 - KINTO開発推進G:8名 - KINTOフロントエンドG:19名 - KINTOプロダクトマネジメントG:5名 - KINTO中古車開発G:19名★ ←こちらの配属になります。
【ソフトウェアエンジニア(リーダークラス)】共通サービス開発G/東京・大阪・福岡
共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。


