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:
- Datei → Neu → Target …
- Wähle Unit Testing Bundle.
- Bei Testing System wähle Swift Testing.
- Bei Target to be Tested wähle deine App.
Es entsteht ein neuer Ordner, etwa AusgabenTests, mit einer Datei:
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 Testingbindet Swift Testing ein.@testable import Ausgabenbindet deine App ein, und zwar so, dass Tests auch aufinternalTypen 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:
// In der App
func share(of part: Double, in total: Double) -> Double {
guard total > 0 else { return 0 }
return part / total
}Der Test:
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)
}@Testmacht 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:
Expectation failed: (share(of: 25, in: 100) → 25.0) == 0.25Du 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:
@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:
@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")))
}#requireprüft wie#expect, bricht den Test aber ab, wenn es scheitert.- Bei Optionals entpackt es zusätzlich den Wert: Aus
URL?wirdURL. Ist der Wertnil, endet der Test mit einer klaren Meldung. - Weil es den Test abbrechen kann, braucht es
try, und der Test wird mitthrowsmarkiert.
#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:
@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:
@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:
@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:
@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:
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. IstBudgetErrorEquatable, 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:
@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.
@MainActor
@Suite struct MealListModelTests { ... }Tests steuern: Traits
Mit Traits (Eigenschaften) passt du an, wie ein Test läuft:
@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:
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
Ü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!
// 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.
Ü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.
Ü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 Appgibt Zugriff auf deinen Code. @Testmarkiert einen Test, gern mit Anzeigenamen. Ausführen mit ⌘ U oder der Raute.#expectprüft und zeigt die Werte bei Fehlschlag.#requirebricht ab und entpackt Optionals.@Suitegruppiert Tests. Jeder Test bekommt eine eigene Instanz, Vorbereitung iminit.arguments:prüft viele Fälle mit einem Test.#expect(throws:)testet Fehler,async-Tests testen asynchronen Code,@MainActorfür Main-Actor-Typen.- Traits wie
.disabled,.timeLimit,.tagssteuern Tests.
Spickzettel
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