iOS-Einstellungen auf dem Cloud-Mac zuverlässig testen

iOS-Einstellungen auf dem Cloud-Mac zuverlässig testen

iOS-Einstellungen auf dem Cloud-Mac zuverlässig testen

Dieselben Tests für iOS-Einstellungen laufen beim ersten Mal erfolgreich durch, lesen beim zweiten Mal aber den Theme-Wert aus dem vorherigen Lauf. Nach dem Wechsel auf einen Cloud-Mac fällt das Ergebnis wieder anders aus. Meist liegt das nicht an der Rechnerleistung, sondern daran, dass die Tests prozessinterne Vorgaben, gespeicherte Einstellungen und Startargumente vermischen. Wenn Sie solche Aufgaben auf VpsKeep ausführen, isolieren Sie zuerst die Einstellungen und prüfen Sie anschließend das Anwendungsverhalten. Den gesamten Simulator zu leeren und auf Besserung zu hoffen, ist keine Lösung.

Zuerst den Lesevertrag festlegen

UserDefaults besteht nicht nur aus einer einzigen Schlüssel-Wert-Tabelle. Beim Lesen werden mehrere Domänen durchsucht: Startargumente können Werte aus der Anwendungsdomäne überschreiben, während die Registrierungsdomäne Rückfallwerte bereitstellt. Dass object(forKey:) einen Wert liefert, bedeutet deshalb nicht, dass dieser Wert gespeichert wurde.

Legen Sie für jede Einstellung zunächst drei Dinge fest: den Schlüssel, den zulässigen Typ und das Ergebnis bei fehlendem Wert. Der Theme-Schlüssel akzeptiert beispielsweise nur system, light und dark. Fehlt der Wert, hat er den falschen Typ oder enthält er eine unbekannte Zeichenfolge, wird immer system zurückgegeben. Damit ist auch das Verhalten bei ungültigen Altwerten aus früheren Versionen eindeutig.

register(defaults:) registriert lediglich Rückfallwerte für den aktuellen Prozess. Es speichert sie nicht als Benutzereinstellungen. Wenn nach dem Löschen eines persistenten Werts wieder der Vorgabewert gelesen wird, ist das ein normaler Rückfall und kein fehlgeschlagener Löschvorgang.

Verwenden Sie dictionaryRepresentation() nicht als Nachweis dafür, dass ein Schlüssel gespeichert wurde. Die Methode fasst die sichtbaren Einstellungen zusammen und kann Werte aus der Registrierungsdomäne enthalten. Um den Speicherzustand zu prüfen, lesen Sie gezielt die betreffende persistente Domäne aus.

Eine eigene Suite injizieren und echte Einstellungen unangetastet lassen

Produktionscode darf standardmäßig .standard verwenden. Tests müssen dagegen eine eigens dafür vorgesehene Instanz injizieren. Die Einstellungskapselung darf nicht innerhalb ihrer Methoden erneut auf .standard zugreifen, sonst schafft die Dependency Injection nur eine scheinbare Isolation.

Der folgende Code kann in ein iOS-Unit-Test-Target übernommen werden. Jeder Test verwendet einen zufälligen Suite-Namen und löscht ausschließlich die selbst angelegte persistente Domäne. Im Beispiel befindet sich auch die Kapselung des Lesezugriffs in der Testdatei. Bei der Einbindung in ein Projekt verschieben Sie diese Kapselung in das App-Target und importieren sie im Test.

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")
        }
    }
}

Zufällige Namen verhindern, dass parallel ausgeführte Tests einander überschreiben. Die Bereinigung beim Verlassen der Funktion greift im normalen Ausführungspfad; wird der Prozess zwangsweise beendet, ist die Ausführung von defer nicht garantiert. Zurückgebliebene Domänen mit zufälligen Namen werden im nächsten Lauf zwar nicht erneut verwendet, dauerhaft genutzte Testgeräte sollten aber regelmäßig auf solche Reste geprüft werden. Löschen Sie dabei nur Domänen, die nachweislich zu den Tests gehören.

Grenzfälle beim Überschreiben und Löschen abdecken

Die Mindestabdeckung umfasst den Rückfall ohne gespeicherten Wert, das Überschreiben durch einen gültigen Wert sowie den Rückfall nach dem Löschen, bei falschem Typ und bei unbekanntem Enum-Wert. Ergänzen Sie außerdem einen Test, der prüft, ob nach dem erneuten Erstellen der Anwendungskapselung weiterhin der gespeicherte Wert gelesen wird. So verhindern Sie, dass die Kapselung bei jeder Initialisierung mit set die Benutzerauswahl zurücksetzt.

Wenn die Anwendung sowohl eine gemeinsame Suite als auch die Anwendungsdomäne nutzt, rufen Sie in Unit-Tests nicht unbedacht addSuite(named:) auf. Dadurch ändert sich der Suchpfad, und das Testergebnis wird von anderen Domänen abhängig. Behandeln Sie die gemeinsamen Einstellungen zunächst als weitere injizierbare Abhängigkeit und prüfen Sie beide getrennt. Führen Sie erst danach Integrationstests der Anwendung durch.

Bei booleschen Einstellungen muss ebenfalls zwischen „nicht gesetzt“ und „vom Benutzer deaktiviert“ unterschieden werden. Ein bloßer Aufruf von bool(forKey:) liefert bei einem fehlenden Wert false. Daraus lässt sich nicht ableiten, ob der Benutzer bereits eine Auswahl getroffen hat. Prüfen Sie ausdrücklich, ob der Schlüssel vorhanden ist, oder führen Sie ein separates, versioniertes Statusfeld ein.

Echte Persistenz mit zwei Prozessen prüfen

Ein neues UserDefaults-Objekt im selben Prozess belegt nur das Leseverhalten, nicht die Persistenz nach einem Neustart. Teilen Sie den Integrationstest in eine Schreib- und eine Lesephase auf und verwenden Sie für beide dieselbe fest benannte Test-Suite.

Schreiben Sie in der ersten Phase den Theme-Wert, bestätigen Sie über eine App-Ansicht oder eine Testschnittstelle, dass er zurückgelesen wird, und lassen Sie die Anwendung anschließend regulär enden. Starten Sie die Anwendung in der zweiten Phase erneut, ohne den Wert noch einmal zu schreiben, und prüfen Sie direkt, ob das Theme wiederhergestellt wurde. Löschen Sie die Testdomäne zum Schluss in einer separaten Bereinigungsphase.

Ein erzwungenes Beenden mit unmittelbar anschließendem Lesen ist kein gewöhnlicher Nachweis erfolgreicher Speicherung, sondern ein Test für den Sonderfall eines abrupten Prozessendes. Auch eine feste Wartezeit von einigen Sekunden oder ein Aufruf von synchronize() ist kein Beleg dafür, dass die Daten auf den Datenträger geschrieben wurden.

Startargumente gehören in eine eigene Testreihe. Wenn die Anwendung das Theme über Argumente injiziert, prüfen Sie dieses Überschreiben separat und entfernen Sie die Argumente bei späteren Starts. Andernfalls verdecken sie den gespeicherten Wert und können den Persistenztest fälschlich erfolgreich erscheinen lassen.

Fehler so protokollieren, dass sie nachvollziehbar bleiben

In der Pipeline auf dem Cloud-Mac genügt es, Testname, Suite-Name, App-Version, Vorhandensein des Schlüssels, tatsächlichen Typ und erwarteten Rückfallwert zu protokollieren. Exportieren Sie nicht einfach das gesamte Einstellungs-Dictionary: Es kann Benutzerkennungen oder interne Umgebungsinformationen enthalten.

Führen Sie die Tests zweimal hintereinander aus, aktivieren Sie anschließend die parallele Ausführung und prüfen Sie, ob die Ergebnisse übereinstimmen. Scheitert nur der zweite Lauf, suchen Sie zuerst nach fest benannten Suites und nicht bereinigten Domänen. Treten Fehler nur bei paralleler Ausführung auf, prüfen Sie gemeinsam genutzte Singletons. Scheitert der Test erst nach einem Neustart, prüfen Sie, ob die Initialisierung Benutzerwerte überschreibt.

Das abschließende Prüfkriterium lautet nicht „einmal die richtige Zeichenfolge gelesen“. Für Vorgabewerte, das Überschreiben durch Benutzerwerte, den Rückfall nach dem Löschen und den Prozessneustart muss jeweils ein eigener Nachweis vorliegen. Erst wenn diese Grenzen klar festgelegt sind, liefern Einstellungstests auf neuen Rechnern und dauerhaft genutzten Knoten dieselben Ergebnisse.

Häufig gestellte Fragen

Schreibt register(defaults:) Vorgaben auf die Festplatte?

Nein. Die Werte liegen in der Registrierungsdomäne des aktuellen Prozesses. Beim nächsten Start muss die Anwendung sie erneut registrieren.

Beweist ein neues UserDefaults-Objekt die Persistenz?

Nein. Objekte im selben Prozess können Zustand teilen. Beenden Sie den schreibenden Prozess regulär und lesen Sie die Werte in einem neuen Prozess.

Schritte auf einem exklusiven Cloud-Mac ausführen

Mieten Sie einen kompletten physischen Mac mini statt einer virtuellen Maschine. Wählen Sie einen Zeitraum von Tag, Woche, Monat oder Quartal. Prüfen Sie zuerst Modell und Standort und bestätigen Sie dann Ihre Bestellung. Maßgeblich ist der Verfügbarkeitsstatus, den die Konsole in Echtzeit anzeigt.