Parametrisierte Tests mit Swift Testing

Ergänzend zu meinem vorangegangenen Artikel über die grundlegende Funktionsweise von Swift Testing möchte ich an dieser Stelle eines der herausragendsten Features des Frameworks erläutern: Parametrisierte Tests.

Das Prinzip hinter parametrisierten Tests ist schnell erklärt: Statt einen Unit-Test einmalig auszuführen, kommt es zu mehreren Aufrufen. Konkret erfolgt die Ausführung des Tests einmal für jeden Parameter.

Geschickt angewendet, vermeidet man durch parametrisierte Tests mehrere sich wiederholende Tests und erlaubt gleichzeitig eine langfristig einfache Erweiterung bestehender Tests durch Hinzufügen neuer Parameter.

Welches Problem parametrisierte Tests konkret lösen können, zeigt das nachfolgende Beispiel. Das stammt aus einer konkreten Beispiel-App, die vorgefertigte Timer für verschiedene Tee-Arten bereitstellt. Für manche Tee-Arten – in diesem Fall Schwarzer und Grüner Tee – gibt es nicht einfach nur eine fixe Zeit, sondern einen Bereich, aus dem sich der gewünschte Timer wählen lässt.

Ob für eine Teesorte ein solcher Timer-Bereich vorliegt, verrät die optional Property durationInMinutesRange. Besitzt sie einen entsprechen Wert, steht ein Timer-Bereich zur Verfügung, andernfalls nicht.

Genau das prüfen die beiden Tests hasDurationRangeForBlackTea() und hasDurationRangeForGreenTea(). Der Code ist eingängig, aber auch repetitiv. Und sollten mit der Zeit weitere Teesorten hinzukommen, die ebenfalls über einen Timer-Bereich verfügen, müssen entsprechend weitere Tests erstellt werden.

struct TeaTests {
    @Test func hasDurationRangeForBlackTea() {
        let blackTea = Tea.black
        #expect(blackTea.durationInMinutesRange != nil)
    }
    
    @Test func hasDurationRangeForGreenTea() {
        let greenTea = Tea.green
        #expect(greenTea.durationInMinutesRange != nil)
    }
}

Umsetzung eines parametrisierten Tests

Mithilfe eines parametrisierten Tests lassen sich die beiden separaten Unit-Tests zusammenfassen. Um einen solchen zu erzeugen, übergibt man dem @Test-Makro eine Collection mit den gewünschten Parametern (in diesem Fall der beiden zu prüfenden Tea-Instanzen) in Form des arguments-Parameters. Zusätzlich muss die Test-Methode um einen Parameter vom Typ Tea erweitert werden. Dieser Parameter wird dann pro Testlauf mit einem der übergebenen Werte aus der Collection befüllt.

@Test(arguments: [Tea.black, Tea.green])
func hasDurationRange(for tea: Tea) {
    #expect(tea.durationInMinutesRange != nil)
}

Führt man diesen Test nun aus, wird er zweimal aufgerufen: einmal mit dem Tea.black– und einmal mit dem Tea.green-Parameter. Das spiegelt sich auch im Test Navigator wider.

Die Parameter des Unit-Tests werden im Test Navigator separat aufgeführt.
Die Parameter des Unit-Tests werden im Test Navigator separat aufgeführt.

So führt man einen Unit-Test nicht nur automatisch mehrmals mit verschiedenen Parametern aus. Xcode zeigt auch übersichtlich an, welche Tests durchlaufen werden konnten und bei welchen Parametern es möglicherweise Probleme gab. Auch lassen sich Tests für einzelne Parameter ganz einfach über den Test Navigator wiederholen.

Analog dazu lässt sich nun ein zweiter Unit-Test ergänzen, der die übrigen Teesorten genau darauf überprüft, dass sie über keinen Timer-Bereich verfügen:

@Test(arguments: [Tea.rooibos, Tea.herbal, Tea.fruit])
func hasNoDurationRange(for tea: Tea) {
    #expect(tea.durationInMinutesRange == nil)
}

Unterstützung für zwei Collections

Unit-Tests in Swift Testing können maximal zwei Collections auf einmal über den arguments-Parameter entgegennehmen. Übergibt man zwei Collections, muss auch die Test-Methode entsprechend über zwei Parameter verfügen, um die jeweiligen Werte aus den Collections entgegennehmen und verarbeiten zu können.

Solch einen Test führt Swift Testing dann für jedes mögliche Paar aus, das sich aus den beiden Collections bilden lässt. Übergibt man beispielsweise zwei Collections mit je drei Werten, ergeben sich daraus insgesamt neun durchgeführte Tests.

Möchte man hingegen bei Einsatz zweier Collections nur die Paare basierend auf demselben Index miteinander vergleichen, lässt sich zu diesem Zweck die globale zip(_:_:)-Funktion nutzen, deren Ergebnis dann dem arguments-Parameter des Tests übergeben wird.

Ein Beispiel für den Einsatz zweier Collections in einer Test-Methode zeigt das nachfolgende Listing. Der erste Parameter entspricht einer Instanz vom Typ Tea, der zweite dem Typ String. Der zweite Parameter enthält die Namen der Teesorten und der Test stellt sicher, dass die von den Tea-Instanzen zurückgelieferten Namen mit den als Parameter festgelegten Namen übereinstimmen. In diesem Fall ist der Einsatz der zip(_:_:)-Funktion essenziell, da zwischen den beiden Collections natürlich nur jeweils ein zu testendes Paar generiert werden soll.

@Test(arguments: zip(
    [Tea.black, Tea.green, Tea.rooibos, Tea.herbal, Tea.fruit],
    ["Schwarzer Tee", "Grüner Tee", "Rooibos Tee", "Kräutertee", "Früchtetee"]
))
func name(team: Tea, name: String) {
    #expect(team.name == name)
}

Kommen im Laufe der Zeit weitere Tea-Instanzen innerhalb des zugrundeliegenden Projekts hinzu, ist es nun ein Leichtes, den bestehenden Test um neue Einträge zu ergänzen.