一覧画面がまれに無効化された NSManagedObject を読み取り、ローカルでは数十回連続で実行しても問題ないのに、CIでは保存、マージ、画面更新のタイミングでランダムにクラッシュすることがあります。こうした問題の原因は、通常データベースファイルの破損ではなく、オブジェクトが所属する NSManagedObjectContext のキューを越えて使われていることです。リトライを増やすのではなく、クラウドMacでCore Dataの並行処理デバッグを有効にし、不正なアクセスを発生源に最も近い位置で即座に失敗させます。
まず問題の範囲を確認する
Core Dataの基本ルールは明確です。プライベートキューのcontextは、そのcontext自身の perform または performAndWait 内でのみ使用できます。メインキューのcontextには、メインキューからしかアクセスできません。また、NSManagedObject を別のキューへ直接渡すこともできません。キューをまたいで受け渡す場合は、安定した NSManagedObjectID を渡し、受け取り側のcontextでオブジェクトを再取得します。
まず、次のようなリスクの高い箇所を確認します。
- ネットワークコールバックが管理対象オブジェクトをメインスレッドへ直接渡し、画面を更新している。
Task.detachedがcontextまたは管理対象オブジェクトをキャプチャしている。- バックグラウンド保存の完了後、オブジェクトの配列を通知やクロージャへ渡している。
- テストが同じ永続コンテナを共有し、直前の非同期書き込みの完了を待っていない。
- contextのマージ中も、画面側が削除済みまたはリセット済みのオブジェクトを保持している。
performを単なる非同期ラッパーとして扱わないでください。保護されるのは特定の保存処理だけではなく、contextとそのオブジェクトグラフ全体です。
並行処理デバッグを有効にする
専用のテストSchemeの起動引数に、次の2項目を追加します。
-com.apple.CoreData.ConcurrencyDebug
1
UIテストでは、開発者のローカルSchemeとCIのSchemeで設定が食い違わないよう、テストコードから明示的に渡すことを推奨します。
let app = XCUIApplication()
app.launchArguments += [
"-com.apple.CoreData.ConcurrencyDebug",
"1"
]
app.launch()
通常のユニットテストがホストアプリに依存する場合は、共有Schemeに同じ引数を設定し、.xcscheme をリポジトリへコミットします。この引数をリリース構成に含めてはいけません。違反を検出するとデバッグ機構がプロセスを意図的に終了しますが、これはまさにテスト環境で必要な動作です。
有効化したら、まず最小限のテストセットを実行します。最初からテストスイート全体を実行すると、多数の並行タスクによって最初の有効なコールスタックが埋もれ、ログ解析の負担も増えます。
オブジェクトではなくIDを受け渡す
典型的な誤りは、バックグラウンドcontextでオブジェクトを作成した後、そのオブジェクト自体をメインキューへ直接渡すことです。正しくは、まず保存して永続object IDを取得し、受け取り側のcontextでオブジェクトを取得し直します。
func importItem(
payload: Payload,
backgroundContext: NSManagedObjectContext,
viewContext: NSManagedObjectContext
) async throws {
let objectID = try await backgroundContext.perform {
let item = Item(context: backgroundContext)
item.title = payload.title
try backgroundContext.obtainPermanentIDs(for: [item])
try backgroundContext.save()
return item.objectID
}
try await viewContext.perform {
let item = try viewContext.existingObject(with: objectID) as! Item
item.lastViewedAt = Date()
try viewContext.save()
}
}
ここには3つの確認点があります。返す前に永続IDを取得していること、受け取り側のcontextが existingObject(with:) でオブジェクトを解決し直していること、すべてのプロパティの読み書きが対応するcontextのクロージャ内で行われていることです。一時IDだけを渡すと、受け取り側のcontextで解決できない可能性があります。また、クロージャの外でプロパティを読み取れば、たとえ文字列を1行読むだけでもキュー制約への違反です。
安定して再現できるテストを設計する
テストで重要なのは、ランダムな並行処理を積み重ねることではなく、競合が起きる経路を固定することです。シナリオは、バックグラウンドでの挿入、バックグラウンドでの保存、メインcontextへのマージ、メインキューでの読み取りという4段階に分けられます。テストごとに独立したインメモリ永続ストアを使用し、終了時にはすべてのタスクが完了するまで待機します。
| 確認項目 | 失敗の兆候 | 修正方針 |
|---|---|---|
| バックグラウンド書き込み | クロージャ外からcontextへアクセスしている | perform 内へ戻す |
| オブジェクトの受け渡し | クロージャが管理対象オブジェクトをキャプチャしている | object IDを渡す |
| マージ通知 | 通知元のcontextが明確でない | 保存通知の送信元を限定する |
| テストのクリーンアップ | 後続のテストケースにも通知が届く | オブザーバーを削除してコンテナを破棄する |
保存の完了待ちに固定の sleep を使ってはいけません。await、expectation、または永続コンテナのコールバックを使い、実際の完了条件を設定します。固定時間の待機では、競合が発生する時間帯を別の場所へ移すだけです。
発生頻度の低い経路については、同じ小規模テストを20〜50回繰り返しても構いませんが、全体のタイムアウトを設定する必要があります。繰り返し回数はタイミングの網羅性を高めるためのものであり、決定的な状態同期の代わりにはなりません。
CIで障害発生時の証拠を残す
クラウドMacのジョブでは、使用するXcode、シミュレータのターゲット、結果の出力先を固定します。次のスクリプトは、失敗時に完全なログと結果バンドルを残します。
#!/bin/zsh
set -euo pipefail
mkdir -p artifacts logs
rm -rf artifacts/CoreDataTests.xcresult
if ! xcodebuild test \
-workspace App.xcworkspace \
-scheme AppConcurrencyTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath artifacts/CoreDataTests.xcresult \
> logs/coredata-tests.log 2>&1; then
grep -nE 'CoreData|Concurrency|NSManagedObjectContext' \
logs/coredata-tests.log || true
exit 1
fi
ターゲットデバイス名には、現在のXcodeで実際に利用できるシミュレータを指定します。すべてのイメージで同じ名前が使えると想定してはいけません。ログを提出する前に、業務データ、アクセストークン、ユーザーディレクトリに含まれる機密情報も削除します。結果バンドル、完全なログ、失敗したテスト名、コミット番号、起動引数は、1組の証拠としてまとめて保存します。
ジョブで並列テストが必要な場合でも、まず並行処理デバッグ用のテストスイートだけを直列で実行します。目的は最大のスループットを得ることではなく、最初のキュー越境アクセスを特定することです。安定性を確認した後で、通常のテスト分割へ組み込めるかを評価します。
ノイズの少ないマージゲートを構築する
マージゲートは2層に分けることを推奨します。第1層では、データ層のユニットテストと少数の重要なUIフローだけを実行し、並行処理デバッグを常に有効にします。第2層では完全な回帰テストを実行しますが、基本的なキュー違反の検出をこの層に依存させません。第1層が失敗した場合は自動リトライを禁止します。リトライで成功すると、本物の不具合がインフラの一時的な揺らぎに見えてしまうためです。
マージ前に、次の項目を再確認します。
- Schemeが共有され、バージョン管理に含まれている。
- テストが独立したストアを使用し、開発用データベースを再利用していない。
- context、オブジェクト、fetched results controllerのいずれもキューをまたいで渡されていない。
- 通知オブザーバーがテスト終了時に正しく削除されている。
- CIが失敗してもログとxcresultがアップロードされる。
- 修正コミットに、修正前のコードでは失敗する回帰テストが含まれている。
キューをまたぐオブジェクトの受け渡しをすべてobject IDへ置き換え、非同期処理の完了条件を明確にし、結果の証拠を追跡可能にすれば、「本番環境でしか偶発しない」ように見えたクラッシュも、通常のコードレビューで扱える問題になります。
よくある質問
並行処理デバッグは本番アプリでも有効にすべきですか?
いいえ。違反を検出するとプロセスを停止させるため、開発用またはCI専用のテスト構成でのみ有効にし、配布用構成には含めません。
テストが通るのに実運用で問題が出るのはなぜですか?
通常のテストでは競合するタイミングを十分に作れないためです。バックグラウンド保存、メインキューでの読み出し、objectIDによる受け渡し、マージを同じシナリオで反復します。
CI失敗時に保存すべき情報は何ですか?
xcodebuildの完全なログ、xcresult、失敗したテスト名、コミット番号、起動引数を保存します。クラッシュレポートがあれば、機密情報を除いて追加します。
次のビルドに備えて専用物理Macを用意
タスクに合わせて機種、データセンター、利用期間を選択できます。ノードは365日継続稼働しており、実際の利用可能状況はコンソールのリアルタイム情報をご確認ください。