Part 6

Einführung in das Testen

Lassen Sie uns unsere ersten Schritte in der Welt des Programmtests machen.

Fehlerhafte Situationen und schrittweises Problembeheben

Fehler schleichen sich in die Programme ein, die wir schreiben. Manchmal sind die Fehler nicht schwerwiegend und verursachen vor allem den Nutzenden des Programms Kopfschmerzen. Gelegentlich können Fehler jedoch sehr ernsthafte Folgen haben. In jedem Fall ist es sicher, dass eine Person, die das Programmieren lernt, viele Fehler macht.

Sie sollten keine Angst davor haben, Fehler zu machen, oder versuchen, diese zu vermeiden, da dies der beste Weg ist, zu lernen. Aus diesem Grund sollten Sie versuchen, das Programm, an dem Sie arbeiten, von Zeit zu Zeit absichtlich zu "brechen", um Fehlermeldungen zu untersuchen und zu sehen, ob diese Meldungen Ihnen etwas über die gemachten Fehler sagen.

Mit zunehmender Komplexität von Programmen wird das Auffinden von Fehlern immer schwieriger. Der in Intellij IDEA integrierte Debugger kann Ihnen helfen, Fehler zu finden. Die Verwendung des Debuggers wird in den in das Kursmaterial eingebetteten Videos vorgestellt; es ist immer ratsam, sich diese anzusehen.

Stack Trace

Wenn in einem Programm ein Fehler auftritt, gibt das Programm normalerweise etwas aus, das als Stack Trace bezeichnet wird, also die Liste der Methodenaufrufe, die zum Fehler geführt haben. Zum Beispiel könnte ein Stack Trace wie folgt aussehen:

Beispielausgabe
Exception in thread "main" ... at Program.main(Program.java:15)

Der Fehlertyp wird am Anfang der Liste angegeben, und die folgende Zeile informiert darüber, wo der Fehler aufgetreten ist. Die Zeile "at Program.main(Program.java:15)" sagt aus, dass der Fehler in Zeile 15 in der Datei Program.java aufgetreten ist.

Beispielausgabe
at Program.main(Program.java:15)

Checkliste zur Fehlerbehebung

Wenn Ihr Code nicht funktioniert und Sie nicht wissen, wo der Fehler liegt, helfen Ihnen diese Schritte, einen Anfang zu finden.

  1. Formatieren Sie Ihren Code richtig, und überprüfen Sie, ob Klammern fehlen.
  2. Stellen Sie sicher, dass die verwendeten Variablen korrekt benannt sind.
  3. Testen Sie den Programmablauf mit verschiedenen Eingaben und finden Sie heraus, welche Art von Eingabe dazu führt, dass das Programm nicht wie gewünscht funktioniert. Wenn Sie in den Tests einen Fehler erhalten haben, können die Tests auch die verwendete Eingabe anzeigen.
  4. Fügen Sie dem Programm Ausgabebefehle hinzu, in denen Sie die Werte der verwendeten Variablen in verschiedenen Phasen der Programmausführung ausgeben.
  5. Vergewissern Sie sich, dass alle verwendeten Variablen initialisiert sind. Andernfalls tritt ein NullPointerException-Fehler auf.
  6. Wenn Ihr Programm eine Ausnahme verursacht, sollten Sie unbedingt auf den mit der Ausnahme verbundenen Stack Trace achten, also die Liste der Methodenaufrufe, die zur Situation führten, die die Ausnahme verursacht hat.
  7. Lernen Sie, den Debugger zu verwenden.

Testeingaben an Scanner übergeben

Das manuelle Testen des Programms ist oft mühsam. Es ist möglich, die Eingabe zu automatisieren, indem z.B. der zu lesende String an ein Scanner-Objekt übergeben wird. Unten finden Sie ein Beispiel dafür, wie ein Programm automatisch getestet werden kann. Das Programm gibt zuerst fünf Zeichenfolgen ein, gefolgt von der zuvor gesehenen Zeichenfolge. Danach versuchen wir, eine neue Zeichenfolge einzugeben. Die Zeichenfolge "six" sollte nicht im Wortsatz erscheinen.

Die Testeingabe kann als String an den Konstruktor des Scanner-Objekts übergeben werden. Jeder Zeilenumbruch im Testeingabestrom wird in der Zeichenkette durch eine Kombination aus Backslash und dem Zeichen "n" "\n" markiert.

String input = "one\n" + "two\n"  +
                "three\n" + "four\n" +
                "five\n" + "one\n"  +
                "six\n";

Scanner reader = new Scanner(input);

ArrayList<String> read = new ArrayList<>();

while (true) {
    System.out.println("Enter an input: ");
    String line = reader.nextLine();
    if (read.contains(line)) {
        break;
    }

    read.add(line);
}

System.out.println("Thank you!");

if (read.contains("six")) {
    System.out.println("A value that should not have been added to the group was added to it.");
}

Die Ausgabe des Programms zeigt nur die vom Programm bereitgestellte Ausgabe, keine Benutzerbefehle.

Beispielausgabe

Enter an input: Enter an input: Enter an input: Enter an input: Enter an input: Enter an input: Thank you!

Das Übergeben eines Strings an den Konstruktor der Scanner-Klasse ersetzt die von der Tastatur gelesene Eingabe. Der Inhalt der String-Variablen input 'simuliert' also die Benutzereingabe. Ein Zeilenumbruch in der Eingabe wird mit \n markiert. Daher entspricht jeder Abschnitt, der in der angegebenen Zeichenfolgeneingabe in ein Zeilenumbruchszeichen endet, einer Eingabe, die dem nextLine()-Befehl gegeben wird.

Wenn Sie Ihr Programm wieder manuell testen möchten, ändern Sie den Parameter des Scanner-Objektkonstruktors in System.in, also in den Eingabestrom des Systems. Alternativ können Sie auch die Testeingabe ändern, da wir es mit einem String zu tun haben.

Die Funktion des Programms sollte weiterhin auf dem Bildschirm überprüft werden. Die Ausgabe kann anfangs etwas verwirrend sein, da die automatisierte Eingabe auf dem Bildschirm überhaupt nicht sichtbar ist. Das endgültige Ziel ist es, auch die Überprüfung der Korrektheit der Ausgabe zu automatisieren, sodass das Programm getestet und das Testergebnis mit einem "Knopfdruck" analysiert werden kann. Darauf werden wir in späteren Abschnitten zurückkommen.

Unit-Tests

Die oben dargestellte Methode des automatisierten Testens, bei der die Eingabe eines Programms modifiziert wird, ist recht praktisch, aber dennoch begrenzt. Das Testen größerer Programme auf diese Weise ist eine Herausforderung. Eine Lösung dafür sind Unit-Tests, bei denen kleine Teile des Programms isoliert getestet werden.

Unit-Tests beziehen sich auf das Testen einzelner Komponenten im Quellcode, wie z.B. Klassen und deren bereitgestellte Methoden. Das Schreiben von Tests zeigt, ob jede Klasse und Methode der Richtlinie entspricht oder von der Richtlinie abweicht, dass jede Methode und Klasse eine einzelne, klare Verantwortung hat. Je mehr Verantwortung die Methode hat, desto komplexer ist der Test. Wenn eine große Anwendung in einer einzigen Methode geschrieben ist, wird das Schreiben von Tests dafür sehr herausfordernd, wenn nicht sogar unmöglich. Ähnlich ist das Schreiben von Tests einfach, wenn die Anwendung in klare Klassen und Methoden unterteilt ist.

Beim Schreiben von Tests werden häufig fertige Unit-Test-Bibliotheken verwendet, die Methoden und Hilfsklassen zum Schreiben von Tests bereitstellen. Die gängigste Unit-Test-Bibliothek in Java ist JUnit, das auch von fast allen Programmierumgebungen unterstützt wird. IntelliJ IDEA kann beispielsweise automatisch nach JUnit-Tests in einem Projekt suchen – wenn welche gefunden werden, werden sie unter dem Projekt im Ordner Test Packages angezeigt.

Schauen wir uns das Schreiben von Unit-Tests anhand eines Beispiels an. Angenommen, wir haben die folgende Calculator-Klasse und möchten automatisierte Tests dafür schreiben.

public class Calculator {

    private int value;

    public Calculator()

 {
        this.value = 0;
    }

    public void add(int number) {
        this.value = this.value + number;
    }

    public void subtract(int number) {
        this.value = this.value + number;
    }

    public int getValue() {
        return this.value;
    }
}

Der Rechner funktioniert, indem er sich immer das Ergebnis der vorherigen Berechnung merkt. Alle nachfolgenden Berechnungen werden immer zum vorherigen Ergebnis addiert. Ein kleiner Fehler, der durch Kopieren und Einfügen entstanden ist, wurde im obigen Rechner belassen. Die Methode subtract sollte den Wert abziehen, aber derzeit wird er hinzugefügt.

Das Schreiben von Unit-Tests beginnt mit der Erstellung einer Testklasse, die unter dem Ordner Test Packages erstellt wird. Wenn die Calculator-Klasse getestet wird, soll die Testklasse CalculatorTest genannt werden. Der String Test am Ende des Namens sagt der Programmierumgebung, dass dies eine Testklasse ist. Ohne den String Test werden die Tests in der Klasse nicht ausgeführt. (Hinweis: Tests werden in IntelliJ im Ordner test/java erstellt.)

Die Testklasse CalculatorTest ist zunächst leer.

public class CalculatorTest {

}

Tests sind Methoden der Testklasse, wobei jeder Test eine einzelne Einheit testet. Beginnen wir mit dem Testen der Klasse – wir beginnen mit der Erstellung einer Testmethode, die bestätigt, dass der neu erstellte Rechnerwert zu Beginn 0 ist.

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

public class CalculatorTest {

    @Test
    public void calculatorInitialValueZero() {
        Calculator calculator = new Calculator();
        assertEquals(0, calculator.getValue());
    }
}

In der Methode calculatorInitialValueZero wird zunächst ein Calculator-Objekt erstellt. Die vom JUnit5-Testframework bereitgestellte assertEquals-Methode wird dann verwendet, um den Wert zu überprüfen. Die Methode wird mit dem Befehl import static aus der Assert-Klasse importiert und erhält den erwarteten Wert als Parameter – in diesem Fall 0 – und den vom Rechner zurückgegebenen Wert. Wenn sich die Werte der assertEquals-Methode unterscheiden, wird der Test nicht bestanden. Jede Testmethode sollte eine "Annotation" @Test haben. Dies sagt dem JUnit5-Testframework, dass es sich um eine ausführbare Testmethode handelt.

Um die Tests auszuführen, wählen Sie das Projekt mit der rechten Maustaste aus und klicken Sie auf Test.

Durch Ausführen der Tests wird eine Ausgabe erzeugt, die einige spezifische Informationen zu jeder Testklasse enthält. Im folgenden Beispiel werden die Tests der CalculatorTest-Klasse aus dem Paket ausgeführt. Die Anzahl der ausgeführten Tests betrug 1, von denen keiner fehlschlug – Fehlschlag bedeutet in diesem Zusammenhang, dass die vom Test getestete Funktionalität nicht wie erwartet funktioniert hat.

Beispielausgabe

[INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running CalculatorTest [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.039 s — in CalculatorTest [INFO] [INFO] Results: [INFO] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------

Fügen wir der Testklasse Funktionen zum Addieren und Subtrahieren hinzu.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {

    @Test
    public void calculatorInitialValueZero() {
        Calculator calculator = new Calculator();
        assertEquals(0, calculator.getValue());
    }

    @Test
    public void valueFiveWhenFiveAdded() {
        Calculator calculator = new Calculator();
        calculator.add(5);
        assertEquals(5, calculator.getValue());
    }

    @Test
    public void valueMinusTwoWhenTwoSubstracted() {
        Calculator calculator = new Calculator();
        calculator.subtract(2);
        assertEquals(-2, calculator.getValue());
    }
}

Das Ausführen der Tests erzeugt die folgende Ausgabe.

Beispielausgabe

[INFO] Running CalculatorTest [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.045 s <<< FAILURE! — in CalculatorTest [ERROR] CalculatorTest.valueMinusTwoWhenTwoSubstracted — Time elapsed: 0.026 s <<< FAILURE! [INFO] ... [INFO] Results: [INFO] [ERROR] Failures: [ERROR] CalculatorTest.valueMinusTwoWhenTwoSubstracted:24 expected: <-2> but was: <2> [INFO] [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------

Die Ausgabe zeigt uns, dass drei Tests ausgeführt wurden. Einer davon schlug fehl. Die Testausgabe informiert uns auch über die Zeile, in der der Fehler aufgetreten ist (24), und über die erwarteten (-2) und tatsächlichen (2) Werte. Immer wenn das Ausführen von Tests zu einem Fehler führt, zeigt IntelliJ den Fehlerzustand auch visuell an.

Während die vorherigen zwei Tests bestanden, führte einer von ihnen zu einem Fehler. Lassen Sie uns den Fehler beheben, der in der Calculator-Klasse zurückgelassen wurde.

// ...
public void subtract(int number) {
    this.value = this.value - number;
}
// ...

Wenn die Tests erneut ausgeführt werden, bestehen sie.

Beispielausgabe

[INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running CalculatorTest [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.036 s — in CalculatorTest [INFO] [INFO] Results: [INFO] [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------

Testgetriebene Entwicklung

Testgetriebene Entwicklung ist ein Softwareentwicklungsprozess, der darauf basiert, ein Stück Software in kleinen Iterationen zu erstellen. Bei der testgetriebenen Softwareentwicklung schreibt der Programmierende immer zuerst einen automatisch ausführbaren Test, der einen bestimmten Teil des Computerprogramms testet.

Der Test wird nicht bestanden, da die Funktionalität, die den Test erfüllt, d.h. der zu untersuchende Teil des Computerprogramms, fehlt. Sobald der Test geschrieben ist, wird dem Programm eine Funktionalität hinzugefügt, die die Testanforderungen erfüllt. Die Tests werden dann erneut ausgeführt. Wenn alle Tests bestanden werden, wird ein neuer Test hinzugefügt oder, wenn die Tests fehlschlagen, das bereits geschriebene Programm korrigiert. Falls erforderlich, wird die interne Struktur des Programms korrigiert oder refaktoriert, sodass die Funktionalität des Programms gleich bleibt, aber die Struktur klarer wird.

Testgetriebene Softwareentwicklung besteht aus fünf Schritten, die wiederholt werden, bis das Programms fertiggestellt ist.

  1. Schreiben Sie einen Test. Der Programmierende entscheidet, welche Programmfunktionalität getestet werden soll, und schreibt einen Test dafür.

  2. Führen Sie die Tests aus und überprüfen Sie, ob die Tests bestanden werden. Wenn ein neuer Test geschrieben wird, wird dieser Test ausgeführt. Wenn der Test bestanden wird, ist der Test höchstwahrscheinlich fehlerhaft und sollte korrigiert werden – der Test sollte nur die Funktionalität testen, die noch nicht implementiert wurde.

  3. Schreiben Sie die Funktionalität, die den Anforderungen des Tests entspricht. Der Programmierende implementiert eine Funktionalität, die nur den Anforderungen des Tests entspricht. Hinweis: Implementieren Sie nichts, was der Test nicht erfordert – Funktionalität wird nur in kleinen Schritten hinzugefügt.

  4. Führen Sie die Tests durch. Wenn die Tests fehlschlagen, liegt wahrscheinlich ein Fehler in der geschriebenen Funktionalität vor. Korrigieren Sie die Funktionalität oder, wenn kein Fehler in der Funktionalität vorliegt, korrigieren Sie den zuletzt durchgeführten Test.

  5. Reparieren Sie die interne Struktur des Programms. Mit zunehmender Größe des Programms wird seine interne Struktur bei Bedarf angepasst. Zu lange Methoden werden in mehrere Teile aufgeteilt und Klassen, die Konzepte darstellen, werden isoliert. Die Tests werden nicht modifiziert, sondern stattdessen verwendet, um die Korrektheit der vorgenommenen Änderungen an der internen Struktur des Programms zu überprüfen – wenn eine Änderung in der Programmstruktur die Funktionalität des Programms verändert, geben die Tests eine Warnung aus und der Programmierende kann die Situation beheben.

Programmierübung:

Exercises (Part06_13)

Unit Testing

In dieser Aufgabe werden Ihnen zwei Dateien zur Verfügung gestellt: Calculator und CalculatorTest. Die Datei Calculator enthält die Implementierung eines einfachen Taschenrechners, und diese Datei darf nicht verändert werden.

Die Datei CalculatorTest enthält bereits einige Beispieltests, die mit JUnit5 erstellt wurden. Diese Tests überprüfen die Grundfunktionalitäten des Taschenrechners, wie das Hinzufügen und Subtrahieren von Zahlen. Die Datei CalculatorTest ist bereits in das Projektverzeichnis src/de/techfak/Part06_13_Exercises eingebunden.

Das Testen von JUnit-Tests ist nicht trivial. In dieser Aufgabe ist es relativ einfach, die Testumgebung zu umgehen. Daher werden wir Ihren Code nach der Einreichung manuell zu überprüfen. Wir erwarten, dass jede Methode, die mit @Test annotiert ist, entweder assertEquals oder (im Falle von divisionByZeroThrowsException) assertThrows verwendet. Leere Testmethoden oder Methoden ohne entsprechende Assertions werden als unzureichend angesehen.

Die Methode divisionByZeroThrowsException wird Ihnen bereits zur Verfügung gestellt. Sie enthält fortgeschrittene Konzepte (wie Lambda-Ausdrücke () -> ...), die erst später im Kurs behandelt werden. Diese Methode dient zu Studienzwecken und ist nicht zur erneuten Implementierung gedacht. Bitte nutzen Sie sie als Beispiel, um zu verstehen, wie man Ausnahmen in Tests behandelt.

Beachten Sie auch, dass diese Aufgabe ob Ihrer Komplexität mit relativ wenig Punkten bewertet wird.

Konkret sollen die folgenden Lücken in den Tests ausgefüllt werden:

  1. calculatorInitialValueZero(): Implementiere einen Test, der überprüft, ob der anfängliche Wert des Calculator 0 ist.

  2. valueFiveWhenFiveAdded(): Implementiere einen Test, der überprüft, ob das Hinzufügen von 5 zu einem Startwert von 0 das erwartete Ergebnis 5 liefert.

  3. valueMinusTwoWhenTwoSubtracted(): Implementiere einen Test, der überprüft, ob das Subtrahieren von 2 von einem Startwert von 0 das erwartete Ergebnis -2 liefert.

  4. valueTenWhenMultipliedByTen(): Implementiere einen Test, der überprüft, ob das Multiplizieren eines Startwerts von 1 mit 10 das erwartete Ergebnis 10 ergibt.

  5. valueTwoWhenDividedByTwo(): Implementiere einen Test, der überprüft, ob das Dividieren eines Startwerts von 4 durch 2 das erwartete Ergebnis 2 ergibt.

Hinweis: Die Methode divisionByZeroThrowsException() wird Ihnen bereits vollständig zur Verfügung gestellt. Sie enthält fortgeschrittene Konzepte (wie Lambda-Ausdrücke () -> ...), die erst später im Kurs behandelt werden. Diese Methode dient zu Studienzwecken und ist nicht zur erneuten Implementierung gedacht. Bitte nutzen Sie sie, um zu verstehen, wie man Ausnahmen in Tests behandelt.

Die Tests sollen korrekt implementiert werden, um sicherzustellen, dass die Calculator-Klasse fehlerfrei arbeitet.


Sie müssen hierfür den Artemis Server verwenden und die entsprechende Aufgabe lösen.

Hier klicken.

Sie haben das Ende dieses Abschnitts erreicht! Weiter zum nächsten Abschnitt: