クラウド Mac で iOS の設定値を回帰テストする

クラウド Mac で iOS の設定値を回帰テストする

同じ iOS 設定テストなのに、初回は成功し、2 回目は前回のテーマ設定を読み込んでしまう。クラウド Mac に移すと、また結果が変わる。こうした問題の原因は、たいていマシンの性能ではありません。テストが、プロセス内の既定値、保存済みの設定、起動引数を混同しているためです。VpsKeep でこの種のテストを実行する際は、まず設定を分離し、そのうえでアプリの振る舞いを検証しましょう。シミュレータを丸ごと消去して、偶然うまくいくことに頼ってはいけません。

まず読み取りの契約を明確にする

UserDefaults は、単一のキーと値のテーブルではありません。読み取り時には複数のドメインが検索されます。起動引数はアプリケーションドメインの値を上書きでき、登録ドメインはフォールバック値を提供します。そのため、object(forKey:) で値が取得できても、その値が保存されているとは限りません。

設定ごとに、キー名、許可する型、値がない場合の結果という 3 点を決めておきます。たとえばテーマのキーでは、system、light、dark だけを受け付けます。キーが存在しない場合、型が違う場合、未知の文字列が入っている場合は、いずれも system を返します。これにより、過去のバージョンが残した不正な値にも一貫した動作を定義できます。

register(defaults:) は、現在のプロセスで使うフォールバック値を登録するだけで、ユーザー設定として保存するわけではありません。永続化された値を削除したあとに既定値が読み取れるのは、正常なフォールバックであり、削除の失敗ではありません。

キーが保存されていることの確認に dictionaryRepresentation() を使わないでください。これは参照可能な設定をまとめたもので、登録ドメインの値が含まれる場合もあります。保存状態を確認するには、対象の永続ドメインを直接読み取ります。

専用の suite を注入し、実際の設定には触れない

本番コードでは .standard を既定で使ってもかまいませんが、テストでは専用のインスタンスを注入する必要があります。設定のラッパーがメソッド内で改めて .standard を取得する実装にしてはいけません。それでは依存性を注入しても、見かけ上の分離にしかなりません。

以下のコードは、iOS のユニットテストターゲットに追加できます。テストごとにランダムな suite 名を使い、自分で作成した永続ドメインだけを削除します。この例では、読み取り用のラッパーもテストファイル内に置いています。実際のプロジェクトに組み込む際は、ラッパーをアプリのターゲットへ移し、テスト側からインポートしてください。

import Foundation
import XCTest

struct ThemePreferences {
    let defaults: UserDefaults

    init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        defaults.register(defaults: ["theme": "system"])
    }

    var theme: String {
        guard let value = defaults.object(forKey: "theme") as? String,
              ["system", "light", "dark"].contains(value) else {
            return "system"
        }
        return value
    }
}

final class ThemePreferencesTests: XCTestCase {
    private func withSuite(
        _ body: (UserDefaults, String) throws -> Void
    ) throws {
        let name = "preferences-tests." + UUID().uuidString
        let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
        defaults.removePersistentDomain(forName: name)
        defer {
            defaults.removePersistentDomain(forName: name)
        }
        try body(defaults, name)
    }

    func testOverrideAndRemoval() throws {
        try withSuite { defaults, name in
            let preferences = ThemePreferences(defaults: defaults)

            XCTAssertEqual(preferences.theme, "system")
            XCTAssertNil(
                defaults.persistentDomain(forName: name)?["theme"]
            )

            defaults.set("dark", forKey: "theme")
            XCTAssertEqual(preferences.theme, "dark")
            XCTAssertEqual(
                defaults.persistentDomain(forName: name)?["theme"]
                    as? String,
                "dark"
            )

            defaults.removeObject(forKey: "theme")
            XCTAssertNil(
                defaults.persistentDomain(forName: name)?["theme"]
            )
            XCTAssertEqual(preferences.theme, "system")
        }
    }

    func testInvalidValuesFallBack() throws {
        try withSuite { defaults, _ in
            let preferences = ThemePreferences(defaults: defaults)

            defaults.set(["dark"], forKey: "theme")
            XCTAssertEqual(preferences.theme, "system")

            defaults.set("unknown", forKey: "theme")
            XCTAssertEqual(preferences.theme, "system")
        }
    }
}

ランダムな名前にすることで、並列実行中のテストが互いの値を上書きするのを防げます。終了時のクリーンアップは通常の実行経路で機能しますが、プロセスが強制終了された場合、defer の実行は保証されません。残ったランダムなドメインが次の実行で再利用されることはありません。ただし、常設のテスト端末では定期的にドメインを確認し、テスト用だと確認できたものだけを削除する必要があります。

上書きと削除の境界条件を補う

最低限必要なのは、保存値がないときのフォールバック、正しい値を書き込んだあとの上書き、削除後のフォールバック、型が不正な場合のフォールバック、未知の列挙値に対するフォールバックです。さらに、「アプリ側のラッパーを作り直しても保存値を読み取れる」ことも確認してください。これにより、ラッパーが初期化のたびに set を呼び、ユーザーの選択をリセットしてしまう実装を防げます。

アプリが共有 suite とアプリケーションドメインの両方を使っている場合、ユニットテストで安易に addSuite(named:) を呼ばないでください。検索経路が変わり、テスト結果が別のドメインに依存するようになります。まず共有設定を別の注入可能な依存関係として扱い、それぞれを検証してから、アプリの統合テストを行います。

真偽値の設定でも、「未設定」と「ユーザーがオフを選択した」を区別する必要があります。bool(forKey:) だけでは、値がない場合も false が返るため、ユーザーが操作したかどうかを判断できません。キーの存在を明示的に確認するか、バージョン管理された状態フィールドを別に設けてください。

2 つのプロセスで実際の永続化を確認する

同じプロセス内で UserDefaults オブジェクトを作り直しても、確認できるのは読み取りの動作だけです。再起動後も値が保持されることの証明にはなりません。統合テストは書き込みと読み取りの 2 段階に分け、両方で同じ固定名のテスト用 suite を使います。

第 1 段階ではテーマを書き込み、画面またはテスト用インターフェースで読み戻せることを確認してから、アプリを正常終了させます。第 2 段階ではアプリを起動し、値を再び書き込まずに、テーマが復元されているかを直接確認します。最後に独立したクリーンアップ段階を設け、このテスト用ドメインを削除します。

プロセスを強制終了した直後の読み取りを、通常の保存確認として扱ってはいけません。それは異常終了時の境界条件を検証するテストです。また、数秒間の固定待機や synchronize() の呼び出しを根拠に、ディスクへの書き込みが完了したと判断してはいけません。

起動引数は別のテストとして扱います。アプリが引数でテーマを注入する場合は、その上書きを個別に検証し、以降の起動では引数を外してください。引数が残っていると保存値が隠され、永続化テストが実際には成立していないのに成功したように見えてしまいます。

失敗を追跡できる記録にまとめる

クラウド Mac のパイプラインでは、テスト名、suite 名、アプリのバージョン、キーの有無、実際の型、期待するフォールバック値を記録すれば十分です。設定辞書全体をそのまま出力してはいけません。ユーザー識別子や内部環境の情報が含まれている可能性があります。

続けて 2 回実行し、その後に並列実行を有効にして、結果が一致するかを確認します。2 回目だけ失敗するなら、まず固定名の suite と未削除のドメインを探します。並列実行時だけ失敗するなら、共有シングルトンを確認します。再起動後だけ失敗するなら、初期化処理がユーザーの値を上書きしていないかを確認します。

最終的な合格基準は、「一度、正しい文字列を読み取れた」ことではありません。既定値、ユーザーによる上書き、削除後のフォールバック、プロセスの再起動について、それぞれ独立した根拠が必要です。これらの境界条件を明確にしてこそ、新しいマシンでも常設ノードでも、設定テストで同じ結論を得られます。

よくある質問

register(defaults:) は既定値をディスクに保存しますか?

保存しません。現在のプロセスの登録ドメインに値を追加します。次回起動時にも登録処理が必要です。

UserDefaults を作り直せば永続化を確認できますか?

確認できません。同じプロセスのオブジェクトは状態を共有する場合があります。書き込み側を正常終了し、新しいプロセスで読み取ってください。

専用クラウドMacで作業を実行

Mac miniを1台まるごとレンタルできます。仮想マシンではありません。日・週・月・四半期から利用期間を選び、機種とノードを確認してから注文してください。実際の空き状況はコントロールパネルにリアルタイムで表示されます。