ユーザーが iOS アプリをアップデートした後も、端末には旧バージョンが保存した JSON キャッシュが残っていることがあります。サーバーからの最新の応答を正常にデコードできても、古いキャッシュまで読めるとは限りません。Codable モデルに項目を追加した際、新しい応答だけをテストし、ユーザーがオフライン画面を開いて初めてデコードエラーが発覚するのはよくあるケースです。対策は、あらゆるエラーを黙って見逃すことではありません。実際の過去データを固定サンプルとして残し、どの変更を許容し、どの入力をエラーにすべきかを明確にします。
まず互換性の境界を決める
アカウントの概要を例にします。id と status は業務上の必須項目で、nickname は後から追加した任意項目です。期待する動作は、nickname がない古いデータは読めること、新しいデータにクライアントがまだ知らないキーがあっても読めること、id がなければ失敗することです。この 3 種類の入力は別々にテストします。「正常な応答」1 件だけでは互換性を証明できません。
自動合成された Decodable の実装では、String? は値がなくても受け付け、結果は nil になります。一方、非オプショナルの String が欠けているとエラーがスローされます。未知のキーも通常は無視されます。ただし、これはデコード層での動作にすぎません。画面側で nickname を強制アンラップしていれば、古いキャッシュは依然として不具合の原因になります。モデルのテストが通った後も、その項目を使うコードに代替表示があるか確認してください。
「古いデータを読めること」と「壊れたデータを拒否すること」の両方をテストに明記します。前者だけを調べると、本当のデータエラーを互換性対応で覆い隠しかねません。
過去の応答を固定サンプルにする
テストターゲットに Fixtures ディレクトリを作り、account-v1.json、account-v2.json、account-missing-id.json を保存します。ファイル名には特定の CI 実行日ではなく、データの形を表します。3 つのファイルをテストターゲットの Copy Bundle Resources に追加してください。プロジェクトのディレクトリにファイルを作るだけでは、テスト実行時に自動で見つかるようにはなりません。
account-v1.json には、id と status があり、nickname がない古い応答を残します。account-v2.json には nickname と、現在のモデルでは宣言していないキーを 1 つ追加します。3 つ目のサンプルでは id を削除し、失敗すべき反例とします。サンプルは、機密情報を除いた実データの形を基に整えてください。個人情報やトークンは置き換えつつ、項目の型、欠落の有無、ネスト構造は維持します。API が変わるたびに古いファイルを上書きすると、回帰を検出する力が失われます。
サーバーが項目を文字列から数値に変更した場合は、新しいバージョンを別に保存し、アプリが両方の形式をサポートする必要があるか先に決めます。テストを通すためだけに、すべての項目へ緩い文字列変換を適用しないでください。互換性が必要なら、明示的なカスタムデコードの分岐と、それに対応するサンプルを用意します。
XCTest で 3 種類の結果を検証する
次のテストは、サンプルがテスト bundle のルートディレクトリにコピーされていることを前提とします。例では AccountSnapshot を独立して宣言していますが、実際のプロジェクトではテスト対象の本番モデルに置き換えてください。一時的に作った別の構造体だけがテストを通る事態を避けるためです。
import Foundation
import XCTest
private struct AccountSnapshot: Decodable {
let id: String
let status: String
let nickname: String?
}
final class CodableContractTests: XCTestCase {
private func decode(_ name: String) throws -> AccountSnapshot {
let url = try XCTUnwrap(
Bundle(for: Self.self).url(
forResource: name,
withExtension: "json"
)
)
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(AccountSnapshot.self, from: data)
}
func testOldResponseRemainsReadable() throws {
let account = try decode("account-v1")
XCTAssertEqual(account.id, "example-001")
XCTAssertNil(account.nickname)
}
func testUnknownFieldDoesNotBreakDecoding() throws {
let account = try decode("account-v2")
XCTAssertEqual(account.id, "example-001")
XCTAssertEqual(account.nickname, "Tester")
}
func testMissingRequiredFieldFails() {
XCTAssertThrowsError(try decode("account-missing-id"))
}
}
読めるはずの 2 つのサンプルに同じ例示用 id を使うことで、アサーションの意味が明確になります。現状の失敗テストは、エラーがスローされることだけを要求しています。エラーの種類自体がデータ処理上の契約なら、DecodingError.keyNotFound と対象の項目名も追加で検証します。これにより、「欠落した項目を想定どおり拒否した場合」と「テストリソースがそもそも見つからなかった場合」を区別できます。
日付と列挙型の動作を推測しない
サンプルに日付が含まれるなら、本番コードで実際に使う JSONDecoder.dateDecodingStrategy を先に確認し、テストでも同じ設定を使います。状態値に新しい列挙型のケースが追加される場合も、未知の状態を保持して汎用メッセージを表示するのか、処理を拒否するのかを決めてください。その判断をモデルとサンプルに反映し、テスト側で独自のデコード方針を急ごしらえしないようにします。
クラウド Mac のテストジョブに組み込む
まず xcodebuild -list でプロジェクトと共有 scheme を確認し、現在利用できるシミュレータの一覧からデバイス ID を取得します。次のコマンドで使う環境変数は CI ジョブから渡し、TEST_TARGET にはテストターゲット名を指定します。デバイス ID を使えば、同名のシミュレータによって実行先が曖昧になるのを防げます。
: "${PROJECT:?Set PROJECT}"
: "${SCHEME:?Set SCHEME}"
: "${SIM_ID:?Set SIM_ID}"
: "${TEST_TARGET:?Set TEST_TARGET}"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,id=$SIM_ID" \
"-only-testing:$TEST_TARGET/CodableContractTests" \
test
CDNMac のクラウド Mac では、選択した Xcode でプロジェクトを開けること、scheme が共有されていること、テストリソースが bundle に含まれていることを確認してからジョブを実行します。ここで実行するのはデータの契約テストなので、署名用の資料も実機の接続も不要です。CI には xcodebuild の終了ステータスをそのまま反映させてください。サンプルの欠落、デコードの失敗、アサーションの不一致はいずれもジョブを失敗させるべきで、ログ内のキーワードで成功を判定してはいけません。
サンプルの保守時に確認すること
モデルの変更をコードレビューする際は、項目ごとに問い直します。古いキャッシュを今後も読み込むか。新しい項目が古いデータにないとき、画面には何を表示するか。未知のキーは無視すべきか。必須項目が壊れていた場合、エラーはどこへ渡るか。そのうえで、見た目が似たサンプル実装だけでなく、本番モデルと本番のデコード設定をテストしているか確認します。
テストが失敗したら、まずエラーの発生元を調べます。ファイルが見つからなければ、ターゲットへの登録と bundle を確認します。keyNotFound なら、その項目が本当に必須なのか、後から追加した任意項目なのかを確かめます。typeMismatch なら、実データの型が変わっていないか調べます。アサーションが失敗したなら、デコード結果の業務上の意味を確認します。これらを一律に try? に変えて空のオブジェクトを返してはいけません。残しておいた過去のサンプルは、「今も何を読めるか」を示す、チームの実行可能な記録です。
よくある質問
任意項目を追加した後も古い JSON サンプルは必要ですか?
必要です。既存のキャッシュには新項目がないため、欠落時に nil としてデコードできることを継続して確認します。
未知の JSON 項目はエラーにすべきですか?
通常は不要です。合成された Decodable は宣言されていないキーを無視します。必須項目の欠落だけを別のサンプルで失敗として検証します。
次回のiOSビルドを専有クラウドMacで実行
M4、16GB RAM、256GB SSDを備えた専用物理環境。6つのノードと4種類の利用期間から、作業に合った組み合わせを選べます。