Unit-Testing mit BlueJ

1. Testen

Testen bedeutet allgemein die Überprüfung, ob sich etwas so verhält, wie es spezifiziert ist oder erwartet wird.

Da jede Code-Änderung potentiell neue Fehler verursachen kann, ist häufiges und möglichst vollständiges Testen (Begriff Test-Abdeckung, EN: Coverage) extrem wichtig für die Software-Qualität.

Sehr viele Fehler lassen sich schon auf "niedrigstem Niveau" prüfen. Hier werden einzelne Methoden oder kleinstmögliche Gruppen von Methoden getestet – das Unit Testing.

Manche Fehler sind erst im Zusammenspiel größerer funktioneller Einheiten erkennbar – das Integration Testing

Prüfung auf Fehler bei der tatsächlichen Nutzung erfordern die Prüfung des Zusammenspiels des gesamten Systems – das System Testing

Bei der geringen Komplexität unserer Projekte ist Unit-Testing das einzige, das wir vorerst betreiben werden.

2. Konkreter Ablauf

Testen besteht aus dem "händischen" Konzipieren eines Testfalls, der bei vollständig festgelegten Umständen (üblicherweise Werte von Instanzvariablen, statischen Variablen, System Properties etc. aller beteiligten Objekte/Klassen) ein eindeutiges, "im Kopf" berechnetes und auch abfragbares Ergebnis liefert.

Dann wird die für den Testfall definierte Situation hergestellt und maschinell der vorab überlegte Erwartungswert mit dem tatsächlich vom Programm gelieferten verglichen.

Bei Übereinstimmung wird nichts getan, bei Diskrepanzen eine Fehlermeldung erzeugt. Somit ist kein Eingriff des Entwicklers nötig. Wenn alles passt, wird am Ende der gesamten Serie von Tests ein OK ausgegeben, anderenfalls Details zu allen gefundenen Fehlern.

3. JUnit5

Um solches Unit-Testen möglichst unkompliziert und zuverlässig durchgeführt werden kann, sind im Laufe der Zeit leistungsfähige "Frameworks" entwickelt worden. Das bekannteste dazu (und vor ca. 25 Jahren der Ausgangspunkt für das Konzept des Unit-Testing) ist JUnit, mittlerweile in Version 5.

In BlueJ 5 lässt sich eine Test-Klasse zu einer "normalen" Klasse wie folgt erzeugen:

alt
Abbildung 1. Rechtsklick-Menü auf Klasse, unterster Eintrag: Create Test Class

Die erzeugte (noch weitgehend leere) Test-Klasse erscheit als grüner "Schatten" hinter der zu testenden Klasse.

Nun können wir mit Rechts-Klick auf die Test-Klasse eine Test-Methode erzeugen:

BlueJ-JunitCreaTestMeth
Abbildung 2. Rechtsklick-Menu auf TEST-Klasse, 3.-unterster Eintrag: Create Test Method …​

Die Test-Methode wurde erzeugt, links in Projekt-Fenster wird der Knopf "recording" rot und wir können wie gewohnt neue Objekte erzeugen, Methoden aufrufen, etc. Nach Methoden-Fertigstellung erscheint ein zuerst ein Dialog für die Test-Aktion:
man muss den Erwartungswert (und bei float oder double-Werten die geforderte Genauigkeit für die Übereinstimmung …​ Rundungsfehler, etc.!) angeben, gegen den das berechnete Ergebnis verglichen werden soll.

Danach öffnet sich ein leicht erweiterter Ergebnis-Dialog, den wir hier wegklicken können.

BlueJ-JunitRec1TestMeth

Wenn ein Fehler auftritt (hier wegen zu hoher Genauigkeitsforderung für die Factory-Methode ofArea(..)), wird sowohl Erwartungswert als auch der berechnete Wert (hier blau markiert) ausgegeben (man sieht die überzogene Genauigkeit im letzten Parameter der letzten Test-Methode):

BlueJ-JunitRec1TestResultERR

Nach Korrektur der Genauigkeit gelingt auch dieser Test, das Ergebnis ist somit:

BlueJ-JunitRec1TestResultOK

3.1. Beispielklasse Circle

public class Circle
{
    private float radius = 1.0f;

    public Circle(float radius) {
        setRadius(radius);
    }
    public void setRadius(float radius) {
        if (radius <= 0) {
            System.out.println("Invalid Radius: " + radius + " -> remains " + this.radius);
        } else {
            this.radius = radius;
        }
    }
    public float calcDiameter() {  // DE: Durchmesser
        return 2*radius;
    }
    public float calcPerimeter() {  // DE: Umfang (for circle also: circumference)
        return (float) (Math.PI*calcDiameter());  // casting needed since PI is double
    }
    public float calcArea() {       // DE: Flaeche
        return (float) Math.PI * radius*radius;
    }
    // 2 Factory Methods (static!!):
    public static Circle ofArea(float area) { // 'Factory method' to create obj. - static
        float r = (float) Math.sqrt(area/Math.PI);
        return new Circle(r);  // behind the scene the usual way of real creation!!!!
    }
    public static Circle ofPerimeter(float perimeter) { // other factory method
        float r = (float) (perimeter/2/Math.PI);
        return new Circle(r);  // see above
    }
    public float getRadius() {
        return this.radius;
    }
}

In der zu testenden Klasse werden unten 2 spezielle Methoden gezeigt: public static Circle ofArea(float area)
und public static Circle ofPerimeter(float perimeter)

Sie sind statisch, da sie nicht an Objekten arbeiten, sondern Objekte erzeugen! Man nennt solche Methoden Factory-Methoden. Intern verwenden sie den Konstruktor, können aber verschiedenste Vorbereitungen treffen, wie hier die Berechnung des benötigten Radius aus Umfang bzw. Fläche.

Die Berechnung des Radius aus Fläche oder Umfang könnte auch sinnvoll als eigene Methoden implementiert und hier nur aufgerufen werden.
Auch diese Methoden sollten vermutlich eher statisch sein, da sie keinen Zugriff auf die Instanzvariablen haben/benötigen.

Die Erzeugung der Test-Methode testFactoryMethodOfArea1() ist etwas komplizierter. Die Methode liefert ja ein Objekt zurück, wir wollen aber nur den Radius in diesem Objekt prüfen. Daher klicken wir das Häkchen bei _Assert that:_weg, dann wird keine Prüfung erzeugt. Im Ergebnis-Dialog wählen wir jedoch den Button get, wodurch das zurückgelieferte Objekt in der "Object Bench" abgelegt wird.

Somit können wir als letzte Aktion die Methode getRadius() aufrufen, den hier nötigen Erwartungswert eingeben und erhalten die richtigen Inhalte der erstellten Test-Methode.

3.2. Genutzte einfache Test-Klasse

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
 * The test class CircleTest.
 * @author  renkin@spengergasse.at
 * @version 2023-02-19
 */
public class CircleTest
{
    /**
     * Default constructor for test class CircleTest
     */
    public CircleTest() {  }

    /**
     * Sets up the test fixture.
     * Called before every test case method.
     */
    @BeforeEach
    public void setUp() {  }

    /**
     * Tears down the test fixture.
     * Called after every test case method.
     */
    @AfterEach
    public void tearDown() {  }

    @Test
    public void testCalcDiameter1() {
        Circle circle1 = new Circle(2.0f);
        assertEquals(4, circle1.calcDiameter(), 0.0001);
    }

    @Test
    public void testCalcDiameter2() {
        Circle circle1 = new Circle(0.5f);
        assertEquals(1, circle1.calcDiameter(), 0.0001);  // stops at first error
        circle1.setRadius(1);
        assertEquals(2, circle1.calcDiameter(), 0.01);    // other problems not seen
        // later we will see better ways to ensure all tests are done
    }

    @Test
    public void testFactoryMethodOfArea1()
    {
        Circle circle1 = Circle.ofArea(12.566f);
        assertEquals(2.0f, circle1.getRadius(), 0.0000001);  // the relevant test
    }
}