目次

目次

【Swift】CoreDataのデータ保存の仕方がVRTを不安定にしていた

アバター画像
上野 翔碁
アバター画像
上野 翔碁
最終更新日2026/03/30 投稿日2026/03/30

はじめに

こんにちは、株式会社レコチョク NX開発推進部 プロダクト開発第2Gの上野です。 2025年4月に新卒で入社し、普段はPlayPASSアプリのiOS開発を担当しています。

最近は、やっとのことで手に入れたSwitch2で、Pokémon LEGENDS Z-Aをやっています。ストーリーをクリアしたので、色違いの厳選・ランクマ・追加コンテンツM次元ラッシュ…どれからやろうか迷っています。

さて今回は、iOSアプリ開発で重要な「VRT(Visual Regression Testing)」が安定しなかった原因を調査したお話をしようと思います。

PlayPASSの機能改善にともない、VRTの再撮影が必要でした。その際、実行するたびにコンテンツの表示順がランダムに変わってしまうという現象が起きました。

調べていくと、原因はテスト対象の処理ではなく、CoreDataへのデータ保存の仕方にあることがわかりました。本記事では、テストコードを書くときに注意すべきことや意識すべきことを紹介します。

動作環境

  • Xcode 26.2
  • iOS 26.2
  • Swift Language Version: 5

VRTとは

VRTとは、UIのスクリーンショットを比較することで、意図しない見た目の変化を検出するテスト手法です。詳しくは社内の別記事「Visual Regression Testingを導入してみた(iOSアプリ) | レコチョクエンジニアブログ」をご覧ください。

基本的な仕組みはシンプルです。

  1. 基準となるスクリーンショットを撮影し、保存する
  2. コード変更後に再撮影する
  3. 1と2を比較し、差分があれば検出する

差分があれば「意図した変更か、意図しないバグか」を人間が判断します。

図1. PlayPASSアプリのアーティスト詳細画面でセクションタイトルを「楽曲」から「Music」に変更したときのVRT

1. 基準となるスクショ 2. コード変更後のスクショ 3. 変更差分が検出される
image01.png image02.png image03.png

VRTが成立するための大前提として、「同じ条件を与えれば、毎回同じスクリーンショットが撮れる」 ことが必要です。特に今回は「同じ順番でデータが表示されること」が崩れていたことが問題でした。

発生した問題

PlayPASSアプリのアーティスト詳細画面で、特定サービスのコンテンツのタイトルを非表示にするという実装をしました。

アプリ上の画面では、2種類のコンテンツが混在して表示されます。

  • サービスAのコンテンツ(タイトルを表示する)
  • サービスBのコンテンツ(タイトルを非表示にする)

当時、タイトルを表示しているVRT画像しか存在しておらず、新たに以下の2パターンを撮影する必要がありました。

  • サービスB(タイトル非表示)のみのVRT
  • サービスA/B(タイトル表示/非表示)が混在したVRT

混在パターンの撮影にあたっては、2種類のコンテンツを交互に並べた状態でモックデータを用意しました。これは、デバイス幅に対してコンテンツをまとめて並べるよりも、交互に配置した方が出し分けの挙動を確認しやすいと判断したためです。また、どのサービスのコンテンツかが一目でわかるよう、どちらのタイトルにも番号を付けるようにしました。

VRT撮影のためのテストコードは、元々あったコードを参考に以下のように書きました。

// TrackCoreDataObject: NSManagedObjectのサブクラス
// ServiceType: サービスの種別を表すenum(.serviceA / .serviceB)
func saveMockContentsMixed() throws {
    try (0..<10).forEach {
        let trackCoreDataObject = try XCTUnwrap(
            TrackCoreDataObject(insertInto: stack.context)
        )
        // 2の倍数(0, 2, 4, 6, 8)の場合はserviceA、それ以外はserviceB
        let serviceType: ServiceType = $0 % 2 == 0 ? .serviceA : .serviceB
        trackCoreDataObject.configure(.mockData(
            albumID: $0,
            trackID: $0,
            artistID: artist.storedIDs.compactMap(Int.init).first ?? 0,
            serviceType: serviceType,
            title: "title\($0)"
        ))
    }
    try stack.context.save()
}

図2. VRT撮影の理想画像

image-20260326012058356.png

しかし、撮影を始めると混在したVRTのコンテンツの表示順が実行するたびに変わり、差分として検出されてしまいました。また、元々あったサービスAのVRTでも、同様の結果となりました。

図3. VRT撮影の結果

VRT撮影1回目 VRT撮影2回目 VRT撮影3回目
image05.png image06.png image07.png

一方で、タイトルを非表示にするサービスBのVRTでは、差分として検出されませんでした。

では実際のアプリでは、毎回並び順が変わっているのでしょうか。実機を確認したところそうはなっておらず、アーティスト詳細画面に遷移するたび、同じ順番で表示されていました。

ここで私はテストコードとプロダクトコードで、データの処理が異なりそうと仮説を立て、調査を始めました。

原因調査

テスト対象の処理を疑う

まず、テスト対象であるタイトルの出し分け処理を疑いました。しかし、テストコードとプロダクトコードで出し分けのロジックは同一でした。表示条件のコードに差異はなく、テスト対象の処理は問題ではありませんでした。

周辺処理を疑う

次に、テスト対象以外の処理(データの生成から保存までの流れ)をプロダクトコードと比較しました。

ステップ テストコード プロダクトコード
① 生成 10件のモックデータをまとめて生成 APIレスポンスを1件ずつパース
② 保存 ループ終了後に context.save() を1回だけ呼ぶ 1件ごとに context.save() を呼ぶ
③ 取得 NSFetchRequest でソートなしにfetch NSFetchRequest でソートなしにfetch
④ 表示 実行するたびに表示順が変わる 毎回同じ順番で表示される

データの取得・表示の処理はテストコードとプロダクトコードで同一でした。異なっていたのは「① 生成」と「② 保存」の部分で、特に context.save() の呼び方に違いがありました。テストコードは10件を一括save、プロダクトコードは1件ずつsaveしていたのです。context.save()はCoreDataのメソッドで、saveの仕方がfetchの順序に影響していたことになります。

context.save()とは

CoreDataで扱うデータの1件1件を表すクラスをNSManagedObjectといいます。データベースで言えば1レコードに相当します。今回のコードでいえばTrackCoreDataObjectがこのサブクラスにあたります。

このNSManagedObjectを操作する場所がNSManagedObjectContext(今回でいうcontext)です。CoreDataにおいてデータの検索・生成・更新・削除といった変更操作とその追跡を担うクラスです(参考:【Swift】CoreDataの概念を知る | レコチョクエンジニアブログ)。

save()は、コンテキスト上で保留されていた変更(挿入・更新・削除)を永続ストレージに書き込んで確定させるメソッドです。これを呼ぶまで、変更はメモリ上にしか存在せず、アプリを終了するとデータが消えてしまいます。

このsave()のタイミングが、データの取得順序に影響します。その鍵になるのがNSManagedObjectID(objectID)です。CoreDataではすべてのNSManagedObjectにユニークな識別子が割り当てられます。objectIDには2つのフェーズがあります。

  • 一時ID:オブジェクトをコンテキストに追加した直後に割り当てられます。メモリ上にのみ存在し、永続ストレージへの書き込みはまだされていません。
  • 永続IDcontext.save()を呼んだ後、永続ストレージへの書き込みが完了したときに確定します。

Apple公式ドキュメントにも次のように明記されています。

New objects inserted into a managed object context are assigned a temporary ID which is replaced with a permanent one once the object gets saved to a persistent store.

NSManagedObjectID.isTemporaryID | Apple Developer Documentation

つまり、objectIDが確定するのはsave()のタイミングです。1件ずつsaveすれば作成順にobjectIDが確定しますが、まとめてsaveすると順序が変わります。

1件ずつ save() を呼ぶと、永続ストレージへの書き込みが1件ごとに完結し、objectIDが1件ずつ順番に確定します。

image.png

一方、まとめてsaveすると、複数のオブジェクト(レコード)が未保存の状態でコンテキスト上に積まれます。永続ストレージに保存されていない状態のオブジェクトは Set<NSManagedObject>として扱われます。

Setは順序を保証しないため、context.save()時にNSManagedObjectContext.insertedObjectsを反復処理する順序が実行ごとに変わる可能性があります。その結果、objectIDの割り当て順が不確定になります。

image-1.png

つまり、一括saveではfetchの結果順序が保証されず、個別saveであれば作成順と一致する ということです。

ただし、個別saveが「順序を保証している」わけではありません。CoreDataはSQLiteを永続ストレージとしてデフォルトで使用しており(バイナリやインメモリを指定する方法もあります)、SQLiteはORDER BYなしのSELECTで、ROWIDつまりobjectIDの順に返すことが多いため、結果的に作成順と一致して見えているに過ぎません。

これはSQLiteがフルテーブルスキャン時にrowID昇順でデータを読み取るという実装による挙動です(参考:Query Planning | SQLite Documentation)。

CoreData公式FAQでも「永続ストア内のオブジェクトは順序を持たない」と明言されており、ソート順を明示しないNSFetchRequestの結果順序は保証されていません。

How do I fetch objects in the same order I created them?

Objects in a persistent store are unordered. Typically you should impose order at the controller or view layer, based on an attribute such as creation date. If there is order inherent in your data, you need to explicitly model that.

Core Data Programming Guide: Frequently Asked Questions

解決方法

テストコードとプロダクトコードでCoreDataへの保存の仕方が異なっていたことが今回の原因でした。同じ条件を与えているつもりが、実際には異なる条件になっていたということです。そこで、テストコードのsaveを一括から個別に変更しました。

func saveMockContentsMixed() throws {
    try (0..<10).forEach {
        let trackCoreDataObject = try XCTUnwrap(
            TrackCoreDataObject(insertInto: stack.context)
        )
        let serviceType: ServiceType = $0 % 2 == 0 ? .serviceA : .serviceB
        trackCoreDataObject.configure(.mockData(
            albumID: $0,
            trackID: $0,
            artistID: artist.storedIDs.compactMap(Int.init).first ?? 0,
            serviceType: serviceType,
            title: "title\($0)"
        ))
        // NOTE: 個別にsaveすることで、1件ずつ順序が確定し、
        //       fetch結果が作成順と一致する。
        //       本番環境が1件ずつsaveする挙動を意図的に再現している。
        try stack.context.save()
    }
}

これだけで、VRTの表示順が安定しました。

ただし、これも仕様上の保証ではありません。SQLiteがROWID順に返すという挙動に依存しているに過ぎず、根本的な解決策は NSSortDescriptorで明示的にソートを指定することです。今回はテストコードをプロダクトコードと同じ挙動に揃えることで対処しました。

今回の気づきはコメントとして残し、同じつまづきが起きないようにしました。今後もこういった気づきや実装の違いがあれば、コメントやコミットで残しておきたいと思いました。

コラム:では、なぜ周辺処理が本番と異なっていたか

テストコードを一括saveにしたのは、周辺のテストコードを参考にした結果でした。save() はディスク書き込みを伴うため、まとめて1回だけ呼ぶ方が効率的で、VRTを撮影するだけであれば十分シンプルで良いコードでした。

問題が浮上したのは、「順序」という観点が加わったときです。プロダクトコードがAPIレスポンスを1件ずつsaveしているという実装を把握していなかったため、テストコードとの間にズレが生じていました。これはテストコードとプロダクトコードが別々に書かれる際に起きやすいパターンです。問題が顕在化するまで気づきにくい点でもあります。

まとめ

項目 テストコード(修正前) テストコード(修正後) プロダクトコード
saveのタイミング 一括(1回) 1件ずつ 1件ずつ
ObjectIDの割り当て順 不定 挿入順に安定 挿入順に安定
VRTの表示順 毎回変わる 安定 安定

今回の体験を通じて、以下のことを改めて意識するようになりました。

  • CoreDataのsave()の対象によって、データの取得順序が変わることがある
  • テストコードにおいても、プロダクトコードのデータ生成フローを再現できているかを確認する
  • VRTはUIだけでなく、その下にあるデータ生成が決定論的かどうかまで意識する必要がある

テストコードとプロダクトコードの差は、表示ロジックではなく「データの作られ方」にありました。今回はVRTで表面化しましたが、CoreDataを使ったテスト全般で起きうる問題です。CoreDataを使った開発をしている方の参考になれば幸いです。

おわりに

プロダクト開発従事者1年目はアプリの実装のことで精一杯でした。今回の経験を通じて、アプリから端末へどのようにデータが保存されるかといった構造への理解も必要だと感じました。2年目以降はアプリ以外の技術にも触れながら開発を進めていこうと思います。

コーディングエージェントが実装の中心になりつつある今、1つ1つの処理の順番も意識して開発を進めていく必要があると感じました。

最後まで読んでいただきありがとうございました。

参考文献

アバター画像

上野 翔碁

目次