Lektion 4.11 · Stufe 4 – Profi

Testbare Architektur

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du Code so aufbauen, dass er sich leicht testen lässt: Logik aus Views in reine Funktionen und Modelle ziehen, Abhängigkeiten wie Datum, Netzwerk und Speicher über Parameter und Protokolle austauschbar machen, ViewModels mit Testdoubles prüfen und SwiftData-Logik mit einem Container im Arbeitsspeicher testen.

Das Problem: Code, der sich nicht testen lässt

In Lektion 4.10 hast du gelernt, Tests zu schreiben. Jetzt willst du die Monatssumme aus Projekt E testen. Sie steht so im Code:

Swift
struct ExpenseList: View {
    @Query private var expenses: [Expense]

    private var monthTotal: Double {
        expenses
            .filter { Calendar.current.isDate($0.date, equalTo: .now, toGranularity: .month) }
            .reduce(0) { $0 + $1.amount }
    }

    var body: some View { ... }
}

Wie testest du das?

  • Die Berechnung ist eine private Eigenschaft einer View. Von außen kommst du nicht heran.
  • Sie liest @Query, braucht also eine Datenbank und eine laufende View.
  • Sie verwendet .now: Heute ergibt sie etwas anderes als nächsten Monat. Ein Test, der heute grün ist, kann morgen rot sein.
  • Sie verwendet Calendar.current: Auf einem Rechner in Tokio fällt Mitternacht woanders hin als in Wien.

Der Code funktioniert, aber er ist nicht testbar. Das ist kein Problem der Tests, sondern der Architektur: wie der Code aufgeteilt ist. Diese Lektion zeigt die Handgriffe, mit denen fast jeder Code testbar wird. Sie machen ihn nebenbei auch übersichtlicher.

Grundsatz 1: Logik raus aus der View

Eine View sollte beschreiben, wie etwas aussieht. Berechnungen, Regeln und Entscheidungen gehören woandershin, wo sie ohne Bildschirm laufen können.

Reine Funktionen

Der beste Ort für Logik ist eine reine Funktion: Sie bekommt alles, was sie braucht, als Parameter und liefert ein Ergebnis. Sie liest nichts von außen und verändert nichts.

Swift
enum ExpenseMath {
    static func total(
        of expenses: [(date: Date, amount: Double)],
        inMonthOf reference: Date,
        calendar: Calendar
    ) -> Double {
        expenses
            .filter { calendar.isDate($0.date, equalTo: reference, toGranularity: .month) }
            .reduce(0) { $0 + $1.amount }
    }
}

Die View ruft sie nur noch auf:

Swift
private var monthTotal: Double {
    ExpenseMath.total(
        of: expenses.map { ($0.date, $0.amount) },
        inMonthOf: .now,
        calendar: .current
    )
}

Und der Test kann alles festlegen:

Swift
@Test("Ausgabe um 23:59 gehört noch zum alten Monat")
func lastMinuteOfMonth() throws {
    var calendar = Calendar(identifier: .gregorian)
    calendar.timeZone = try #require(TimeZone(identifier: "Europe/Vienna"))

    let lastMinute = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 30, hour: 23, minute: 59)))
    let firstMinute = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 1, hour: 0, minute: 0)))
    let reference = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 15)))

    let total = ExpenseMath.total(
        of: [(lastMinute, 10), (firstMinute, 20)],
        inMonthOf: reference,
        calendar: calendar
    )

    #expect(total == 20)
}

Fester Kalender, feste Zeitzone, feste Daten. Der Test läuft heute, in einem Jahr und auf jedem Rechner der Welt gleich.

Warum Tupel statt Expense?

Die Funktion nimmt einfache Werte statt SwiftData-Modellen. So braucht der Test keine Datenbank. Für größere Datenmengen ist ein kleines eigenes Struct übersichtlicher als ein Tupel, etwa struct ExpenseValue { let date: Date; let amount: Double; let category: String }. Solche einfachen Werttypen sind außerdem Sendable und können im Hintergrund verarbeitet werden (Lektion 4.5).

Woran du nicht testbaren Code erkennst

Achte beim Schreiben auf diese Warnzeichen. Jedes macht Tests schwer:

WarnzeichenProblemLösung
Date.now, Calendar.currentErgebnis hängt von Zeit und Ort abals Parameter übergeben
URLSession.sharedechtes Netzwerk im Testüber Protokoll austauschbar machen
UserDefaults.standardTests teilen sich Dateneigene Instanz übergeben
.randomErgebnis ist zufälligWerte oder Generator übergeben
Logik in body oder privaten View-Eigenschaftennicht erreichbarin Funktion oder Modell auslagern

Das Muster ist immer dasselbe: Was von außen kommt, wird übergeben, statt es sich selbst zu holen.

Grundsatz 2: Abhängigkeiten austauschbar machen

Nicht alles lässt sich in reine Funktionen verwandeln. Ein ViewModel, das Rezepte lädt, muss irgendwann das Netzwerk benutzen. Wie testest du es, ohne bei jedem Testlauf echte Anfragen an TheMealDB zu schicken?

Das Problem

Swift
@Observable
class MealListModel {
    var state: LoadState<[Meal]> = .idle
    private let api = MealAPI()                  // ⚠️ fest verdrahtet

    func load(category: String) async {
        state = .loading
        do {
            state = .loaded(try await api.meals(in: category))
        } catch {
            state = .failed("Laden fehlgeschlagen")
        }
    }
}

Das Modell erzeugt seine API selbst. Ein Test würde echte Netzwerkaufrufe machen: langsam, abhängig vom Server und ohne Möglichkeit, einen Fehler gezielt auszulösen.

Die Lösung: ein Protokoll

Zuerst beschreibst du, was das Modell vom Netzwerk braucht, in einem Protokoll (Lektion 1.15):

Swift
protocol MealService: Sendable {
    func meals(in category: String) async throws -> [Meal]
}

Die echte API erfüllt es:

Swift
extension MealAPI: MealService {}

Das Modell bekommt seinen Dienst von außen:

Swift
@Observable
class MealListModel {
    var state: LoadState<[Meal]> = .idle
    private let service: any MealService

    init(service: any MealService = MealAPI()) {
        self.service = service
    }

    func load(category: String) async {
        state = .loading
        do {
            state = .loaded(try await service.meals(in: category))
        } catch {
            state = .failed("Laden fehlgeschlagen")
        }
    }
}

Der Standardwert = MealAPI() sorgt dafür, dass sich in der App nichts ändert: MealListModel() funktioniert wie vorher.

Testdoubles

Im Test übergibst du einen Ersatz, ein sogenanntes Testdouble, das sich genau so verhält, wie der Test es braucht:

Swift
struct StubMealService: MealService {
    var result: Result<[Meal], Error>

    func meals(in category: String) async throws -> [Meal] {
        try result.get()
    }
}

Result ist ein Enum mit den Fällen .success(Wert) und .failure(Fehler). result.get() gibt den Wert zurück oder wirft den Fehler.

Jetzt kannst du beide Wege testen, ohne Netzwerk, in Millisekunden:

Swift
@MainActor
@Suite("Rezeptliste")
struct MealListModelTests {
    let sample = [Meal(id: "1", name: "Pfannkuchen", thumbnail: nil)]

    @Test("Erfolgreiches Laden zeigt die Rezepte")
    func loadSuccess() async {
        let model = MealListModel(service: StubMealService(result: .success(sample)))
        await model.load(category: "Dessert")
        #expect(model.state == .loaded(sample))
    }

    @Test("Fehler beim Laden zeigt eine Meldung")
    func loadFailure() async {
        let model = MealListModel(service: StubMealService(result: .failure(URLError(.notConnectedToInternet))))
        await model.load(category: "Dessert")
        #expect(model.state == .failed("Laden fehlgeschlagen"))
    }
}

Damit == funktioniert, müssen LoadState und Meal Equatable sein. Bei Enums mit Equatable-Werten reicht dafür : Equatable hinter dem Namen.

Die Familie der Testdoubles

  • Stub: liefert vorbereitete Antworten. Wie oben.
  • Fake: eine einfache, funktionierende Umsetzung, etwa ein Speicher im Arbeitsspeicher statt auf der Festplatte.
  • Spy: merkt sich, wie er aufgerufen wurde, damit der Test prüfen kann: „Wurde save genau einmal aufgerufen?“

Im Alltag sagt man oft einfach „Mock“ für alle drei. Wichtig ist das Prinzip, nicht der Name.

Ein Spy

Manchmal willst du prüfen, ob und wie eine Abhängigkeit aufgerufen wurde:

Swift
final class SpyMealService: MealService, @unchecked Sendable {
    private(set) var requestedCategories: [String] = []

    func meals(in category: String) async throws -> [Meal] {
        requestedCategories.append(category)
        return []
    }
}

@Test("Lädt die gewählte Kategorie")
func requestsCategory() async {
    let spy = SpyMealService()
    let model = MealListModel(service: spy)
    await model.load(category: "Seafood")
    #expect(spy.requestedCategories == ["Seafood"])
}

@unchecked Sendable verspricht dem Compiler, dass der Spy sicher zwischen Threads verwendet werden darf, ohne dass er es prüfen kann. Für einen Test, der nacheinander abläuft, ist das in Ordnung. In App-Code solltest du so ein Versprechen nur mit gutem Grund geben (Lektion 3.8).

Grundsatz 3: SwiftData-Logik testen

Logik, die direkt mit SwiftData arbeitet, testest du mit einem Container im Arbeitsspeicher, so wie der previewContainer aus Lektion 3.14:

Swift
@MainActor
@Suite("Kategorien")
struct CategoryTests {
    let context: ModelContext

    init() throws {
        let container = try ModelContainer(
            for: Expense.self, Category.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: true)
        )
        context = container.mainContext
    }

    @Test("Löschen einer Kategorie behält die Ausgaben")
    func deleteKeepsExpenses() throws {
        let food = Category(name: "Essen", icon: "fork.knife", tint: .orange)
        let lunch = Expense(title: "Mittag", amount: 12, category: food)
        context.insert(lunch)

        context.delete(food)
        try context.save()

        let remaining = try context.fetch(FetchDescriptor<Expense>())
        #expect(remaining.count == 1)
        #expect(remaining.first?.category == nil)
    }
}

Jeder Test bekommt einen eigenen, leeren Container, weil der Initializer für jeden Test neu läuft. Tests können sich nicht gegenseitig stören, und nichts landet auf der Festplatte.

Dieser Test prüft übrigens die Löschregel .nullify aus Projekt E. Ändert jemand sie versehentlich in .cascade, schlägt er sofort fehl, bevor ein Nutzer seine Ausgaben verliert.

Achtung

Der Container muss so lange leben wie der Kontext. Im Beispiel lebt er nur im Initializer, aber mainContext hält ihn fest. Hast du Probleme mit verschwindenden Daten, speichere den Container zusätzlich als Eigenschaft der Suite.

Wie viel Architektur?

Es ist leicht, es zu übertreiben: Protokolle für alles, fünf Schichten für einen Bildschirm, Fabriken für Fabriken. Das macht Code nicht testbarer, sondern nur schwerer zu lesen.

Eine pragmatische Faustregel:

  • Reine Logik (Berechnungen, Regeln, Parsing): immer in Funktionen oder Werttypen auslagern. Kostet fast nichts.
  • Externe Dienste (Netzwerk, Dateien, Systemfunktionen wie Benachrichtigungen): hinter ein kleines Protokoll stellen, sobald du das Modell testen willst.
  • SwiftData: direkt verwenden und mit Containern im Arbeitsspeicher testen. Ein eigenes Protokoll darüber lohnt sich selten.
  • Views: nicht in Unit-Tests prüfen. Was eine View anzeigt, prüfst du mit Previews und, für wichtige Abläufe, mit UI-Tests (Lektion 4.12).

Typische Fehler

Tests schlagen „manchmal“ fehl

Der Code verwendet Date.now, Calendar.current, Zufall oder gemeinsamen Zustand. Mach diese Abhängigkeiten zu Parametern.

Tests sind langsam und brauchen Internet

Echte Netzwerkaufrufe in Unit-Tests. Ersetze den Dienst durch einen Stub.

Logik ist in der View versteckt

Private berechnete Eigenschaften in Views sind nicht testbar. Zieh die Logik in eine Funktion oder ein Modell, das die View nur aufruft.

Zu viele Protokolle

Ein Protokoll für jede Klasse, auch wenn es nur eine Umsetzung gibt und nie ersetzt wird. Führe Protokolle erst ein, wenn du wirklich austauschen willst, meist für Tests externer Dienste.

FAQ

Anfänger

Was bedeutet das überhaupt?

Muss ich meine Apps jetzt komplett umbauen? Nein. Fang mit der Logik an, die am wichtigsten oder am fehleranfälligsten ist, etwa Berechnungen. Zieh sie in eine Funktion und schreib einen Test dafür. Jede kleine Verbesserung zählt.

Ist eine Funktion mit vielen Parametern nicht unübersichtlich? Mit Standardwerten wie calendar: Calendar = .current bleibt der Aufruf in der App kurz, und nur Tests geben die Werte an. Werden es sehr viele Parameter, fasse zusammengehörige in einem Struct zusammen.

Fortgeschritten

Warum verhält es sich so?

any MealService oder Generics? any MealService ist einfach und flexibel. Mit einem Generic, class MealListModel<Service: MealService>, kennt der Compiler den konkreten Typ und kann besser optimieren, aber der Typ wird umständlicher. Für Dienste, die selten aufgerufen werden wie Netzwerk, ist any völlig ausreichend.

Wie teste ich ein @Observable-Modell, das in einer .task lädt? Teste nicht die View, sondern rufe die Methode des Modells direkt mit await auf, wie im Beispiel. Die View verbindet nur .task mit model.load(). Diese Verbindung ist so einfach, dass sie keinen eigenen Test braucht.

Profi

Was passiert unter der Haube?

Wie teste ich zeitabhängige Abläufe wie Debouncing, ohne wirklich zu warten? Mach die Uhr austauschbar. Swift hat dafür das Protokoll Clock. Gibst du dem Modell eine any Clock<Duration> und rufst try await clock.sleep(for:) auf, kannst du im Test eine Uhr übergeben, die sofort zurückkehrt oder die der Test von Hand weiterstellt. In der App verwendest du ContinuousClock().

Was ist der Unterschied zwischen Unit-, Integrations- und UI-Tests? Unit-Tests prüfen eine einzelne Einheit isoliert, mit ersetzten Abhängigkeiten. Schnell und zahlreich. Integrationstests prüfen mehrere echte Teile zusammen, etwa Modell plus SwiftData. UI-Tests bedienen die fertige App wie ein Nutzer. Langsam, aber nah an der Realität. Eine gesunde Mischung hat viele Unit-Tests, einige Integrationstests und wenige UI-Tests für die wichtigsten Abläufe.

Übungen

Anfänger

Übung 1: Logik auslagern

Nimm aus Projekt E die Berechnung der Summen pro Kategorie aus StatsView und zieh sie in eine statische Funktion, die ein Array aus einfachen ExpenseValue-Structs bekommt und ein Dictionary [String: Double] liefert. Schreib zwei Tests dafür, einen davon mit einer leeren Liste.

Fortgeschritten

Übung 2: Fehlerfälle des Modells

Erweitere MealListModel so, dass bei URLError(.notConnectedToInternet) die Meldung „Keine Internetverbindung“ erscheint und bei allen anderen Fehlern „Laden fehlgeschlagen“. Teste beide Fälle mit StubMealService. Nutze einen Test mit Argumenten.

Lösung Übung 2Erst selbst probieren!
Swift
// Im Modell
func load(category: String) async {
    state = .loading
    do {
        state = .loaded(try await service.meals(in: category))
    } catch let error as URLError where error.code == .notConnectedToInternet {
        state = .failed("Keine Internetverbindung")
    } catch {
        state = .failed("Laden fehlgeschlagen")
    }
}

// Test
@MainActor
@Test("Fehlermeldungen", arguments: [
    (URLError(.notConnectedToInternet) as any Error, "Keine Internetverbindung"),
    (URLError(.timedOut) as any Error, "Laden fehlgeschlagen"),
    (CocoaError(.fileReadCorruptFile) as any Error, "Laden fehlgeschlagen")
])
func errorMessages(error: any Error, message: String) async {
    let model = MealListModel(service: StubMealService(result: .failure(error)))
    await model.load(category: "Dessert")
    #expect(model.state == .failed(message))
}

Die Argumente sind Paare aus Fehler und erwarteter Meldung. as any Error ist nötig, damit Swift ein Array mit verschiedenen Fehlertypen bilden kann.

Profi

Übung 3: Debouncing ohne Warten

Gib dem Suchmodell aus Projekt D eine austauschbare any Clock<Duration>. Schreib einen Test, der prüft, dass bei drei schnell aufeinanderfolgenden Suchanfragen nur die letzte beim Dienst ankommt. Verwende einen Spy für den Dienst und eine Uhr, deren sleep sofort zurückkehrt.

Zusammenfassung

  • Testbarkeit ist eine Frage der Architektur: wie der Code aufgeteilt ist.
  • Logik raus aus der View, am besten in reine Funktionen, die alles als Parameter bekommen.
  • Was von außen kommt, wird übergeben: Datum, Kalender, Zufall, Speicher, Dienste. Mit Standardwerten bleibt der Aufruf in der App kurz.
  • Externe Dienste hinter ein kleines Protokoll. Im Test ersetzen Stubs, Fakes und Spies die echten Dienste.
  • SwiftData-Logik mit einem Container im Arbeitsspeicher testen, für jeden Test frisch.
  • Nicht übertreiben: Protokolle nur dort, wo du wirklich austauschen willst.

Spickzettel

Swift
// reine Funktion mit Standardwerten
static func total(of items: [Item], in month: Date = .now, calendar: Calendar = .current) -> Double

// Dienst hinter Protokoll
protocol MealService: Sendable {
    func meals(in category: String) async throws -> [Meal]
}
extension MealAPI: MealService {}

@Observable class Model {
    private let service: any MealService
    init(service: any MealService = MealAPI()) { self.service = service }
}

// Stub im Test
struct Stub: MealService {
    var result: Result<[Meal], Error>
    func meals(in category: String) async throws -> [Meal] { try result.get() }
}

// SwiftData im Test
let container = try ModelContainer(for: Item.self,
    configurations: ModelConfiguration(isStoredInMemoryOnly: true))

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.