Testen

Konstantes Testen ist unerlässlich für eine schnelle Programmentwicklung.

Wenn das Ändern eines Teils Ihres Codes anderen Code beschädigt, zeigen Ihre Tests das Problem sofort auf. Wenn Sie es nicht sofort herausfinden, häufen sich die Änderungen an und Sie können nicht mehr feststellen, welche Änderung das Problem verursacht hat. Sie werden viel länger brauchen, um es zu verfolgen.

Testen ist eine entscheidende Praxis, daher führen wir es früh ein und verwenden es im gesamten Rest des Buches. Auf diese Weise gewöhnen Sie sich daran, Tests als Standardteil des Programmierprozesses zu betrachten.

println() zu verwenden, um die Korrektheit des Codes zu überprüfen, ist ein schwacher Ansatz—Sie müssen jedes Mal die Ausgabe genau prüfen und bewusst sicherstellen, dass sie korrekt ist.

Um Ihre Erfahrung beim Verwenden dieses Buches zu vereinfachen, haben wir unser eigenes kleines Testsystem erstellt. Das Ziel ist ein minimaler Ansatz, der:

  1. Das erwartete Ergebnis von Ausdrücken zeigt.
  2. Eine Ausgabe liefert, damit Sie wissen, dass das Programm läuft, selbst wenn alle Tests erfolgreich sind.
  3. Das Konzept des Testens frühzeitig in Ihrer Praxis verankert.

Obwohl es für dieses Buch nützlich ist, ist unseres kein Testsystem für den Arbeitsplatz. Andere haben lange und hart gearbeitet, um solche Testsysteme zu erstellen. Zum Beispiel:

  • JUnit ist eines der beliebtesten Java-Testframeworks und kann leicht in Kotlin verwendet werden.
  • Kotest ist speziell für Kotlin entwickelt und nutzt die Funktionen der Kotlin-Sprache.
  • Das Spek Framework produziert eine andere Form des Testens, genannt Spezifikationstests.

Um unser Testframework zu verwenden, müssen wir es zuerst importieren. Die grundlegenden Elemente des Frameworks sind eq (gleich) und neq (nicht gleich):

// Testing/TestingExample.kt
import atomictest.*

fun main() {
  val v1 = 11
  val v2 = "Ontology"

  // 'eq' means "equals":
  v1 eq 11
  v2 eq "Ontology"

  // 'neq' means "not equal"
  v2 neq "Epistemology"

  // [Error] Epistemology != Ontology
  // v2 eq "Epistemology"
}
/* Output:
11
Ontology
Ontology
*/

Der Code für das Paket atomictest befindet sich in Appendix A: AtomicTest. Es ist nicht beabsichtigt, dass Sie alles in AtomicTest.kt sofort verstehen, da es einige Funktionen verwendet, die erst später im Buch erscheinen werden.

Um ein klares, angenehmes Erscheinungsbild zu erzeugen, verwendet AtomicTest eine Kotlin-Funktion, die Sie noch nicht gesehen haben: die Fähigkeit, einen Funktionsaufruf a.function(b) in der textähnlichen Form a function b zu schreiben. Dies wird als Infix-Notation bezeichnet. Nur Funktionen, die mit dem Schlüsselwort infix definiert sind, können auf diese Weise aufgerufen werden. AtomicTest.kt definiert die infix-Funktionen eq und neq, die in TestingExample.kt verwendet werden:

expression eq expected
expression neq expected

eq und neq sind flexibel – fast alles funktioniert als Testausdruck. Wenn erwartet ein String ist, wird Ausdruck in einen String umgewandelt und die beiden Strings werden verglichen. Andernfalls werden Ausdruck und erwartet direkt verglichen (ohne sie vorher umzuwandeln). In jedem Fall erscheint das Ergebnis von Ausdruck auf der Konsole, sodass Sie etwas sehen, wenn das Programm läuft. Selbst wenn die Tests erfolgreich sind, sehen Sie das Ergebnis links von eq oder neq. Wenn Ausdruck und erwartet nicht gleichwertig sind, zeigt AtomicTest einen Fehler an, wenn das Programm läuft.

Der letzte Test in TestingExample.kt schlägt absichtlich fehl, damit Sie ein Beispiel für eine Fehlerausgabe sehen. Wenn die beiden Werte nicht gleich sind, zeigt Kotlin die entsprechende Nachricht an, die mit [Error] beginnt. Wenn Sie die letzte Zeile auskommentieren und das obige Beispiel ausführen, sehen Sie nach allen erfolgreichen Tests:

[Error] Epistemology != Ontology

Der tatsächliche Wert, der in v2 gespeichert ist, entspricht nicht dem, was im Ausdruck “erwartet” behauptet wird. AtomicTest zeigt die String-Darstellungen sowohl für erwartete als auch für tatsächliche Werte an.

eq und neq sind die grundlegenden (infix) Funktionen, die für AtomicTest definiert sind – es ist wirklich ein minimalistisches Testsystem. Wenn Sie eq- und neq-Ausdrücke in Ihren Beispielen verwenden, erstellen Sie sowohl einen Test als auch eine Konsolenausgabe. Sie überprüfen die Korrektheit des Programms, indem Sie es ausführen.

Es gibt ein zweites Werkzeug in AtomicTest. Das trace-Objekt erfasst die Ausgabe für einen späteren Vergleich:

// Testing/Trace1.kt
import atomictest.*

fun main() {
  trace("line 1")
  trace(47)
  trace("line 2")
  trace eq """
    line 1
    47
    line 2
  """
}

Das Hinzufügen von Ergebnissen zu trace sieht aus wie ein Funktionsaufruf, daher können Sie println() effektiv durch trace() ersetzen.

In früheren Atomen haben wir die Ausgabe angezeigt und uns auf die menschliche visuelle Inspektion verlassen, um Unstimmigkeiten zu erkennen. Das ist unzuverlässig; selbst in einem Buch, in dem wir den Code immer wieder genau prüfen, haben wir gelernt, dass man der visuellen Inspektion nicht trauen kann, um Fehler zu finden. Von nun an verwenden wir selten kommentierte Ausgabeblöcke, da AtomicTest alles für uns erledigen wird. Manchmal fügen wir jedoch immer noch kommentierte Ausgabeblöcke ein, wenn dies einen nützlicheren Effekt hat.

Die Vorteile des Testens im gesamten restlichen Buch sollten Ihnen helfen, das Testen in Ihren Programmierprozess zu integrieren. Sie werden sich wahrscheinlich unwohl fühlen, wenn Sie Code sehen, der keine Tests hat. Sie könnten sogar entscheiden, dass Code ohne Tests per Definition fehlerhaft ist.

Testen als Teil der Programmierung

Testen ist am effektivsten, wenn es in Ihren Softwareentwicklungsprozess integriert ist. Das Schreiben von Tests stellt sicher, dass Sie die erwarteten Ergebnisse erhalten. Viele Leute befürworten das Schreiben von Tests vor dem Schreiben des Implementierungscodes - Sie lassen zuerst den Test fehlschlagen, bevor Sie den Code schreiben, um ihn erfolgreich zu machen. Diese Technik, genannt Testgetriebene Entwicklung (TDD), ist eine Möglichkeit sicherzustellen, dass Sie wirklich das testen, was Sie denken. Eine vollständigere Beschreibung von TDD finden Sie auf Wikipedia (suchen Sie nach “Testgetriebene Entwicklung”).

Es gibt einen weiteren Vorteil beim testbaren Schreiben - es verändert die Art und Weise, wie Sie Ihren Code gestalten. Sie könnten die Ergebnisse einfach auf der Konsole anzeigen. Aber im Testdenken fragen Sie sich: “Wie werde ich das testen?” Wenn Sie eine Funktion erstellen, entscheiden Sie, dass Sie etwas aus der Funktion zurückgeben sollten, wenn auch nur, um dieses Ergebnis zu testen. Funktionen, die nichts anderes tun, als Eingaben zu nehmen und Ausgaben zu erzeugen, neigen dazu, auch bessere Designs zu erzeugen.

Hier ist ein vereinfachtes Beispiel, das TDD verwendet, um die BMI-Berechnung aus Zahlentypen zu implementieren. Zuerst schreiben wir die Tests sowie eine anfängliche Implementierung, die fehlschlägt (weil wir die Funktionalität noch nicht implementiert haben):

// Testing/TDDFail.kt
package testing1
import atomictest.eq

fun main() {
  calculateBMI(160, 68) eq "Normal weight"
//  calculateBMI(100, 68) eq "Underweight"
//  calculateBMI(200, 68) eq "Overweight"
}

fun calculateBMI(lbs: Int, height: Int) =
  "Normal weight"

Nur der erste Test besteht. Die anderen Tests schlagen fehl und sind kommentiert. Als nächstes fügen wir Code hinzu, um zu bestimmen, welche Gewichte in welchen Kategorien sind. Jetzt schlagen alle Tests fehl:

// Testing/TDDStillFails.kt
package testing2
import atomictest.eq

fun main() {
  // Everything fails:
  // calculateBMI(160, 68) eq "Normal weight"
  // calculateBMI(100, 68) eq "Underweight"
  // calculateBMI(200, 68) eq "Overweight"
}

fun calculateBMI(
  lbs: Int,
  height: Int
): String {
  val bmi = lbs / (height * height) * 703.07
  return if (bmi < 18.5) "Underweight"
  else if (bmi < 25) "Normal weight"
  else "Overweight"
}

Wir verwenden Ints anstelle von Doubles, was zu einem Nullergebnis führt. Die Tests führen uns zur Lösung:

// Testing/TDDWorks.kt
package testing3
import atomictest.eq

fun main() {
  calculateBMI(160.0, 68.0) eq "Normal weight"
  calculateBMI(100.0, 68.0) eq "Underweight"
  calculateBMI(200.0, 68.0) eq "Overweight"
}

fun calculateBMI(
  lbs: Double,
  height: Double
): String {
  val bmi = lbs / (height * height) * 703.07
  return if (bmi < 18.5) "Underweight"
  else if (bmi < 25) "Normal weight"
  else "Overweight"
}

Sie können zusätzliche Tests für die Randbedingungen hinzufügen.

In den Übungen für dieses Buch haben wir Tests enthalten, die Ihr Code bestehen muss.

Übungen und Lösungen finden Sie unter www.AtomicKotlin.com.