Groovy Shell Scripting Teil 3 – Testing

Von Georg Berky
0 Kommentar
Groovy Shell Scripting Teil 3 - Testing

Dies ist Teil 3 unserer Serie zu Groovy Shell Scripting. Im ersten Teil haben wir Groovy mit Hilfe von SDKMAN installiert und ein erstes „Hello World“ auf der Kommandozeile geschrieben. In Teil 2 haben wir mit Pipes und FIFOs Prozesse miteinander kommunizieren und unsere Groovy Skripte mit anderen Linux Tools zusammen arbeiten lassen.

Szenario

Im vorigen Teil von Groovy Shell Scripting haben wir einen Producer gebaut, der Zahlen in eine Named Pipe legt. Diese Zahlen werden dann von einem Consumer gelesen und wieder auf der Kommandozeile ausgegeben. Den Consumer wollen wir jetzt so erweitern, dass er die Zahlen nicht nur lesen, sondern auch inkrementieren kann. Diese einfache Simulation echter Geschäftslogik wollen wir testen – einmal in einem klassischen Unit Test und einmal in einem integrierten Test. Wir werden sehen, dass wir dafür nicht einmal mehr Tools nachinstallieren müssen, weil Groovy bereits viele Werkzeuge mitbringt.

Source Code organisieren

Die meisten meiner Skripte sind recht klein und gehen nicht über wenige Klassen hinaus. Ich starte sie direkt aus der Shell, statt sie in ein ausführbares JAR zu packen. Mein Repository für diese Serie sieht gerade so aus:

.
├── README.md
…
└── testing
    ├── Deserializer.groovy
    ├── DeserializerTest.groovy
    ├── Inkrementer.groovy
    ├── InkrementerTest.groovy
    ├── PipeConsumerTest.groovy
    ├── fifo
    ├── pipe_consumer.groovy
    └── pipe_producer.groovy

Wie ihr seht, habe ich für diesen Post einfach einen Ordner testing angelegt, der sowohl Tests als auch Produktionsklassen nebeneinander enthält. Aus diesem Ordner heraus werde ich die Beispiele weiter unten aufrufen.

Packages

Angenommen, mein Repository sähe so aus:

.
├── hello
│   └── Hello.groovy
└── hello.groovy

dann könnte ich mit diesem Layout vom Skript hello.groovy auf die Klasse Hello folgendermaßen zugreifen:

#!/usr/bin/env groovy
def hello = new hello.Hello()
hello.doSomething()

oder alternativ auch mit einem Import:

#!/usr/bin/env groovy
import hello.Hello

def hello = new Hello()
hello.doSomething()

Tests von Produktionsklassen trennen

Wenn das Repository größer wird, lohnt es sich irgendwann, Test- und Produktionscode zu trennen. Angenommen mein Repository hat folgenden Inhalt:

.
├── src
│   └── bar
│       └── Foo.groovy
└── test
    └── bar
        └── FooTest.groovy

Mein Produktionscode liegt in Packages unter src. Jedes Package bekommt ein eigenes Verzeichnis. Unter-Packages werden zu Unterverzeichnissen. Mit den Tests verfahren wir genauso.

Meine Testklasse sieht so aus:

package bar
import org.junit.jupiter.api.*

class FooTest {
    @Test
    void "the answer is"() {
        def foo = new Foo()        

        def something = foo.doSomething()

        assert something == 42
    }
}

Meine Produktionsklasse so:

package bar

class Foo {
    int doSomething() {
        42
    }
}

Dann kann ich den Test im Root-Verzeichnis meines Repositorys so starten:

➜ groovy -cp test:src test/bar/FooTest.groovy
JUnit5 launcher: passed=1, failed=0, skipped=0, time=51ms

Der Aufruf mit -cp path1:path2 sagt Groovy, wo nach Klassen gesucht werden soll. Wenn ich teste, brauche ich sowohl den Verzeichnisbaum unter test als auch den unter src. Wenn ich nur Produktionscode starten will, nehme ich test einfach nicht in den Classpath auf. So ist dann sicher gestellt, dass sich kein Testcode unter meinen Produktionscode gemischt hat.

Details wie genau der Classloader der JVM nach Klassen sucht, findet ihr in der Java Documentation „How classes are found“ im Oracle Tech Network.

Wirklich komplexe Projekte

Spätestens wenn ihr einen echten Buildprozess für eure Projekte zu braucht, würde ich anfangen, mir über Tools wie Maven oder Gradle Gedanken zu machen. Damit baut man aber meistens Artefakte wie JAR oder WAR Dateien und wir verlassen die Gefilde des Groovy Shell Scripting. Es kann sich aber lohnen, wiederverwendbare Komponenten für Skripte zu schreiben, und diese später via Dependency Management dort einzubinden. Dazu kommen wir im nächsten Teil.

Batteries included: JUnit + Power Assertions

Groovy bringt allerlei Hilfmittel bereits mit. Integriert sind inzwischen ganze drei Versionen von JUnit: 3, 4 und 5. Man muss keine zusätzlichen Bibliotheken mehr einbinden, sondern kann sofort loslegen. Wir beschränken uns hier auf JUnit 4 und 5.

JUnit 4

Auf der Shell ist der einzige Unterschied zu regulären JUnit Tests, dass wir die aus Teil 1 von Groovy Shell Scripting bekannte Shebang an den Anfang der Datei setzen müssen. Danach folgen die bekannten Imports und eine Testklasse mit den Annotations von JUnit 4:

#!/usr/bin/env groovy
import org.junit.Test

class MyTest {

    @Test
    void "very simple test"() {
        assert 2 + 2 == 5
    }
}

Groovy erlaubt Strings als Methodennamen. Dies machen wir uns bei Tests zunutze, um besonders sprechende Namen zu vergeben. Wenn wir den Test starten, bekommen wir folgende Fehlermeldung:

➜ ./5_junit4.groovy
JUnit 4 Runner, Tests: 1, Failures: 1, Time: 10
Test Failure: myTest(MyTest)
Assertion failed:

assert 2 + 2 == 5
         |   |
         4   false

Diese sprechenden Fehlermeldungen sind ein weiteres Feature von Groovy, die „Power Assertions“. Wir sehen alle Teile der fehlgeschlagenen Assertion auf einen Blick: Die ganze Expression, ihre Teile und das Ergebnis ihrer Auswertungen. Auf der rechten Seite des Vergleichsoperators sehen wir den erwarteten Wert und direkt darunter, dass der Vergleich fehlgeschlagen ist. Bei komplexeren Evaluationen hilft uns das enorm bei der Analyse des Problems.

JUnit 5

Auch JUnit 5 funktioniert ohne weiteres Zutun:

#!/usr/bin/env groovy
import org.junit.jupiter.api.Test

class MyTest {

    @Test
    void "simple JUnit 5 Test"() {
        assert 1 + 1 == 3
    }
}

Und auch dieser Test schlägt fehl:

➜ ./5_junit5.groovy
JUnit5 launcher: passed=0, failed=1, skipped=0, time=51ms

Failures (1):
  JUnit Jupiter:MyTest:simple JUnit 5 Test()
    MethodSource [className = 'MyTest', methodName = 'simple JUnit 5 Test', methodParameterTypes = '']
    => Assertion failed:

assert 1 + 1 == 3
         |   |
         2   false

Stubs und Mocks

Groovy kann sowohl interpretiert als auch kompiliert ausgeführt werden. Seine bekanntesten Hilfmittel für Stubbing (StubFor) und Mocking (MockFor) können aber nur im dynamischen Kontext verwendet werden. Skripte und Tests, die wir direkt mit groovy SomethingTest starten, werden vorkompiliert und können sich deswegen diese dynamischen Features der Sprache nicht zunutze machen.

Stattdessen werden wir das Feature „Map Coercion“ benutzen, das uns erlaubt Maps mit dem Coercion Operator as zu Implementierungen von Interfaces zu machen. Dies funktioniert auch im kompilierten Kontext.

Unit Tests

Groovy Shell Scripting
Groovy Shell Scripting

Unit Tests sind Tests von Entwicklern für Entwickler. Sie laufen isoliert von der Außenwelt und wissen also nichts von Dateien, Netzwerk, Datenbanken oder – frei nach Robert Martin – anderen Implementierungsdetails aus der Außenwelt unseres Systems. Hier wird also nur das einzelne Unit und auch nur ein spezieller Usecase getestet.

In unserem Szenario wollen wir das Inkrementieren einer Zahl testen, die unser Skript aus der Named Pipe gelesen hat. Die reine Logik unseres Systems beschränkt sich also darauf, eine Zahl zu nehmen und die inkrementierte Zahl zurück zu liefern. Woher die Zahl kommt in diesem Moment, ist ihr egal. Das entkoppelt das Holen der Zahl vom Inkrementieren. Wir achten dadurch auf das Software Pattern des Single Responsibility Principle.

Eine weitere Komponente unseres Systems hat die Aufgabe, eine Nachricht (d.h. eine Zeile) aus der Named Pipe zu lesen und in eine Zahl umzuwandeln. Auch dafür werden wir einen isolierten Test schreiben.

Zum Schluss werden wir im letzten Abschnitt das ganze System in einem integrierten Test prüfen, also die Funktionalität über das Unit hinaus sicherstellen.

Incrementer

import org.junit.jupiter.api.*

class IncrementerTest {

    private def sut

    @BeforeEach
    void "setup"() {
        sut = new Incrementer()
    }

    @Test
    void "increment 1"() {
        assert sut.increment(1) == 2 
    }

    @Test
    void "increment 2"() {
        assert sut.increment(2) == 3 
    }

    @Test
    void "increment 3"() {
        assert sut.increment(3) == 4
    }
}

Auch TDD funktionert auf der Kommandozeile gut. Getrieben von unseren Tests sieht die Logik der Produktionsklasse zum Schluss so aus:

class Incrementer {
    public int increment(int number) {
        number + 1
    }
}

Ihr habt vielleicht bemerkt, dass ich hier keine Shebang an die Klassen geschrieben habe, obwohl dies möglich gewesen wäre. Diese schreibe ich normalerweise nur an den Einstiegspunkt unseres Systems, um diesen als solchen zu markieren. Den Test starte ich auf die traditionelle Weise mit

➜ groovy InkrementerTest.groovy
JUnit5 launcher: passed=3, failed=0, skipped=0, time=13ms

Deserializer

Der Deserialisierer soll eine Zeile aus der Pipe lesen, die Zeile in eine Zahl umwandeln und diese an den Inkrementierer weitergeben. Letzteres werden wir durch einen Interaktionstest gegen einen Mock verifizieren. Den Mock erzeugen wir durch die oben erwähnte Map Coercion. Er nimmt einfach das übergebene Argument an und speichert es ins Field receivedDeserialized.

import org.junit.jupiter.api.*

class DeserializerTest {

    private def receivedDeserialized

    private def incrementerMock
    private def sut

    @BeforeEach
    void "setup"() {
        incrementerMock = [increment: { int number -> 
            receivedDeserialized = number }] as Incrementer

        sut = new Deserializer(incrementerMock)
    }

    @Test
    void "deserialize 1 on one line"() {
        def line = "1\n"

        sut.deserialize(line)

        assert receivedDeserialized == 1
    }

    @Test
    void "deserialize 2 on one line"() {
        def line = "2\n"

        sut.deserialize(line)

        assert receivedDeserialized == 2
    }

    @Test
    void "deserialize 3 on one line"() {
        def line = "3\n"

        sut.deserialize(line)

        assert receivedDeserialized == 3
    }

    @Test
    void "deserialize 1 on one line without newline"() {
        def line = "1"

        sut.deserialize(line)

        assert receivedDeserialized == 1
    }

    @Test
    void "deserialize non-numbers does not call increment"() {
        def garbage = "lkajdfoiaer"

        sut.deserialize(garbage)

        assert !receivedDeserialized
    }

    @Test
    void "deserialize NULL does not call increment"() {
        sut.deserialize(null)

        assert !receivedDeserialized
    }
}

Der Deserialisierer sieht getrieben von unseren Tests so aus:

class Deserializer {

    private def incrementer

    Deserializer(Incrementer incrementer) {
        this.incrementer = incrementer
    }

    Integer deserialize(String line) {
        try {
            def deserialized = Integer.parseInt(line?.trim())

            return incrementer.increment(deserialized)
        }
        catch(NumberFormatException e) {
            System.err.println("Received malformed message: '${line}'")
        }
    }
}

Die Fehler geben wir auf System.err aus. Damit können wir sie falls nötig gesondert weiter verarbeiten. Sie landen also nicht in den Pipes, die wir mit dem System.out unseres Skripts verbinden.

Von Ende zu Ende

Producer

Zum Schluss testen wir einmal die gesamte Strecke. Hier zur Erinnerung nochmal der Producer für die Zahlen. Diesmal schreibt er direkt nach System.out und produziert dank BigInteger und Stream.iterate() ohne Überlauf einen potenziell unendlich langen Stream von Zahlen.

#!/usr/bin/env groovy
import java.util.stream.*

def autoFlush = true
def pipe = new PrintWriter(System.out, autoFlush)

def numbers = Stream.iterate(0G) { i ->
    i.add(1G)
}

numbers.each {
    pipe.println("${it}")
    sleep 1000
}

Da der Producer unendlich viele Zahlen produziert, ist es nicht möglich, ihn direkt zu testen. Außerdem verwenden wir nur wenige Methoden aus der Standardbibliothek. Wir verzichten daher auf einen Test und wenden uns der Logik des Consumers zu.

Consumer

Um den Consumer integriert zu testen, müssen wir seine Eingaben kontrollieren und seine Ausgaben verifizieren können.

Groovy hat die API von String um die Methode execute() erweitert. Diese startet einen Process, einen Kindsprozess unseres Skriptes, indem es den Befehl im String ausführt und eine Instanz von Process liefert.

Auf dieser Instanz können wir stdin und stdout setzen. Um in die Eingabe bequem schreiben zu können, verwenden wir wieder einen PrintStream. Die Ausgabe lesen wir mit der Methode readLines() aus dem Ausgabe-Stream des Prozesses. Auch diese Methode ist eine Erweiterung von Groovy.

import org.junit.jupiter.api.*

class PipeConsumerTest {

    @Test
    void "consumer increments text from stdin"() {
        def process = "./pipe_consumer.groovy".execute()
        def stdin = new PrintStream(process.getOutputStream(), true)
        def stdout = process.getInputStream()

        stdin.println("1")
        stdin.println("2")
        stdin.println("3")
        stdin.close()

        assert stdout.readLines() == ["2", "3", "4"]
    }
}

Nach dem Aufruf von execute() läuft der Prozess eigentlich schon. Da er aber keine Eingaben über sein stdin bekommt, passiert auch noch nichts und wir müssen uns nicht darum kümmern, den Prozess erst dann zu starten, wenn wir sein stdin und stdout gesetzt haben. Er blockiert einfach so lange, bis er Input bekommt – ein weiterer Vorteil des Arbeitens mit Pipes.

Etwas verwirrend sind die Bezeichnungen der Methoden: Um an das stdin des Prozesses zu kommen, muss man getOutputStream aufrufen. Die Blickrichtung hier ist aus der Sicht des aufrufenden Prozesses, statt aus der Sicht des Objekts, dessen Methode aufgerufen wird. Gleiches gilt in Gegenrichtung für stdout und getInputStream(). Wir hätten auch über die Properties outputStream und inputStream auf die Streams zugreifen können, aber das hätte die Verwirrung nur noch vergrößert.

Der Consumer selbst sieht so aus:

#!/usr/bin/env groovy
def deserializer = new Deserialisierer(new Inkrementer())

System.in.eachLine {
   deserializer.deserialize(it) 
}

Alles zusammen

Zum Schluss lassen wir den Producer und zwei Consumer einmal zusammen laufen. Das Ergebnis sieht folgendermaßen aus:

Producer und Consumer in Aktion. Ganz links der laufende Producer. In der Mitte und rechts zwei Consumer, die aus der Named Pipe lesen und die inkrementierten Zahlen ausgeben.

Fazit

Unsere Tests sind grün und wir können uns sicher sein, dass der Consumer so funktioniert wie er soll. Das haben wir sowohl in Isolation als auch integriert getestet.

Da Skripte von Groovy kompiliert werden, konnten wir einige dynamische Features für Stubbing und Mocking nicht einsetzen, aber Groovy bringt genügend andere Mittel mit, die auch kompiliert funktionieren.

Dank Pipes und FIFOs haben wir ein einfaches Mittel zur Interprozesskommunikation, für das wir sonst vielleicht eine Messaging-Lösung hätten benutzen müssen.

Zudem konnten wir durch das einheitliche Interface auch für integrierte Tests mit wenig Aufwand die Ein- und Ausgaben manipulieren und testen.

Verpasst nicht den nächsten Teil von Groovy Shell Scripting! Dort werden wir über Dependency Management für eure Skripte sprechen.

0 Kommentar

Tutorials und Top Posts

Gib uns Feedback

Diese Seite benutzt Cookies. Ein Akzeptieren hilft uns die Seite zu verbessern. Ok Mehr dazu