1. Ein Fehler ist Teil des Funktionsvertrags
Wenn Sie throws zum ersten Mal sehen, ist die Versuchung gross, einen Fehler als etwas Plotzliches zu betrachten: „Na ja, dann eben, wenn alles ganz schlimm lauft.“ In echten Programmen — und in unserer Lern-CLI-Anwendung LibraryCLI — sind Fehler meist ein normaler Teil der Validierung: Der Benutzer hat Unsinn eingegeben, die Datei wurde nicht gefunden, der Befehl wurde nicht erkannt — und das ist keine Katastrophe, sondern ein erwarteter Fall.
Daraus folgt ein wichtiger Gedanke: Wenn ein Fehler erwartbar ist, dann sollte er durch einen Test festgehalten werden, genauso streng wie ein erfolgreiches Ergebnis. Andernfalls kann Ihr Code „schonend kaputtgehen“: Er wirft weiterhin irgendeinen Fehler, aber nicht mehr den, den Sie versprochen haben, und das UX-Konzept bricht zusammen.
Um den „Fehlervertrag“ zu prufen, gibt es in XCTest ein zentrales Werkzeug: XCTAssertThrowsError.
2. Das grundlegende XCTAssertThrowsError: Wir prufen die Tatsache des Fehlers
Beginnen wir mit dem Einfachsten: Wir prufen, dass eine Funktion tatsachlich einen Fehler wirft. Das ist nutzlich, wenn Sie die Regel „leerer String ist unzulassig“ festhalten wollen.
Ein Minimalbeispiel: Wir parsen einen nicht leeren String.
Beispiel
import XCTest
enum ParseError: Error {
case empty
}
func parseNonEmpty(_ s: String) throws -> String {
guard !s.isEmpty else { throw ParseError.empty }
return s
}
final class ParseNonEmptyTests: XCTestCase {
func testParseNonEmpty_whenEmpty_throws() {
XCTAssertThrowsError(try parseNonEmpty(""))
}
}
Hier macht der Test genau eine Sache: Er stellt sicher, dass der Aufruf „absturtzt“. Aber beachten Sie: Dieser Test fixiert noch nicht, welcher Fehler genau geworfen wurde. Das heisst, Sie konnten morgen aus Versehen die Implementierung andern und plotzlich einen ganz anderen Fehler werfen (oder sogar fatalError, was noch lustiger ist), und der Test ware trotzdem grun.
Das ist, als wurde man die Sicherheit eines Autos nach dem Prinzip prufen: „Na ja, es bremst wenigstens.“ Ja, schon. Aber man mochte auch wissen, ob es mit dem Pedal bremst und nicht mit der Wand.
3. Warum der Fehler innerhalb von XCTAssertThrowsError den Typ Error hat
Der nachste Schritt ist, den Typ und den Fall des Fehlers zu prufen. Und genau hier kommt die Uberraschung: Im Closure von XCTAssertThrowsError bekommen Sie nicht Ihren ParseError, sondern den abstrakten Typ Error.
Das ist normal: XCTest weiss nicht, welcher Fehler kommt, deshalb gibt es ihn als Protokolltyp weiter. Danach casten Sie im Test zum gewunschten Typ mit as?.
Beispiel
import XCTest
enum ParseError: Error, Equatable {
case empty
}
func parseNonEmpty(_ s: String) throws -> String {
guard !s.isEmpty else { throw ParseError.empty }
return s
}
final class ParseNonEmptyTests: XCTestCase {
func testParseNonEmpty_whenEmpty_throwsParseErrorEmpty() {
XCTAssertThrowsError(try parseNonEmpty("")) { error in
guard let parseError = error as? ParseError else {
return XCTFail("Unexpected error type: \(error)")
}
XCTAssertEqual(parseError, .empty)
}
}
}
Hier gibt es drei wichtige Punkte.
Erstens wird ParseError als Equatable definiert, damit man Fehler mit XCTAssertEqual vergleichen kann. Bei einfachen enum-Fehlern synthesiert Swift Equatable automatisch, und das vereinfacht Tests erheblich.
Zweitens verwenden wir as? und nicht as!. Das ist grundsatzlich wichtig: as! kann den Test selbst mit einem Laufzeitabsturz zerschlagen, und Sie bekommen eine Meldung auf dem Niveau „Pech gehabt“ statt einer sauberen Diagnose.
Drittens ist XCTFail hier keine Verzierung. Das ist Ihr „Notausgang“ fur den Fall, dass der Fehler einen ganz anderen Typ hat. Ohne XCTFail kann der Test halb leer werden und seltsame Ergebnisse liefern.
4. Den Fall mit XCTAssertEqual prufen
Jetzt festigen wir es: Wenn ein Fehler ein enum ohne associated values ist, ist der beste Weg meist, ihn als Equatable zu definieren und direkt zu vergleichen.
Das ist das lesbarste Szenario. Der Test liest sich wie: „Ich erwarte genau diesen Fehler.“
Beispiel
import XCTest
enum PortError: Error, Equatable {
case invalid
}
func parsePort(_ s: String) throws -> Int {
guard let p = Int(s), (1...65535).contains(p) else { throw PortError.invalid }
return p
}
final class PortTests: XCTestCase {
func testParsePort_whenInvalid_throwsInvalid() {
XCTAssertThrowsError(try parsePort("99999")) { error in
XCTAssertEqual(error as? PortError, .invalid)
}
}
}
Hier konnte man sogar ohne guard auskommen, weil error as? PortError nil zuruckgibt und XCTAssertEqual den Test scheitern lasst. Aber die Diagnose ware schlechter: „XCTAssertEqual failed“. Deshalb ist es meistens doch sinnvoller, guard + XCTFail zu schreiben, wie im vorherigen Abschnitt, besonders im Lehrcode, wo Klarheit wichtig ist.
5. Associated values: Die „Details“ per Pattern Matching prufen
Wenn ein Fehler zusatzliche Daten enthalt — zum Beispiel „welcher genaue String keine Zahl war“ — ist ein Vergleich uber XCTAssertEqual ebenfalls moglich. Oft ist es aber nutzlicher, den associated value explizit zu extrahieren und separat zu prufen. Das macht den Test ausdrucksstarker und hilft genauer zu verstehen, was kaputtgegangen ist.
Stellen wir uns vor, wir parsen ein Alter und wollen, wenn der String keine Zahl ist, den ursprunglichen Input speichern (fur eine Nachricht an den Benutzer).
Beispiel
import XCTest
enum AgeError: Error, Equatable {
case notANumber(input: String)
}
func parseAge(_ s: String) throws -> Int {
guard let value = Int(s) else { throw AgeError.notANumber(input: s) }
return value
}
final class AgeTests: XCTestCase {
func testParseAge_whenInvalid_inspectsAssociatedValue() {
XCTAssertThrowsError(try parseAge("abc")) { error in
guard let e = error as? AgeError else {
return XCTFail("Unexpected error type: \(error)")
}
guard case let .notANumber(input) = e else {
return XCTFail("Expected .notANumber, got \(e)")
}
XCTAssertEqual(input, "abc")
}
}
}
Hier sehen Sie ein Muster, das in unserem Projekt wirklich oft vorkommen wird:
- Mit as? casten wir Error auf Ihren Fehlertyp
- Mit guard case prufen wir, dass es genau der gewunschte Fall ist
- Wir extrahieren associated values und prufen sie mit normalen Assers
Das ist etwas langer als „Fehler einfach vergleichen“, aber dafur halten Sie die Semantik fest: Der Fehler ist nicht einfach „keine Zahl“, sondern „keine Zahl aus dem Input abc“.
6. XCTFail(_:) als zwingender Zweig
Anfanger schreiben Tests oft so, dass sie in einer unerwarteten Situation einfach… gar nichts tun. Zum Beispiel vergessen sie, den Test im else-Zweig oder nach einem switch scheitern zu lassen.
Das ist besonders gefahrlich bei Fehler-Tests: Sie konnen plotzlich aufhoren, die gewunschte Bedingung zu prufen, und der Test wird „standardmassig grun“.
Darum ist die Regel einfach: Wenn Sie einen konkreten Fehlerfall erwarten, mussen alle anderen Varianten zu XCTFail fuhren.
Beispiel
import XCTest
enum LoginError: Error {
case emptyUsername
case emptyPassword
}
func validateLogin(user: String, pass: String) throws {
if user.isEmpty { throw LoginError.emptyUsername }
if pass.isEmpty { throw LoginError.emptyPassword }
}
final class LoginTests: XCTestCase {
func testValidateLogin_whenEmptyPassword_throwsEmptyPassword() {
XCTAssertThrowsError(try validateLogin(user: "neo", pass: "")) { error in
guard let e = error as? LoginError else {
return XCTFail("Unexpected type: \(error)")
}
switch e {
case .emptyPassword:
XCTAssertTrue(true) // formal auch ohne das moglich
default:
XCTFail("Expected .emptyPassword, got \(e)")
}
}
}
}
Ja, XCTAssertTrue(true) sieht wie ein Witz aus — und das ist es auch tatsachlich. In der Regel lasst man einfach einen leeren Zweig case .emptyPassword: break. In Lehrbeispielen ist es aber manchmal nutzlich zu zeigen, wo genau der „richtige“ Zweig liegt.
7. XCTAssertNoThrow: Ein gultiger Fall fallt nicht um
Nach den Fehlern mochten wir wieder in die Welt, in der alles gut ist. Aber auch hier gibt es eine Feinheit: „wirft nicht“ ist eine eigene Aussage, die es wert ist, festgehalten zu werden. Besonders dann, wenn Sie Validierung schreiben und klar sagen wollen: „Diese Eingabe gilt als korrekt.“
XCTAssertNoThrow tut genau das: Es pruft, dass ein Ausdruck keinen Fehler wirft.
Beispiel
import XCTest
enum PortError: Error { case invalid }
func parsePort(_ s: String) throws -> Int {
guard let p = Int(s), (1...65535).contains(p) else { throw PortError.invalid }
return p
}
final class PortNoThrowTests: XCTestCase {
func testParsePort_whenValid_doesNotThrow() {
XCTAssertNoThrow(try parsePort("8080"))
}
}
Aber es gibt einen wichtigen Gedanken: XCTAssertNoThrow ersetzt nicht die Prufung des Ergebnisses. Wenn Ihnen wichtig ist, dass die Funktion den richtigen Wert zuruckgibt, sollten Sie das in zwei Schritte teilen: zuerst „darf keinen Fehler werfen“, dann „muss den erwarteten Wert liefern“.
Beispiel
import XCTest
enum PortError: Error { case invalid }
func parsePort(_ s: String) throws -> Int {
guard let p = Int(s), (1...65535).contains(p) else { throw PortError.invalid }
return p
}
final class PortValueTests: XCTestCase {
func testParsePort_whenValid_returnsParsedValue() {
var port: Int = -1
XCTAssertNoThrow(port = try parsePort("8080"))
XCTAssertEqual(port, 8080)
}
}
Dieses Muster sieht wegen der Zuweisung innerhalb von XCTAssertNoThrow etwas ungewohnlich aus, ist aber vollig brauchbar und betont die Idee: Wir prufen sowohl das Fehlen eines Fehlers als auch die Korrektheit des Ergebnisses.
8. Wir testen den Befehlsparser von LibraryCLI
Jetzt wollen wir alles auf unser Lernprojekt anwenden. Wir haben eine CLI, die Befehle als String entgegennimmt, zum Beispiel:
- add "Clean Code" 2008
- remove 123
- list
Heute ist uns nicht wichtig, den ganzen Parser perfekt zu implementieren, sondern zu zeigen, wie man den Fehlervertrag testet.
Wir erstellen ein minimales Modell fur Befehle und Parse-Fehler.
Beispiel (minimales Modell)
import Foundation
enum Command: Equatable {
case add(title: String, year: Int)
}
enum CommandParseError: Error, Equatable {
case emptyInput
case unknownCommand(String)
case missingTitle
case invalidYear(String)
}
Und wir schreiben einen „Spielzeug“-Parser (stark vereinfacht, ohne Anfuhrungszeichen und ohne komplexe Tokenisierung — im Moment trainieren wir das Testen von Fehlern und nicht das vollstandige CLI-Parsing).
Beispiel (Parser)
import Foundation
struct CommandParser {
func parse(_ line: String) throws -> Command {
let parts = line.split(separator: " ").map(String.init)
guard let cmd = parts.first else { throw CommandParseError.emptyInput }
guard cmd == "add" else { throw CommandParseError.unknownCommand(cmd) }
guard parts.count >= 2 else { throw CommandParseError.missingTitle }
guard let year = Int(parts.last ?? "") else { throw CommandParseError.invalidYear(parts.last ?? "") }
return .add(title: parts[1], year: year)
}
}
Ja, so ein Parser ist nicht perfekt — zum Beispiel ist title hier nur ein einziges Wort. Aber fur die Demonstration von Tests ist das hervorragend.
Jetzt schreiben wir einen Test: Ein leerer String muss zu .emptyInput fuhren.
Beispiel (Test auf einen konkreten Fall)
import XCTest
@testable import Domain // or your module, where CommandParser lives
final class CommandParserErrorTests: XCTestCase {
func testParse_whenEmpty_throwsEmptyInput() {
let parser = CommandParser()
XCTAssertThrowsError(try parser.parse("")) { error in
guard let e = error as? CommandParseError else {
return XCTFail("Unexpected error type: \(error)")
}
XCTAssertEqual(e, .emptyInput)
}
}
}
Jetzt ein Test fur einen unbekannten Befehl: Wir wollen nicht einfach „irgendeinen Fehler“, sondern genau .unknownCommand("rm").
Beispiel (Test auf associated value)
import XCTest
@testable import Domain
final class CommandParserUnknownCommandTests: XCTestCase {
func testParse_whenUnknownCommand_inspectsName() {
let parser = CommandParser()
XCTAssertThrowsError(try parser.parse("rm 10")) { error in
guard let e = error as? CommandParseError else {
return XCTFail("Unexpected error type: \(error)")
}
guard case let .unknownCommand(name) = e else {
return XCTFail("Expected .unknownCommand, got \(e)")
}
XCTAssertEqual(name, "rm")
}
}
}
Und ein Test fur „Jahr ist keine Zahl“: Wichtig ist, dass der Fehler den ursprunglichen Input bewahrt (das ist fur eine schonere Benutzermeldung nutzlich).
Beispiel
import XCTest
@testable import Domain
final class CommandParserInvalidYearTests: XCTestCase {
func testParse_whenYearInvalid_containsOriginalInput() {
let parser = CommandParser()
XCTAssertThrowsError(try parser.parse("add Dune year2000")) { error in
guard let e = error as? CommandParseError else {
return XCTFail("Unexpected error type: \(error)")
}
guard case let .invalidYear(input) = e else {
return XCTFail("Expected .invalidYear, got \(e)")
}
XCTAssertEqual(input, "year2000")
}
}
}
Achten Sie darauf, wie diese Tests einen „technischen Vertrag“ des Parser-Verhaltens bilden. Morgen verbessern Sie die Tokenisierung, fugen Anfuhrungszeichen hinzu, andern die Interna — und die Tests garantieren, dass das externe Verhalten (Fehler und ihre Details) vorhersagbar bleibt.
Warum der Name test… wichtig ist und wie XCTest Tests findet
Sie mussen heute nicht tief in den Test Runner eintauchen, aber ein praktischer Fakt ist gut zu kennen: XCTest ist historisch an Konventionen gebunden, und die Erkennung von Tests basiert oft auf „magischen“ Regeln wie dem Prefix test. Im Swift-/XCTest-Okosystem ist das kein Zufall, sondern eine Folge davon, wie XCTest uber lange Zeit das Problem der Testentdeckung auf verschiedenen Plattformen gelost hat.
Der praktische Schluss ist schlicht: Benennen Sie Testmethoden so, dass XCTest sie sieht, und wundern Sie sich nicht, wenn die Methode checkParser() plotzlich nicht gestartet wurde, obwohl Sie genau darauf gehofft hatten.
9. Typische Fehler bei der Verwendung von XCTAssertThrowsError
Fehler Nr. 1: Man pruft nur die Tatsache „es ist abgesturzt“, aber nicht Typ und Fall des Fehlers.
Ein solcher Test wird schnell zu „Rauschen“: Er schutzt den Vertrag nicht, sondern protokolliert nur, dass irgendwo ein Fehler war. Die Losung ist einfach: Innerhalb von XCTAssertThrowsError casten Sie Error mit as? auf Ihren Typ, danach prufen Sie den Fall und die associated values. Dann fangt der Test tatsachlich Verhaltensregressionen.
Fehler Nr. 2: Man verwendet as! fur das Casten des Fehlers und bekommt einen Crash statt eines normalen Testfehlers.
Wenn as! nicht passt, sturzt der Test wie eine Anwendung ab, und die Diagnose wird schlechter: Sie verlieren die Kontrolle uber die Meldung und manchmal sogar uber die Absturzstelle. Viel besser ist as? + XCTFail("Unexpected error type: ..."): sicherer und verstandlicher.
Fehler Nr. 3: Man vergisst, in unerwarteten Zweigen den Test scheitern zu lassen, wodurch der Test schweigt.
Wenn Sie im Closure if case ... { ... } verwenden und in else nichts tun, kann der Test grun bleiben, obwohl die Pruflogik faktisch nie ausgefuhrt wurde. Disziplin hilft: In jedem unerwarteten Zweig muss XCTFail stehen, und bei Fallprufungen ist guard case ... else { return XCTFail(...) } praktisch.
Fehler Nr. 4: Man vergleicht Fehler uber die String-Darstellung statt uber die Struktur des Fehlers.
Meldungstexte andern sich, werden lokalisiert oder formatiert — und Tests werden fragil. Wenn der Fehler ein enum ist, definieren Sie ihn besser als Equatable und vergleichen die Cases, oder extrahieren Sie associated values uber switch/guard case. Der String ist die „menschenlesbare Schicht“, aber der Test sollte auf dem „maschinenlesbaren Vertrag“ basieren.
Fehler Nr. 5: Man verwechselt die Prufung „wirft nicht“ mit der Prufung des Ergebnisses.
Manchmal schreibt man XCTAssertNoThrow(try ...) und hort dort auf, obwohl die Funktion einen falschen Wert zuruckgegeben haben konnte. Trennen Sie die Erwartungen besser: getrennt das Fehlen eines Fehlers und die Korrektheit des Ergebnisses prufen. Dann wissen Sie bei einem Fehlschlag sofort, was kaputt ist: Validierung oder Berechnung.
GO TO FULL VERSION