Lektion 4.10 · Stufe 4 – Profi

Swift Testing

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du mit Swift Testing automatisch prüfen, ob dein Code stimmt: ein Test-Target anlegen, Tests mit @Test schreiben, Erwartungen mit #expect und #require formulieren, Tests mit @Suite gruppieren, mit Argumenten viele Fälle auf einmal prüfen und asynchronen sowie fehlerwerfenden Code testen.

Das Problem: „Hat das vorher nicht funktioniert?“

Du baust eine neue Funktion in den Ausgaben-Tracker ein. Danach öffnest du die App, tippst dich durch, alles scheint zu funktionieren. Zwei Wochen später merkst du: Die Monatssumme stimmt seit deiner Änderung nicht mehr, wenn eine Ausgabe genau um Mitternacht erfasst wurde. Wann ist das kaputtgegangen? Was genau hast du geändert?

Je größer eine App wird, desto weniger kannst du alles von Hand prüfen. Jede Änderung kann etwas anderes kaputt machen, und du merkst es oft erst, wenn es ein Nutzer meldet.

Automatische Tests lösen das: kleine Programme, die deinen Code aufrufen und prüfen, ob das Ergebnis stimmt. In Sekunden, so oft du willst. Ändert sich etwas, das nicht hätte ändern sollen, schlägt ein Test fehl, und du weißt sofort, wo.

Apples modernes Werkzeug dafür heißt Swift Testing.

Ein Bild dafür

Tests sind wie ein Sicherheitsnetz beim Trapez. Ohne Netz traust du dich nicht, etwas Neues zu probieren, weil jeder Fehler teuer ist. Mit Netz kannst du mutig umbauen und verbessern. Fällt etwas, fängt es dich auf.

Das Test-Target

Tests leben in einem eigenen Target, einem eigenen Baustein des Projekts neben der App.

Für ein neues Projekt: Setz beim Anlegen das Häkchen bei Include Tests und wähle als Testing System Swift Testing.

Für ein bestehendes Projekt:

  1. Datei → Neu → Target …
  2. Wähle Unit Testing Bundle.
  3. Bei Testing System wähle Swift Testing.
  4. Bei Target to be Tested wähle deine App.

Es entsteht ein neuer Ordner, etwa AusgabenTests, mit einer Datei:

Swift
import Testing
@testable import Ausgaben

struct AusgabenTests {
    @Test func example() async throws {
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
    }
}
  • import Testing bindet Swift Testing ein.
  • @testable import Ausgaben bindet deine App ein, und zwar so, dass Tests auch auf internal Typen und Funktionen zugreifen können, nicht nur auf öffentliche.

Der erste Test

Nehmen wir eine kleine Funktion aus der App, die den Anteil einer Kategorie berechnet:

Swift
// In der App
func share(of part: Double, in total: Double) -> Double {
    guard total > 0 else { return 0 }
    return part / total
}

Der Test:

Swift
import Testing
@testable import Ausgaben

@Test func shareIsPartDividedByTotal() {
    #expect(share(of: 25, in: 100) == 0.25)
}

@Test func shareOfEmptyTotalIsZero() {
    #expect(share(of: 10, in: 0) == 0)
}
  • @Test macht eine Funktion zu einem Test. Sie darf auch außerhalb eines Structs stehen.
  • #expect(...) prüft eine Bedingung. Ist sie falsch, schlägt der Test fehl.

Tests ausführen

  • ⌘ U führt alle Tests aus.
  • Neben jedem Test erscheint links im Editor eine Raute. Ein Klick darauf führt nur diesen Test aus.
  • Der Test Navigator (⌘ 6) zeigt alle Tests mit ihrem Ergebnis.

Erwartetes Ergebnis

Beide Rauten werden grün mit Häkchen. Ändere part / total testweise in part / total * 100, drück ⌘ U, und der erste Test wird rot.

Was ein fehlgeschlagener Test zeigt

Das Besondere an #expect: Es zeigt nicht nur „falsch“, sondern die Werte:

Text
Expectation failed: (share(of: 25, in: 100) → 25.0) == 0.25

Du siehst sofort, was herauskam (25.0) und was erwartet war (0.25). Das macht die Fehlersuche viel einfacher.

Gute Testnamen

Ein Test sollte beim Lesen sagen, was er prüft. Swift Testing erlaubt dafür Anzeigenamen:

Swift
@Test("Anteil ist Teil geteilt durch Gesamt")
func shareCalculation() {
    #expect(share(of: 25, in: 100) == 0.25)
}

Im Test Navigator steht dann der deutsche Satz. Bei einem Fehlschlag weißt du sofort, welche Regel verletzt wurde.

#require: Abbrechen, wenn es nicht weitergeht

Manchmal hängt der Rest eines Tests von einer Bedingung ab. Zum Beispiel beim Parsen von Deep Links aus Lektion 3.22:

Swift
@Test("Rezept-Link wird erkannt")
func recipeLink() throws {
    let url = try #require(URL(string: "rezepte://recipe/52772"))
    let link = try #require(DeepLink(url: url))

    #expect(link == .route(.recipes, .recipe(id: "52772")))
}
  • #require prüft wie #expect, bricht den Test aber ab, wenn es scheitert.
  • Bei Optionals entpackt es zusätzlich den Wert: Aus URL? wird URL. Ist der Wert nil, endet der Test mit einer klaren Meldung.
  • Weil es den Test abbrechen kann, braucht es try, und der Test wird mit throws markiert.

#expect oder #require?

#expect für die eigentlichen Prüfungen: Der Test läuft weiter, und du siehst alle Fehler auf einmal. #require für Voraussetzungen, ohne die der Rest keinen Sinn ergibt, vor allem zum Entpacken von Optionals.

Damit == im Beispiel funktioniert, muss DeepLink Equatable sein. Das ist ein typischer Effekt von Tests: Sie machen kleine Verbesserungen am Code nötig, die ihn insgesamt sauberer machen.

Tests gruppieren: @Suite

Tests, die zusammengehören, fasst du in einem Struct zusammen:

Swift
@Suite("Deep Links")
struct DeepLinkTests {
    @Test("Rezept-Link") func recipe() throws { ... }
    @Test("Kategorie-Link") func category() throws { ... }
    @Test("Unbekannter Link ergibt nil") func unknown() throws { ... }
}

Ein Struct mit Tests ist automatisch eine Suite. Mit @Suite("...") gibst du ihr einen Anzeigenamen.

Gemeinsame Vorbereitung

Brauchen alle Tests einer Suite dieselben Daten, bereitest du sie im Initializer vor:

Swift
@Suite("Statistik")
struct StatisticsTests {
    let expenses: [ExpenseValue]

    init() {
        expenses = [
            ExpenseValue(category: "Essen", amount: 30),
            ExpenseValue(category: "Essen", amount: 20),
            ExpenseValue(category: "Verkehr", amount: 50)
        ]
    }

    @Test("Summe aller Ausgaben")
    func total() {
        #expect(Statistics.total(of: expenses) == 100)
    }

    @Test("Summen pro Kategorie")
    func perCategory() {
        let sums = Statistics.sumsByCategory(expenses)
        #expect(sums["Essen"] == 50)
        #expect(sums["Verkehr"] == 50)
    }
}

Wichtig: Swift Testing erzeugt für jeden Test eine neue Instanz des Structs. Jeder Test bekommt also frische Daten und kann die anderen nicht beeinflussen. Tests laufen standardmäßig sogar gleichzeitig, was sie schnell macht.

Viele Fälle mit einem Test: Argumente

Oft willst du dieselbe Regel mit vielen Eingaben prüfen. Statt zehn fast gleiche Tests zu schreiben, gibst du Argumente an:

Swift
@Test("Unbekannte oder kaputte Links werden ignoriert", arguments: [
    "rezepte://",
    "rezepte://unbekannt",
    "rezepte://recipe",
    "https://example.com/recipe/1",
    "anderes-schema://recipe/1"
])
func invalidLinks(_ string: String) throws {
    let url = try #require(URL(string: string))
    #expect(DeepLink(url: url) == nil)
}

Der Test läuft für jedes Argument einmal, und jeder Durchlauf erscheint im Test Navigator einzeln. Schlägt einer fehl, siehst du genau, bei welcher Eingabe.

Für Paare aus Eingabe und erwartetem Ergebnis übergibst du Tupel:

Swift
@Test("Anteile", arguments: [
    (25.0, 100.0, 0.25),
    (50.0, 200.0, 0.25),
    (0.0, 100.0, 0.0),
    (10.0, 0.0, 0.0)
])
func shares(part: Double, total: Double, expected: Double) {
    #expect(share(of: part, in: total) == expected)
}

Fehler testen

Soll eine Funktion bei falschen Eingaben einen Fehler werfen (Lektion 1.17), prüfst du das so:

Swift
enum BudgetError: Error {
    case negativeAmount
}

func validate(amount: Double) throws(BudgetError) {
    if amount < 0 { throw .negativeAmount }
}

@Test("Negative Beträge sind nicht erlaubt")
func negativeAmount() {
    #expect(throws: BudgetError.negativeAmount) {
        try validate(amount: -5)
    }
}

@Test("Positive Beträge sind erlaubt")
func positiveAmount() {
    #expect(throws: Never.self) {
        try validate(amount: 5)
    }
}
  • #expect(throws: Fehler) erwartet genau diesen Fehler. Ist BudgetError Equatable, kannst du den konkreten Fall angeben.
  • #expect(throws: Never.self) erwartet, dass kein Fehler geworfen wird.

Asynchronen Code testen

Tests dürfen async sein. Du rufst asynchrone Funktionen einfach mit await auf:

Swift
@Test("Statistik im Hintergrund berechnen")
func backgroundStatistics() async {
    let result = await StatisticsCalculator().sums(for: sampleEntries)
    #expect(result.count == 3)
}

Main Actor in Tests

In App-Projekten mit Swift 6 sind viele deiner Typen standardmäßig auf dem Main Actor isoliert (Lektion 3.8). Testest du solche Typen, etwa ein @Observable-Modell, markiere die Suite oder den Test mit @MainActor. Sonst meldet der Compiler, dass du von einem anderen Kontext darauf zugreifst.

Swift
@MainActor
@Suite struct MealListModelTests { ... }

Tests steuern: Traits

Mit Traits (Eigenschaften) passt du an, wie ein Test läuft:

Swift
@Test("Lädt echte Daten", .disabled("Server ist gerade in Wartung"))
func liveData() async throws { ... }

@Test(.timeLimit(.minutes(1)))
func slowCalculation() async { ... }

@Test(.bug("https://github.com/dmblx/app/issues/42", "Mitternacht wird falsch zugeordnet"))
func midnightExpense() { ... }
  • .disabled("Grund"): Der Test wird übersprungen, mit Begründung.
  • .timeLimit: Der Test schlägt fehl, wenn er länger dauert.
  • .bug: verknüpft den Test mit einem Fehlerbericht.

Mit Tags kannst du Tests quer über Suites hinweg markieren und gefiltert ausführen, etwa alle Tests zum Thema Netzwerk:

Swift
extension Tag {
    @Tag static var networking: Self
}

@Test(.tags(.networking)) func decodeMeal() throws { ... }

Was testen?

Nicht jeder Code ist gleich gut zu testen und gleich wichtig. Konzentrier dich auf:

  • Logik mit Regeln: Berechnungen, Umrechnungen, Validierungen, Parsing. Hier stecken die meisten Fehler.
  • Randfälle: leere Listen, null, negative Werte, Mitternacht, Monatswechsel, Schaltjahre.
  • Fehler, die schon einmal aufgetreten sind: Schreib für jeden behobenen Fehler einen Test. Dann kommt er nie unbemerkt zurück.

Weniger lohnend sind Tests, die nur prüfen, dass SwiftUI eine View anzeigt. Wie du Code so aufteilst, dass die wichtige Logik gut testbar ist, zeigt Lektion 4.11.

Typische Fehler

Cannot find 'share' in scope

@testable import DeinAppName fehlt oder der Name stimmt nicht. Der Name ist der Name des App-Targets, nicht unbedingt der des Projekts.

Test vergleicht Kommazahlen und schlägt knapp fehl

0.1 + 0.2 == 0.3 ist mit Double falsch (Projekt E, Teil 1). Vergleiche mit Toleranz: #expect(abs(result - 0.3) < 0.0001), oder verwende Decimal.

Tests beeinflussen sich gegenseitig

Mehrere Tests verändern gemeinsamen Zustand, etwa ein Singleton oder UserDefaults.standard. Weil Tests gleichzeitig laufen, ist das Ergebnis zufällig. Gib jedem Test eigene Daten, etwa über den Initializer der Suite.

Fehler wegen Main Actor

Der getestete Typ ist auf dem Main Actor isoliert. Markiere die Suite mit @MainActor.

FAQ

Anfänger

Was bedeutet das überhaupt?

Was ist mit XCTest? XCTest ist das ältere Test-Framework mit XCTestCase und XCTAssertEqual. Du wirst es in bestehenden Projekten noch oft sehen, und für UI-Tests (Lektion 4.12) und Leistungstests wird es weiterhin verwendet. Für neue Unit-Tests ist Swift Testing die bessere Wahl. Beide können im selben Projekt nebeneinander existieren.

Wie viele Tests brauche ich? Es gibt keine feste Zahl. Fang mit der wichtigsten Logik an, etwa den Berechnungen der Auswertung, und ergänze Tests, wenn du Fehler findest. Ein paar gute Tests sind viel mehr wert als keine.

Fortgeschritten

Warum verhält es sich so?

Wie teste ich Code, der das aktuelle Datum verwendet? Gar nicht direkt, denn Date.now ändert sich bei jedem Lauf. Gib das Datum als Parameter in die Funktion, etwa func monthTotal(of expenses: [Expense], now: Date = .now). Im Test übergibst du ein festes Datum. Das ist ein Beispiel für testbare Architektur (Lektion 4.11).

Wie teste ich Callbacks, die irgendwann aufgerufen werden? Mit confirmation: await confirmation("Speichern wurde aufgerufen") { confirmed in store.onSave = { confirmed() }; store.save() }. Der Test schlägt fehl, wenn confirmed() nicht aufgerufen wurde. Mit expectedCount: kannst du auch prüfen, wie oft.

Profi

Was passiert unter der Haube?

Wie laufen Tests parallel, und wann muss ich das abschalten? Swift Testing führt Tests standardmäßig gleichzeitig aus, auch über Suites hinweg. Teilen sich Tests eine Ressource, die nicht parallel nutzbar ist, etwa eine Datei, markiere die Suite mit .serialized. Besser ist meist, die gemeinsame Ressource zu beseitigen.

Wie funktioniert #expect intern? Es ist ein Makro. Beim Kompilieren zerlegt es den Ausdruck in seine Teile, sodass bei einem Fehlschlag jeder Teilwert angezeigt werden kann. Deshalb siehst du (share(of: 25, in: 100) → 25.0) == 0.25 statt nur „falsch“. Normale Funktionen könnten das nicht, weil sie nur das Ergebnis false sehen würden.

Übungen

Anfänger

Übung 1: Erste Tests

Leg in einem deiner Projekte ein Test-Target mit Swift Testing an. Schreib eine Funktion isValidTitle(_:), die true liefert, wenn ein Text nach dem Entfernen von Leerzeichen nicht leer ist. Teste sie mit vier Fällen: normaler Text, leerer Text, nur Leerzeichen, Text mit Leerzeichen am Rand.

Lösung Übung 1Erst selbst probieren!
Swift
// App
func isValidTitle(_ title: String) -> Bool {
    !title.trimmingCharacters(in: .whitespaces).isEmpty
}

// Tests
@Test("Gültige Titel", arguments: ["Kaffee", "  Kaffee  ", "a"])
func validTitles(_ title: String) {
    #expect(isValidTitle(title))
}

@Test("Ungültige Titel", arguments: ["", " ", "     "])
func invalidTitles(_ title: String) {
    #expect(!isValidTitle(title))
}

Zwei Tests mit Argumenten decken alle Fälle ab, und jeder Fall erscheint einzeln im Test Navigator.

Fortgeschritten

Übung 2: Deep Links testen

Schreib eine Suite DeepLinkTests für den DeepLink-Typ aus Lektion 3.22. Teste alle gültigen Links mit erwarteten Ergebnissen als Argument-Paare und mindestens fünf ungültige Links. Mach DeepLink dafür Equatable.

Profi

Übung 3: Der Mitternachts-Fehler

Schreib eine Funktion monthTotal(of: [(date: Date, amount: Double)], in month: Date, calendar: Calendar) -> Double. Teste sie mit festen Daten: Ausgaben am 31. um 23:59 Uhr, am 1. um 0:00 Uhr und am 1. um 0:01 Uhr. Verwende einen Calendar mit fester Zeitzone (calendar.timeZone = TimeZone(identifier: "Europe/Vienna")!), damit der Test überall gleich läuft.

Zusammenfassung

  • Automatische Tests prüfen deinen Code in Sekunden und fangen Fehler ab, bevor Nutzer sie finden.
  • Tests leben in einem eigenen Test-Target. @testable import App gibt Zugriff auf deinen Code.
  • @Test markiert einen Test, gern mit Anzeigenamen. Ausführen mit ⌘ U oder der Raute.
  • #expect prüft und zeigt die Werte bei Fehlschlag. #require bricht ab und entpackt Optionals.
  • @Suite gruppiert Tests. Jeder Test bekommt eine eigene Instanz, Vorbereitung im init.
  • arguments: prüft viele Fälle mit einem Test.
  • #expect(throws:) testet Fehler, async-Tests testen asynchronen Code, @MainActor für Main-Actor-Typen.
  • Traits wie .disabled, .timeLimit, .tags steuern Tests.

Spickzettel

Swift
import Testing
@testable import MeineApp

@Suite("Rechner")
struct CalculatorTests {
    let data: [Int]
    init() { data = [1, 2, 3] }

    @Test("Summe") func sum() {
        #expect(data.reduce(0, +) == 6)
    }

    @Test func parse() throws {
        let url = try #require(URL(string: "app://x/1"))
        #expect(Link(url: url) != nil)
    }

    @Test(arguments: [(2, 4), (3, 9)])
    func square(input: Int, expected: Int) {
        #expect(input * input == expected)
    }

    @Test func error() {
        #expect(throws: MyError.invalid) { try validate(-1) }
    }

    @MainActor @Test func model() async { ... }
}
// ⌘ U  ·  Raute neben dem Test

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.