iOS-Hintergrundaufgaben auf einem Cloud-Mac zuverlässig testen

iOS-Hintergrundaufgaben auf einem Cloud-Mac zuverlässig testen

Eine Hintergrundaktualisierung funktioniert auf dem Entwicklungsrechner gelegentlich, scheint nach der Einbindung in CI jedoch häufig „gar nicht ausgeführt“ zu werden. Das Problem liegt meist nicht im Aufgabencode, sondern darin, dass der Test den vom System bestimmten Ausführungszeitpunkt als deterministische Bedingung behandelt. Wann iOS einen Prozess aufweckt, entscheidet das System. Auch ein Cloud-Mac kann daraus keinen festen Zeitgeber machen. Zuverlässig wird der Test erst, wenn drei Bereiche getrennt werden: die statische Registrierungskonfiguration, die dünne Scheduler-Adapter-Schicht und die Geschäftslogik, die Synchronisierung oder Bereinigung tatsächlich ausführt.

Zuerst überprüfbare Grenzen definieren

Eine vollständige Kette für Hintergrundaufgaben umfasst mindestens Registrierung, Übermittlung der Anfrage, System-Callback, Ausführung, Abbruch bei Ablauf und Rückmeldung des Ergebnisses. Die kontinuierliche Integration sollte die beiden kontrollierbaren Enden zuverlässig prüfen, statt darauf zu warten, dass das System die Aufgabe „zufällig“ auslöst.

Ebene Automatisierte Prüfung Nicht als Assertion geeignet
Konfiguration Bezeichner, Capability-Deklarationen, Konfiguration des Targets Zeitpunkt des Aufweckens durch das System
Adapter Erfolgreiche Registrierung, Weiterleitung des Callbacks, Abschlussstatus Scheduling-Priorität
Aufgabe Ausgabe, Fehler, Abbruch, wiederholte Ausführung Tatsächlicher Akkustand und Nutzungsverhalten

Das Ziel eines Regressionstests besteht nicht darin, nachzuweisen, dass eine Aufgabe zu einer bestimmten Minute startet. Er soll belegen, dass die App eine vom System zugestellte Aufgabe korrekt abschließen, abbrechen oder sicher erneut versuchen kann.

Zunächst wird ein fester Aufgabenbezeichner wie com.example.app.refresh festgelegt. Er muss sowohl im Registrierungscode als auch unter BGTaskSchedulerPermittedIdentifiers vorkommen. Wenn unterschiedliche Build-Konfigurationen verschiedene Info.plist-Dateien erzeugen, muss das Build-Artefakt geprüft werden und nicht nur die Quelldatei im Repository.

Den Scheduler auf einen dünnen Adapter reduzieren

Netzwerk-, Datenbank- und Cache-Logik sollte nicht direkt im Callback von BGAppRefreshTask stehen. Definieren Sie zunächst eine austauschbare Ausführungseinheit:

protocol RefreshJob {
    func run() async throws
    func cancel()
}

final class BackgroundRefreshAdapter {
    private let job: RefreshJob

    init(job: RefreshJob) {
        self.job = job
    }

    func handle(task: BGAppRefreshTask) {
        task.expirationHandler = { [job] in
            job.cancel()
        }

        Task {
            do {
                try await job.run()
                task.setTaskCompleted(success: true)
            } catch {
                task.setTaskCompleted(success: false)
            }
        }
    }
}

In die eigentliche Aufgabe werden anschließend Netzwerkclient, Speicherschnittstelle und Uhr injiziert. Dadurch müssen Unit-Tests keinen BGAppRefreshTask nachbilden, sondern lediglich prüfen, welche Ausgabe RefreshJob für eine gegebene Eingabe erzeugt. Der Adapter bleibt klein und ist ausschließlich dafür verantwortlich, die Ausführung weiterzuleiten, den Ablauf zu behandeln und den Abschlussstatus zu melden.

Den Abbruch bis in die unterste Ebene weitergeben

Eine boolesche Variable zu setzen reicht normalerweise nicht aus. Zwischen Download, Verarbeitung und blockweisem Schreiben sollte jeweils der Abbruchstatus geprüft werden. Bei Verwendung von Swift Concurrency kann an den Phasengrenzen Task.checkCancellation() aufgerufen werden. Datenbankschreibvorgänge sollten kurze Transaktionen verwenden, damit nach Ablauf der Aufgabe keine lange Transaktion offen bleibt und kein unvollständiger Datenbestand zurückbleibt.

Zuerst eine statische Konfigurationsprüfung einrichten

Die häufigsten Ursachen für fehlgeschlagene Hintergrundaufgaben sind falsch geschriebene Bezeichner, nicht in die Target-Konfiguration übernommene Capabilities oder Tests, die die falsche plist-Datei lesen. Prüfen Sie das App-Bundle direkt nach dem Build:

set -euo pipefail

APP_PATH="$BUILT_PRODUCTS_DIR/$WRAPPER_NAME"
PLIST="$APP_PATH/Info.plist"
TASK_ID="com.example.app.refresh"

plutil -extract BGTaskSchedulerPermittedIdentifiers raw "$PLIST" |
  grep -Fx "$TASK_ID"

plutil -extract UIBackgroundModes raw "$PLIST" |
  grep -F "fetch"

Das Skript sollte als eigene Build-Phase mit ausdrücklich angegebenen Eingabedateien ausgeführt werden, damit es nicht bei jedem Build bedingungslos läuft. Bei Verarbeitungsaufgaben muss außerdem der zugehörige Hintergrundmodus geprüft werden. Dass zwei Aufgaben über dasselbe Framework registriert werden, bedeutet nicht, dass ihre Deklarationen identisch sein dürfen.

Auch der Registrierungscode sollte ein beobachtbares Ergebnis liefern. Schreiben Sie beim Start das Registrierungsergebnis jedes Bezeichners in ein internes Diagnoseprotokoll der App. Der Test liest diese Einträge und prüft, ob alle Registrierungen erfolgreich waren. Das Protokoll sollte nur Aufgabenbezeichner, Phase und Fehlertyp enthalten, jedoch weder Zugriffstoken noch vollständige Anfrageinhalte.

Erfolg, Ablauf und wiederholte Ausführung abdecken

Für die Geschäftsaufgabe sind mindestens vier Testgruppen erforderlich. Die erste verwendet eine feste Antwort und prüft, ob Cursor, Cache und Aktualisierungszeitpunkt nach erfolgreicher Ausführung atomar übernommen werden. Die zweite injiziert während des Downloads oder Schreibens einen Fehler und stellt sicher, dass die alten Daten weiterhin lesbar sind. Die dritte löst einen Abbruch aus und prüft, ob temporäre Dateien bereinigt und keine Erfolgsmarkierung aktualisiert wird. Die vierte führt dieselbe Eingabe zweimal hintereinander aus und stellt sicher, dass keine doppelten Datensätze entstehen.

func testRepeatedRunIsIdempotent() async throws {
    let store = InMemoryStore()
    let client = StubClient(items: [.init(id: "42")])
    let job = SyncRefreshJob(client: client, store: store)

    try await job.run()
    try await job.run()

    XCTAssertEqual(store.items.map(\.id), ["42"])
    XCTAssertEqual(store.commitCount, 2)
}

Idempotenz bedeutet nicht, dass der zweite Durchlauf nichts tun darf. Entscheidend ist, dass der Endzustand identisch bleibt und eine wiederholte Übernahme keine doppelten Objekte erzeugt. Wenn die Aufgabe Dateien hochlädt, kann der bereits übernommene Status über einen stabilen fachlichen Schlüssel erfasst werden. Arbeitet die Aufgabe mit einem Seitennavigations-Cursor, sollte außerdem der Unterbrechungspunkt getestet werden, an dem die Daten bereits geschrieben wurden, der Cursor aber noch nicht aktualisiert ist.

Keine Wartezeiten mit sleep in Tests verwenden

Vermeiden Sie feste sleep-Aufrufe, mit denen die Abschlusszeit asynchroner Vorgänge lediglich geschätzt wird. Abstrahieren Sie Uhr und Backoff-Strategie als Protokolle und verwenden Sie in Tests eine manuell fortschaltbare Uhr. Polling benötigt eine klare Abbruchbedingung. Eine Fehlermeldung sollte außerdem die aktuelle Phase ausgeben, statt lediglich eine Zeitüberschreitung zu melden.

Überprüfbare Nachweise auf dem Cloud-Mac archivieren

Listen Sie zunächst die auf dem aktuellen Knoten verfügbaren Simulatoren auf und wählen Sie anschließend eine für das Projekt installierte Runtime:

xcrun simctl list devices available

xcodebuild test \
  -scheme BackgroundTasks \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \
  -resultBundlePath artifacts/BackgroundTasks.xcresult

Der Gerätename sollte über eine Pipeline-Variable bereitgestellt werden, damit das Skript nach einem Xcode-Update nicht an eine nicht mehr vorhandene Runtime gebunden ist. Bewahren Sie bei einem Fehler xcresult, Testprotokolle, die Diagnosedaten der App und die verwendete Commit-ID auf. Es reicht nicht, nur die letzten Dutzend Zeilen der Konsolenausgabe hochzuladen.

Während der Fehleranalyse kann der System-Callback mit den Debugging-Funktionen von Xcode für Hintergrundaufgaben manuell ausgelöst werden. Dieser Einstiegspunkt dient jedoch nur dazu, die Verdrahtung der Adapter-Schicht zu prüfen, und darf keine Abhängigkeit des Release-Builds werden. Die abschließende Abnahme erfolgt auf zwei Ebenen: CI prüft Konfiguration und Aufgabenlogik deterministisch, während kontrollierte Geräte den tatsächlichen Lebenszyklus nach der Zustellung durch das System prüfen. Die Ergebnisse beider Ebenen werden getrennt erfasst, damit sich bei einem Fehler unterscheiden lässt, ob eine Konfigurationsregression, ein Fehler in der Geschäftslogik oder eine falsche Erwartung an den Ausführungszeitpunkt des Systems vorliegt.

Häufig gestellte Fragen

Kann der iOS-Simulator einen pünktlichen Hintergrundstart beweisen?

Nein. Registrierung, Arbeitslogik, Abbruch und Idempotenz sind prüfbar, der tatsächliche Aufweckzeitpunkt des Systems ist jedoch keine deterministische CI-Bedingung.

Welche Architektur macht BGTask-Tests stabil?

Ein dünner BGTaskScheduler-Adapter ruft eine separate asynchrone Arbeitseinheit auf. Tests injizieren Uhr, Eingaben, Fehler und Abbruchsignal direkt.

DEDIZIERTER BUILD-SLOT

Wählen Sie einen exklusiven Cloud-Mac für kontinuierliche Build-Aufgaben

Prüfen Sie M4, Arbeitsspeicher, Speicherplatz, Standort und Abrechnungszeitraum und binden Sie die feste Build-Umgebung anschließend in Ihre bestehende Pipeline ein.

Konfiguration auswählen und bestellen