Blog

Einführung in Test::Class::Moose

15.12.2020 // Gregor Goldbach

Mit Test::Class::Moose können Entwickler die Mittel von Moose nutzen, um ihre Testsuite objektorientiert aufzubauen. In einem vorherigen Artikel habe ich gezeigt, wie diese Tests durch einen Testtreiber ausgeführt werden. In diesem Artikel zeigen ich an einem einfachen Beispiel die grundsätzliche Verwendung von Test::Class::Moose.

Traditionelle Perl-Tests

In Perl schreiben Entwickler seit Jahrzehnten Tests mit den Werkzeugen, die das Modul Test::More und seiner Verwandten zur Verfügung stellen. Hier ein Beispiel aus der Dokumentation dieses Moduls:

ok( $exp{9} == 81,                   'simple exponential' );
ok( Film->can('db_Main'),            'set_db()' );
ok( $p->tests == 4,                  'saw tests' );
ok( !grep(!defined $_, @items),      'all items defined' );

Die Testwerkzeuge wie ok sind einfache Funktionen, die einen erwarteten mit einem tatsächlich berechneten Wert vergleichen. Die mit diesen Werkzeugen erstellten Testsuites bestehen häufig aus prozedural geschriebenen Testskripten, die in einem Verzeichnis namens »t« liegen. Sie werden dann vom Programm prove oder einem geskripteten Testtreiber ausgeführt, der das Modul Test:: Harness verwendet.

Die so aufgebauten Testsuiten werden mit zunehmender Größe unübersichtlich. Zudem ist es nicht immer einfach, gezielt einzelne Funktionalitäten zu testen. Dies liegt zum Teil auch daran, dass Test::More keine Möglichkeit bietet, Tests zu feiner zu gruppieren und dann nur diese auszuführen.

Eine Abhilfe kann hier der objektorientierte Aufbau von Testsuites schaffen. Die Idee ist hierbei, einzelnen Funktionalitäten durch Testklassen auf Fehler überprüfen zu lassen. Hierfür gibt es seit vielen Jahren die Distribution Test::Class.

Um Tests feinkörniger auswählen zu können, als es mit Test::Class möglich ist, hat Curtis Ovid Poe einen ähnlichen Ansatz mit Moose umgesetzt: Test::Class::Moose.

Test::Class + Moose = Test::Class::Moose

Der Ansatz von Test::Class::Moose scheint recht einfach zu sein. Die eigentlichen Tests werden weiterhin mit Test::More geschrieben, Test::Class::Moosebietet nur einen Rahmen, um die Testsuite besser zu strukturieren, indem die Testsuite objektorientiert aufgebaut wird. Der getestete Code muss dafür nicht unbedingt objektorientiert sein.

Damit Test::Class::Moose genutzt werden kann, muss eine Testklasse von Test::Class::Moose abgeleitet werden.

Eine einfache Klasse testen

Eine beispielhafte Testklasse, die Test::Class::Moose und die Methode add der Klasse Calculator testet, sieht so aus:

package TestFor::Calculator;
use Test::Class::Moose;
with 'Test::Class::Moose::Role::AutoUse';

sub test_add {
    my $c = Calculator->new;

    subtest 'identity' => sub {
        my $n        = 42;
        my $expected = $n;
        my $got      = $c->add( $n, 0 );
        is $got, $expected;
    };
}

Die Datei liegt in t/lib/TestFor/Calculator.pm.

In der ersten Zeile folgen wir dem Beispiel aus der Dokumentation von Test::Class::Moose und legen als Namensraum TestFor::Calculator fest. Die zweite Zeile lädt Test::Class::Moose und erledigt damit die Hauptarbeit. Es sorgt nicht nur für die Ableitung der Testklasse, sondern bindet unter anderem auch die Pragmas strict und warnings sowie das Modul Test::Most ein. Es nimmt somit den Entwickler:innen viel Schreibarbeit ab und macht den Quelltext kürzer, übersichtlicher und einheitlicher.

Der eigentliche Test befindet sich in der Methode test_add und arbeitet mit dem Testwerkzeug is, das automatisch bereitgestellt wird. Alle Methoden einer Klasse, die mit test_ beginnen, werden als Testmethoden aufgefasst und in alphabetischer Reihenfolge ausgeführt.

Die dazugehörige Beispielklasse Calculator (lib/Calculator.pm) erhält die Methode add, um zwei Zahlen zu addieren:

package Calculator;

use Moose;
use v5.20;
use feature 'signatures';
no warnings 'experimental::signatures';

sub add ($self, $n, $m) {
  return $n+$m;
}

1;

Wir nutzen nun folgenden in meinem anderen Artikel beschriebenen Testrunner (t/run_test_class.t), um den Test laufen zu lassen:

use Test::Class::Moose::CLI;
Test::Class::Moose::CLI->new_with_options->run;

Der Testlauf sieht dann wie folgt aus:

$ prove -lv t/run_test_class.t
t/run_test_class.t ..
1..1
ok 1 - TestFor::Calculator {
    1..1
    ok 1 - test_add {
        # Subtest: identity
        ok 1 - identity {
            ok 1
            1..1
        }
        1..1
    }
}
ok
All tests successful.
Files=1, Tests=1,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.39 cusr  0.03 csys =  0.45 CPU)
Result: PASS

Tests ergänzen

Wir fügen nun in unsere Testklasse einen Test für eine neue Method sub ein, die zwei Zahlen voneinander abziehen soll:

sub test_sub {
    my $c = Calculator->new;

    subtest 'identity' => sub {
        my $n        = 42;
        my $expected = $n;
        my $got      = $c->sub( $n, 0 );
        is $got, $expected;
    };

    subtest 'negate' => sub {
        my $m        = 42;
        my $expected = -$m;
        my $got      = $c->sub( 0, $m );
        is $got, $expected;
    };
}

Wir implementieren nun die neue Methode ...

sub sub ($self, $n, $m) {
  return $n-$m;
}

... und rufen die Tests für diese Methode auf. Hier nutzen wir eine Eigenschaft vom Testrunner: Anders als bei der alleinigen Verwendung von Test::More können wir auch nur Teile einer Testklasse ausführen.

Es ist hier ein Auswahl von Testmethoden möglich, indem wir den Namen über die Option --methods angeben. Hinweis: Damit die Übergabe funktioniert, muss prove mit einem Doppeldoppelpunkt mitgeteilt werden, dass die folgenden Argument nicht ausgewertet, sondern an das aufgerufene Testprogramm übergeben werden soll:

$ prove -lv t/run_test_class.t :: --methods test_sub
t/run_test_class.t ..
1..1
ok 1 - TestFor::Calculator {
    1..1
    ok 1 - test_sub {
        # Subtest: identity
        ok 1 - identity {
            ok 1
            1..1
        }
        # Subtest: negate
        ok 2 - negate {
            ok 1
            1..1
        }
        1..2
    }
}
ok
All tests successful.
Files=1, Tests=1,  1 wallclock secs ( 0.02 usr  0.00 sys +  0.41 cusr  0.04 csys =  0.47 CPU)
Result: PASS

Da unsere schmalen Testmenge keine Fehler in der neuen Methode gefunden hat, rufen wir nun mutig alle Tests auf:

$ prove -lv t/run_test_class.t
t/run_test_class.t ..
1..1
ok 1 - TestFor::Calculator {
    1..2
    ok 1 - test_add {
        # Subtest: identity
        ok 1 - identity {
            ok 1
            1..1
        }
        1..1
    }
    ok 2 - test_sub {
        # Subtest: identity
        ok 1 - identity {
            ok 1
            1..1
        }
        # Subtest: negate
        ok 2 - negate {
            ok 1
            1..1
        }
        1..2
    }
}
ok
All tests successful.
Files=1, Tests=1,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.40 cusr  0.04 csys =  0.47 CPU)
Result: PASS

Die Einschränkung der auszuführenden Testmenge ist nicht nur auf Methodennamen beschränkt, sondern umfasst auch Testklassen. Mehr dazu kann in meinem Artikel zum Testrunner erfahren werden oder in der Dokumentation von Test::Class::Moose::CLI.

Reihenfolge der Testausführung ändern

Test::Class::Moose führt Tests mit dem vorgestellten Testtreiber stets in der gleichen Reihenfolge aus. Der Treiber lädt zunächst alle Testklassen und führt dann in alphabetischer Reihenfolge der Klassen die Testmethoden ebenfalls in alphabetischer Reihenfolge aus. Damit sind Testläufe reproduzierbar und ihre Ergebnisse vergleichbar.

Um verdeckte Abhängigkeiten von Testmethoden zu finden, können diese auch in zufälliger Reihenfolge ausgeführt werden:

$ prove -lv t/run_test_class.t :: --randomize-methods
t/run_test_class.t ..
1..1
ok 1 - TestFor::Calculator {
    1..2
    ok 1 - test_sub {
        # Subtest: identity
        ok 1 - identity {
            ok 1
            1..1
        }
        # Subtest: negate
        ok 2 - negate {
            ok 1
            1..1
        }
        1..2
    }
    ok 2 - test_add {
        # Subtest: identity
        ok 1 - identity {
            ok 1
            1..1
        }
        1..1
    }
}
ok
All tests successful.
Files=1, Tests=1,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.39 cusr  0.04 csys =  0.46 CPU)
Result: PASS

Auch zu --randomize-methods gibt es ein Gegenstück für Testklassen: --randomize-classes.

Test2 anstelle von Test::More verwenden

Eingangs habe ich erwähnt, dass Test::Class::Moose die Infrastruktur für den Aufbau einer Testsuite zur Verfügung stellt. Die eigentlichen Tests schreiben Entwickler weiterhin mit Test::More.

Seit mehreren Jahren kann Test2 als Ersatz für Test::More produktiv genutzt werden. Um diese Distribution zu nutzen, muss nur wenig geändert werden.

Im Normalfall bindet das Ableiten von Test::Class::Moose auch gleich das Modul Test::Most ein, das wiederum Test::More und verwandte Module einbindet. Um dies zu unterbinden und Test2 zu verwenden, muss folgende Zeile beim Ableiten von Test::Class::Moose verwendet werden:

use Test::Class::Moose bare => 1;

Die verwendeten Testwerkzeuge müssen dann manuell eingebunden werden. In unserem Beispiel brauchen wir Test2::Tools::Compare für die Testwerkzeuge zum Vergleichen und Test2::Tools::Subtests für die Aufteilung in Untertests:

use Test::Class::Moose bare => 1;
use Test2::Tools::Compare;
use Test2::Tools::Subtest qw(subtest_buffered);

Die Anleitung von Test::Class::Moose zeigt in einem Beispiel, wie man eine eigene Basisklasse für Testklassen definieren kann, um sich diese Schreibarbeit zu ersparen.

Untertests funktionieren in Test2 etwas anders, da zwei Werkzeuge bereitgestellt werden. Warum ist das so?

Test::More gibt Entwickler:innen subtest an die Hand. Diese Funktion gibt Text sofort auf der Konsole aus, sobald er von aufgerufenen Code erzeugt wird. Dieses Verhalten bietet Test2 mit dem Werkzeug subtest_streamed. Bei nebenläufig ausgeführtem Code kann dies zu unübersichtlicher Ausgabe führen. Daher bietet Test2 auch die Funktion subtest_buffered an, die die Ausgabe puffert und in der korrekten Reihenfolge ausgibt.

Wir müssen also unsere beispielhafte Testklasse etwas umschreiben:

package TestFor::Calculator;

use Test::Class::Moose bare => 1;
use Test2::Tools::Compare;
use Test2::Tools::Subtest qw(subtest_buffered);

with 'Test::Class::Moose::Role::AutoUse';

sub test_add {
    my $c = Calculator->new;

    subtest_buffered 'identity' => sub {
        my $n        = 42;
        my $expected = $n;
        my $got      = $c->add( $n, 0 );
        is $got, $expected;
    };
}

sub test_sub {
    my $c = Calculator->new;

    subtest_buffered 'identity' => sub {
        my $n        = 42;
        my $expected = $n;
        my $got      = $c->sub( $n, 0 );
        is $got, $expected;
    };

    subtest_buffered 'negate' => sub {
        my $m        = 42;
        my $expected = -$m;
        my $got      = $c->sub( 0, $m );
        is $got, $expected;
    };
}

Wenn wir die Tests wie bisher mit prove aufrufen, werden wir keine Änderung feststellen. Die Ausgabe ist die gleiche, allerdings laufen die Tests nun unter Test2.

Um eine deutliche Änderung zu sehen, können wir die Tests mit yath, dem Gegenstück von prove unter Test2, aufrufen:

$ yath -lv t/run_test_class.t

** Defaulting to the 'test' command **

( LAUNCH )  job  1    t/run_test_class.t
[  PLAN  ]  job  1    Expected assertions: 1
[  PASS  ]  job  1  +~TestFor::Calculator
[  PLAN  ]  job  1    | Expected assertions: 2
[  PASS  ]  job  1    +~test_add
[  PASS  ]  job  1    | +~identity
[  PASS  ]  job  1    | | + <UNNAMED ASSERTION>
[  PLAN  ]  job  1    | | | Expected assertions: 1
            job  1    | | ^
[  PLAN  ]  job  1    | | Expected assertions: 1
            job  1    | ^
[  PASS  ]  job  1    +~test_sub
[  PASS  ]  job  1    | +~identity
[  PASS  ]  job  1    | | + <UNNAMED ASSERTION>
[  PLAN  ]  job  1    | | | Expected assertions: 1
            job  1    | | ^
[  PASS  ]  job  1    | +~negate
[  PASS  ]  job  1    | | + <UNNAMED ASSERTION>
[  PLAN  ]  job  1    | | | Expected assertions: 1
            job  1    | | ^
[  PLAN  ]  job  1    | | Expected assertions: 2
            job  1    | ^
            job  1    ^
( PASSED )  job  1    t/run_test_class.t
(  TIME  )  job  1    Startup: 0.41443s | Events: 0.01167s | Cleanup: 0.01234s | Total: 0.43844s

                                Yath Result Summary
-----------------------------------------------------------------------------------
     File Count: 1
Assertion Count: 9
      Wall Time: 0.65 seconds
       CPU Time: 0.83 seconds (usr: 0.12s | sys: 0.02s | cusr: 0.60s | csys: 0.09s)
      CPU Usage: 127%
    -->  Result: PASSED  <--

Durch wenige Anpassungen in den Tests kann so die moderne Testinfrastruktur von Perl genutzt werden.

Zusammenfassung

Mit der Distribution Test::Class::Moose können Entwickler:innen ihre Testsuite übersichtlich strukturieren. Entwickler:innen können diesen Rahmen nutzen, um mit den Testwerkzeugen aus Test::More und dem neueren Test2 ihre Tests feinkörnig auszuführen.

In diesem Artikel konnte ich nur einen kurzen Überblick geben. Mehr zur Motivation und den Möglichkeiten von Test::Class::Moose hat Dave Rolsky 2016 in einem Vortrag gezeigt.

Weiterlesen


Permalink:

Testtreiber für Test::Class::Moose

23.11.2020 // Gregor Goldbach

Test::Class::Moose hilft beim Organisieren von Tests dadurch, dass objektorientiertes Schreiben von Tests ermöglicht wird.

Mit der Klasse Test::Class::Moose::Runner können diese Tests parametrisiert ausgeführt werden.

Die Klasse Test::Class::Moose::CLI unterstützt beim Schreiben eines Testtreibers, um Tests komfortabel auf der Kommandozeile auszuführen.

Organisation von Testsuites

Test::Class::Moose bietet objektorientierte Hilfsmittel, um eine Testsuite zu organisieren.

Tests können über Klassen und Methoden verteilt und mit Tags versehen ein, um sie etwa als langsam oder für bestimmte Funktionalitäten auszuzeichnen. Ein Beispiel:

package TestFor::My::Test::Module;
use Test::Class::Moose;

use My::Module;
 
sub test_construction {
    my $test = shift;
    my $obj  = My::Module->new;
    isa_ok $obj, 'My::Module';
}

sub test_database : Tags( database )            { ... }
sub test_network  : Tests(7) Tags( online api ) { ... }

Der Aufruf dieser Tests erfordert das Laden der Testklassen und den Aufruf der Methoden, die die eigentlichen Tests enthalten.

In der Praxis wird die vollständige Testsuite auf CI-Systemen ausgeführt. Bei der Fehlersuche führen Entwickler:innen in der Regel jedoch nur jene Tests aus, die für die fehlerhafte Komponente relevant sind.

Um diese Testauswahl effizient zu ermöglichen und bei der Fehlersuche flexibel auswählen zu können, ist ein Kommandozeilenwerkzeug hilfreich: der Testtreiber.

Genau diesen liefert Test::Class::Moose::CLI.

Der Testtreiber

Der Testtreiber selbst ist ein Testprogramm wie jedes andere, das mit prove aufgerufen werden kann. Er umfasst in der Minimalfassung nur zwei Zeilen:

use Test::Class::Moose::CLI;

Test::Class::Moose::CLI->new_with_options->run;

Wenn diese in der Datei t/tcm.t abgelegt werden, dann kann er mit prove aufgerufen werden:

> prove -lv t/tcm.t

Es werden dann alle Testklassen geladen, die sich in oder unterhalb des Verzeichnisses t/lib befinden. In alphabetischer Reihenfolge der Paketnamen werden dann die Testmethoden ebenfalls ist alphabetischer Reihenfolge ausgeführt.

Eine lebendige Testsuite hat üblicherweise eine Größe, die eine Ausführung aller Tests bei der Fehlersuche unpraktisch erscheinen lässt. Entwickler:innen sind bei der Fehlersuche typischerweise vor allem daran interessiert, eine bestimmte Menge von Tests schnell auszuführen.

Die Menge der ausgeführten Tests kann mit diesem einfachen Testtreiber über einige Parameter mitgeteilt werden. Sollen nur die Tests einer bestimmten Testklasse aufgerufen werden, so kann diese über das Argument --classes angegeben werden:

> prove -lv t/tcm.t :: --classes Foo

Diese Einschränkung funktioniert auch für Methoden:

> prove -lv t/tcm.t :: --methods Bar

Es gibt auch einen Parameter für die Einschränkung nach den eingangs erwähnten Tags:

> prove -lv t/tcm.t :: --tags Baz

Damit würden alle Methoden in allen Testklassen ausgeführt, die mit dem Tag Baz versehen sind.

Die erwähnten Optionen können mehrfach angegeben und mit einem vorangestellten exclude- negiert werden:

> prove -lv t/tcm.t :: --classes Foo --classes Foo2 --exclude-classes NoFoo \
  --tags fast --exclude-tags db

Neben der Menge der ausgeführten Tests kann auch die Reihenfolge ihrer Ausführung geändert werden. Die Vorgabe der Ausführung nach alphabetischer Sortierung kann durch eine zufällige Reihenfolge ersetzt werden. Sinnvoll kann dies sein, um eine Stichprobe zu ziehen, ob die Testmethoden isoliert voneinander arbeiten – wenn hier ein Test fehlschlägt, dann wird ein vermeintlicher lokaler Zustand aus einer Testmethode in einer anderen Testmethode verwendet.

Im folgenden Beispiel werden alle Testmethoden aller Testklassen in jeweils in zufälliger Reihenfolge ausgeführt:

> prove -lv t/tcm.t :: –randomize–classes –randomize-methods

Beide Optionen sind auch einzeln verwendbar.

Die hier vorgestellten Optionen sind nur ein Teil der vorhandenen, die der Dokumentation von Test::Class::Moose::CLI beschrieben sind.

Zusammenfassung

Gerade während der Fehlersuche ist ein komfortabler Aufruf von Tests hilfreich, um schnell Rückmeldung von den Tests zu erhalten.

Für die auf Test::Class::Moose basierenden Tests bietet das vorgestellte Modul eine Grundlage für einen eigenen Testtreiber.

Bereits über den vorgestellten minimalen Treiber erhalten Entwickler:innen eine flexible Möglichkeit, die Menge und Reihenfolge der auszuführenden Tests zu beeinflussen.


Permalink:

Akzeptanztests mit Test::BDD::Cucumber

01.10.2020 // Gregor Goldbach

Akzeptanztests sind Tests, die als natürlichsprachliche Szenarien aus Anwendersicht formuliert werden. Sie dienen als lebendige Dokumentation eines Systems und können das gemeinsame Verständnis im Team herstellen. Test::BDD::Cucumber ist eine Distribution, mit der Fehler in Perl-Anwendungen durch die Ausführung von Akzeptanztests gefunden werden können.

Klassische Perl-Tests: von innen nach außen

In Perl sind Unit-Tests weit verbreitet. Oftmals sind diese als einzige Testart nach außen sichtbar, weil sie in CPAN-Distributionen ausgeliefert werden. Mit der CPAN Testers Matrix gibt es eine transparente Möglichkeit, die Ergebnisse der Unit-Tests einzusehen.

Integrations- oder Systemtests sind bei weitem nicht so bekannt in der Perl-Welt, weil es hierfür weder ein einheitliches Framework noch eine ähnlich transparente Plattform gibt.

Unit-Tests arbeiten »von innen nach außen« (»inside-out«): Die Units bilden die untersten Bausteine, aus denen Anwendungen erstellt werden. Unit-Tests sind gut geeignet für die testgetriebende Entwicklung von Units und üblicherweise schnell in der Ausführung. Deswegen ist es praktisch möglich und ratsam, eine hohe Testabdeckung durch eine Vielzahl von Testfällen zu erreichen.

Akzeptanztests: von außen nach innen

In der Softwaretechnik gibt es die Disziplin des »behaviour driven development« (BDD), in der Akzeptanztests ein Bestandteil sind.

Diese Art von Systemtests heißt so, weil mit ihnen die Bedingungen geprüft werden, unter denen ein Anwender die Umsetzung der Software akzeptiert. Sie sind natürlichsprachlich aus Sicht des Anwenders formuliert.

Da Anwender in Funktionalitäten denken, werden Akzeptanztests pro Feature geschrieben. Sie bestehen aus Szenarien, die exemplarisch die Verwendung der Software im Rahmen eines Features beschreiben.

Es wird das vollständig integrierte System »von außen nach innen« (»outside-in«) getestet.

Da das vollintegrierte System getestet wird, sind diese Test langsam. Es werden daher nur einzelne bespielhafte Fälle – die Szenarien – getestet.

Akzeptanztests passen gut zu testgetriebener Entwicklung einer Anwendung. In dieser Vorgehensweise spezifiziert man erst das Verhalten der Software aus Sicht des Anwenders in Szenarien und implementiert anschließend das Feature. Die Szenarien helfen beim Konzentrieren auf das Wesentliche eines Features.

Beispiel

Wie sieht nun ein solcher Akzeptanztest aus? Das folgende Beispiel zeigt ein Szenario eines erfolgreichen Logins.

Feature: Login
  Scenario: User logs in providing correct credentials
    Given I am an existing user
    When I log in providing the correct password
    Then the login succeeds
    And I am at the dashboard page

Szenarien werden nach dem Schema »Arrange, Act, Assert« (AAA) mit der Given-When-Then-Schablone geschrieben. Das Szenario beschreibt aus der Sicht der Benutzers die wesentliche Funktionalität: Nach einem erfolgreichen Login wird der Benutzer auf sein Dashboard geleitet.

Der »Given«-Schritt entspricht »Arrange«, in dem die Voraussetzungen für den Test geschaffen werden. In diesem Fall wird das getestete System so verändert, dass ein Benutzer existiert.

Der »When«-Schritt entspricht »Act«. Hier wird die zu testende Funktionalität ausgeführt.

Im abschließenden »Then«-Schritt (»Assert«) werden die erwarteten Ergebnisse geprüft.

Technische Umsetzung mit Test::BDD::Cucumber

Die Ausführung solcher Szenarien kann in Perl mit einer Reihe von Distributionen erfolgen. Die ausgereifteste ist Test::BDD::Cucumber. Die Akzeptanztests werden standardmäßig im Verzeichnis t/features abgelegt. Szenarien eines Features werden in einer Datei beschrieben, die auf .feature endet.

Die einzelnen Schritte werden als Zeichenketten mit regulären Ausdrücken geprüft. Welche dies sind, legen die Entwickler über Subroutinenaufrufe fest. Diese werden in allen Dateien im Verzeichnis t/features/step_definitions erwartet.

Für unser oben gezeigtes Szenario könnte die Deklaration der einzelnen Schritte wie folgt aussehen:

Given qr{there is an existing user} => sub {
    my $context = shift;
    
    # Benutzer anlegen
    ...
};

When qr{I log in providing the correct password} => sub {
    my $context = shift;
    
    # Anmeldung mit korrektem Passwort durchführen
    ...
};

Then qr{the login succeeds} => sub {
    my $context = shift;
    
    # prüfen, ob Login erfolgreich war
    ...
};

Then qr{I am at the dashboard page} => sub {
    my $context = shift;
    
    # prüfen, ob Dashboard angezeigt wird
    ...
};

Jeder Schritt wird durch diese regulären Ausdrücke geprüft. Bei einer Übereinstimmung wird der entsprechende Code ausgeführt.

Wie im Beispiel zu sehen ist, wird an die Subroutine der Kontext übergeben, in dem der Code ausgeführt wird. Über diesen Kontext können Informationen zwischen den Schritten ausgetauscht werden, ohne auf globale Zustände zugreifen zu müssen. In unserem Beispiel würde hier zum Beispiel der angelegte Benutzer übergeben werden, so dass Name und Passwort nicht fest im Code verdrahtet werden müssen.

Der jeweilige Then-Schritt prüft den erwarteten Zustand mit den aus Test::More bekannten Werkzeugen.

Die Akzeptanztests werden mit dem Werkzeug pherkin in der Shell aufgerufen. Bei der Ausführung eines Test werden alle Schritte ausgegeben. Fehler werden laut und deutlich in der Konsole gemeldet:

$ pherkin -Ilib t/features/login.feature 
  Feature: Login
    Scenario: User logs in providing correct credentials
      Given I am an existing user
      When I log in providing the correct password
      Then the login succeeds
      step defined at t/features/login.feature line N.
              ok - Starting to execute step: the login succeeds
              not ok 1
              #   Failed test at ...

In den »Then«-Schritten können alle Test::Builder-basierten Testwerkzeuge verwendet werden, die die Entwickler auch sonst in ihren Unit-Tests einsetzen.

Die obigen Beispiele können nur skizzenhaft das Vorgehen zeigen. Die Test::BDD::Cucumber-Distribution enthält einige lauffähige Beispiele, die konkret die Funktionalität der Distribution zeigen.

Vorteile von Akzeptanztests

Durch das Verwenden von Akzeptanztests ist das Team gezwungen, sich aus der Sicht der Benutzer der Implementierung zu nähern. Dadurch wird häufig deutlich, dass das Wesentliche der Umsetzung einer Software in der Regel nicht in den grafischen Details liegt, sondern vielmehr in der Funktionalität, die der Benutzer erwartet.

Das Team formuliert die Szenarien in einer allgemeinen Sprache (»ubiquitäre Sprache«) der Anwendungsdomäne. Da dieses bereits bekannte Vokabular verwendet wird, kann das gemeinsame Verständnis über die Funktionsweise der Software erhöht werden. Da die Tests zudem ausführbar sind, erhält das Team so eine ausführbare, lebendige Dokumentation des tatsächliche funktionierenden Systems.

Akzeptanztests testen die lauffähige Software mit all ihren Komponenten und nicht nur einzelne Teile. Sie sind daher sehr gut dazu geeignet, um nach Abschluss von Unit- und Integrationstests Fehler in der Zusammenarbeit aller Softwarebausteine einer Anwendung zu finden.

Abschließend führe ich noch einige bewährte Vorgehensweisen auf, um diese Vorteile tatsächlich nutzen zu können.

Best Practices

  • Szenarien sollen allgemein formuliert werden und keine Details erwähnen, da die Tests sonst sehr brüchig sind.
  • Szenarien sollen nur exemplarisch formuliert werden, da Akzeptanztests langsam sind. Es sollen nicht alle Testfälle abgedeckt werden; hierfür sind Unit-Tests besser geeignet.
  • Die Szenarien sollen in dem im Team verwendeten Vokabular formuliert werden, um darüber das gemeinsame Verständnis zu erhöhen.
  • Das Team sollte möglichst früh mit Akzeptanztests beginnen, da darüber schnell ein Prototyp der Anwendung erstellt werden kann und so testbarer Code entsteht.
  • Szenarien sollen aus Benutzersicht geschrieben werden. Darin wird die Software wie durch einen Benutzer bedient:
    • Wenn die getestete Software ein REST-Server, wird ein REST-Client verwendet.
    • Wenn die getestete Software eine Webanwendung, wird ein Browser ferngesteuert.
    • Wenn die getestete Software ein Kommandozeilenwerkzeug, dann wird dieses wirklich aufgerufen und STDOUT und STDERR ausgewertet.
  • Es sollen keine Komponente gemockt werden, sondern die Anwendung soll in einer Testumgebung laufen.

Zusammenfassung

Das Testen mit Akzeptanztests erfordert im Team einiges Umdenken im Vergleich zum Testen mit Unit-Tests. Es wird aber damit belohnt, dass im Ergebnis eine lebendige und ausführbare Dokumentation des Systems vorliegt. Diese Dokumentation wurde mit einem gemeinsamen Vokabular verfasst, das das gemeinsame Verständnis ermöglicht.

Test::BDD::Cucumber ist eine Distribution, mit der Akzeptanztests in Perl geschrieben werden können. Die Distribution kann verwendet werden, um nach der erfolgreichen Ausführung von Unit-Tests Fehler im voll integrierten System zu finden.

Weiterlesen


Permalink:

Test::Perl::Critic::Progressive – Codier-Richtlinien Schritt für Schritt durchsetzen

25.08.2020 // Gregor Goldbach

Codier-Richtlinien haben als Ziel, die Formulierung von Code zu vereinheitlichen und ihn dadurch zu verbessern. Wenn sie bei einer bestehenden Code-Basis eingeführt werden, gibt es in der Regel zu Beginn viele Verstöße. Test::Perl::Critic::Progressive ist ein Werkzeug, um die Richtlinien für Perl-Code schrittweise durchzusetzen.

Die Einhaltung von Richtlinien mit perlcritic prüfen

Das Werkzeug perlcritic ist seit vielen Jahren das Standardwerkzeug, um Perl-Code statisch zu prüfen. Eine statische Prüfung wird im Gegensatz zur dynamischen Prüfung vor der Ausführung durchgeführt; es bedeutet also, dass der Code untersucht wird, ohne ihn auszuführen. Mit perlcritic kann so die Einhaltung von Codier-Richtlinien festgestellt werden.

Das folgende Beispiel zeigt einen Aufruf von perlcritic, der eine Datei auf Einhaltung der Richtlinie CodeLayout::RequireTidy prüft und einen Verstoß meldet:

$ perlcritic -s CodeLayout::RequireTidy lib/App/perldebs.pm
[CodeLayout::RequireTidyCode] Code is not tidy at lib/App/perldebs.pm line 1.

Durch die Einhaltung von Richtlinien kann eine Team unter anderem Folgendes erreichen:

  • Das Verständnis über den Code wird verbessert, da er einheitlich geschrieben ist. Die Pflege und Erweiterung wird dadurch vereinfacht.
  • Mitarbeiter können besser und schneller eingearbeitet werden, da die Richtlinien eine Hilfestellung zur Codierpraxis geben.
  • Die Verwendung unsicherer Sprachkonstrukte kann aufgedeckt werden. Dadurch werden Sicherheitsprobleme verhindert.

Prüfen von Richtlinien als Test

Die Ablauflogik des Kommandozeilenwerkzeugs perlcritic kann über das\ Modul Test::Perl::Critic in die Testsuite eingebunden werden. Verstöße gegen die Richtlinien können so recht einfach aufgedeckt werden:

use Test::Perl::Critic;
all_critic_ok();

Eine Organisation erstellt selten zunächst ihre Richtlinien und entwickelt dann erst die Software anhand dieser Richtlinien. In der Regel ist der umgekehrte Fall der Normalfall: Eine existierende Code-Basis ist ohne Richtlinien geschrieben worden und die Organisation erhofft sich durch Durchsetzung der Richtlinien beispielsweise die oben genannten Vorteile.

Wenn nun bei vorhandener Code-Basis Richtlinien eingeführt werden, gibt es üblicherweise eine Reihe von Verstößen, da der Code vorher beliebig formuliert werden konnte. Die Entwickler sehen sich nun mit dem Problem konfrontiert, dass sie sich einerseits an die Richtlinien halten wollen oder sogar müssen, andererseits der existierende Code nicht zusätzlich zum Tagesgeschäft kurzfristig umgeschrieben werden kann.

Wie kann dieses Problem gelöst werden?

Iterative Verbesserung

Das Modul Test::Perl::Critic::Progressive kann hier eine technische Hilfestellung bieten. Es ist hiermit möglich, bei einer beliebigen Anzahl von Verstößen diese Schritt für Schritt zu beheben. Die Code-Basis kann so iterativ in eine Form überführt werden, die den Richtlinien entspricht.

Wie arbeiten dieses Modul nun?

Test::Perl::Critic::Progressive arbeitet ähnlich wie Test::Perl::Critic. Es verwendet die Prüflogik von perlcritic und sammelt beim ersten Aufruf die Anzahl der Verstöße jeder einzelnen Richtlinie. Die Sammlung wird in der Datei .perlcritic-history als Hash abgelegt:

$VAR1 = [
          {
            'Perl::Critic::Policy::CodeLayout::RequireTidy' => 10,
            'Perl::Critic::Policy::BuiltinFunctions::ProhibitStringyEval' => 85,
            'Perl::Critic::Policy::BuiltinFunctions::RequireGlobFunction' => 0,
            'Perl::Critic::Policy::ClassHierarchies::ProhibitOneArgBless' => 2,
            ...
          }
        ];

Bei jedem weiteren Aufruf wird diese Sammlung erneut erstellt. Beide Sammlungen werden nun in einem Test miteinander verglichen. Dieser Test schlägt fehl, wenn die Anzahl von Verstößen bei einer Richtlinie angestiegen ist:

CodeLayout::RequireTidy: Got 54 violation(s).  Expected no more than 36.

Mit Test::Perl::Critic::Progressive kann man also sicherstellen, dass die Anzahl der Verstöße gegen die festgelegten Richtlinien nicht ansteigt.

Neben dieser technischen Lösung muss es noch organisatorische Änderungen geben, damit der Code auch wirklich verbessert wird. In der Regel bedeutet dies, dass den Entwicklern Zeit für die Verbesserung gegeben werden muss.

Nur dann, wenn das Team neben den technischen Lösung des Auffindens von Problemen zusätzlich noch Verfahren umsetzt, um den Code anhand der Empfehlungen der Richtlinien umzuformulieren, können die Entwickler schrittweise die Verstöße reduzieren. Sie werden dann nach und nach die Code-Basis überarbeiten und im Sinne der Richtlinien verbessern.

Best Practice

  • Grundsätzlich ist Test::Perl::Critic::Progressive durch seine iterative Arbeitsweise gut geeignet, in agilen Entwicklungsteams eingesetzt zu werden.
  • Der Basissatz von Richtlinien, die bei der Installation von perlcritic vorhanden sind, sollte auf keinen Fall vollständig eingebunden werden, da er veraltet ist.
  • Es ist ratsam, mit wenigen Richtlinien zu beginnen. Die Entwickler werden so durch schnelle Erfolgserlebnisse motiviert und erfahren den praktischen Nutzen von Richtlinien. Außerdem sollten die Entwickler bei der Auswahl der Richtlinien beteiligt werden, um die Akzeptanz zu erhöhen.
  • Das Team sollte entscheiden, ob es zunächst die Verstöße einzelner Richtlinien auf 0 verringern möchte oder ob einzelne Dateien vollständig bereinigt werden sollen.
  • Wenn das Team problematische Codierweisen entdeckt hat, sollte es für die Aufdeckung dieser Schreibweisen auf CPAN nach ergänzenden Richtlinien suchen oder eigene Richtlinien entwickeln.
  • Nach Möglichkeit sollte Test::Perl::Critic::Progressive in Versionskontrollsystemen so eingesetzt werden, dass abgelehnter Code nicht eingecheckt werden kann, um dem Entwickler schnellstmögliche Rückmeldung zu geben.
  • Unterstützend kann ein mit Test::Perl::Critic::Progressive durchgeführter Test in einem CI-System zu Beginn der Testsuite laufen, um dem Entwickler schnell Rückmeldung zu geben.
  • Test::Perl::Critic::Progressive verwendet Perl::Critic und somit die Distribution PPI. Letztere ist bei großen Codemengen langsam und führt zu langen Feedback-Zyklen. Test::Perl::Critic::Progressive sollte in einem CI-System daher nicht auf allen Quellen angewendet werden. Ist das nicht möglich, so sollte die Verwendung parallel zur restlichen Testsuite ablaufen.

Zusammenfassung

Test::Perl::Critic::Progressive ist ein Werkzeug, um Codier-Richtlinien schrittweise durchzusetzen. In Verbindung mit einem Vorgehen zum Beheben von Verstößen gegen diese Richtlinien kann es eingesetzt werden, um eine Code-Basis im Team nach und nach zu verbessern.

Links


Permalink:

Test2::V0

01.08.2020 // Gregor Goldbach

Perl hat eine lange Tradition im Testen. Klassischerweise werden Tests mit Hilfe der Distribution Test::More geschrieben und mit prove ausgeführt. Test::More wird jedoch seit längerem schon vielen Anforderungen nicht mehr gerecht.

Test2 ist eine Neuentwicklung eines Test-Frameworks, das diese Anforderungen erfüllt und für zukünftige Erweiterungen gerüstet ist. In diesem Artikel stellen wir dieses Framework vor.

Einleitung

In den letzten Jahren haben sich die Anforderungen an ein Test-Framework geändert. Die Verwendung von Test::More ist teilweise problematisch. Einige Probleme möchte ich hier aufführen:

  • Tests und das sie ausführende Programm kommunizieren über das Textprotokoll TAP (Test Anything Protocol). Dieses Protokoll ist eher für Maschinen als Menschen gemacht und teilweise schwer zu lesen. Zudem unterstützt es Subtests nicht vollständig.
  • Die Ausgabe von TAP ist in Tests mit normaler Ausgabe vermischt. Diagnosemeldungen können nur schlecht mit Testausgaben synchronisiert werden.
  • Erweiterungen sind nur schwer möglich, sodass die Weiterentwicklung mit neuen Features praktisch nicht stattfindet.
  • Best Practices im Schreiben von modernem Perl-Code sind boilerplate und tribal knowledge. Praktisch immer verwendete Argumente beim Aufruf von Tests müssen immer wieder angegeben werden und sind nicht voreingestellt.* *Das macht die Verwendung umständlich.
  • Das Ergebnis eines Testlaufs ist als Text in der Konsole zu sehen. Nur dort. Untersuchungen des Testlaufs kann man nur durch Inspektion dieser Ausgabe vornehmen. Es ist nicht möglich, das Ergebnis eines Testlaufs auf einem anderen Rechner zu untersuchen.

Test2 soll alles besser machen und erweiterbar sein. Zudem bietet es erweiterte Funktionalitäten und wird aktiv weiterentwickelt. Der Autor Chad Granum stellt seit 2015 wesentliche Neuentwicklungen auf der Perl Konferenz vor.

Test2 ist praktisch gut ausgreift, sodass Teile von Test::More dieses neue Framework bereits seit Jahren verwenden.

TAP und Test::More

TAP ist das Test Anything Protocol, das Testprogramme und deren Aufrufer verbindet. Es sind in der Ausgabe von Testprogrammen die Zeilen, die mit # beginnen. Sie werden von den Funktionen, die in den auf Test::Builder basierten Modulen implementiert sind, auf STDOUT ausgegeben. Außerdem gibt es Funktionen, die Diagnosemeldungen auf STDERR ausgeben.

Der Aufrufer von Testprogrammen ist bei Perl bisher in der Regel prove, das Test::Harness (Test-Harnisch) zum Parsen von TAP verwendet und bei Bedarf STDOUT und STDERR teilweise oder ganz ausgibt.

Die Tests werden mit Test::More implementiert, das wiederum auf Test::Builder aufsetzt. Große Tests können in kleinere Subtests unterteilt werden, die dann im TAP etwas eingerückt dargestellt werden.

Im Wesentlichen werden hier von dem aufrufenden prove Textausgaben verarbeitet.

Vorteile von Test2

Test2 bietet eine Reihe von Vorzügen, die ich hier nur in Teilen ausführen kann.

Das Design ist mit den Erfahrungen der letzten 30 Jahre Perl gewählt worden und macht Erweiterungen einfacher möglich. Wegen der einfacheren Arbeit wird Test2 im Gegensatz zu Test::Harness weiterentwickelt. Ein neueres Feature ist zum Beispiel das Setzen von Timeouts, nach dessen Überschreitung ein Test abgebrochen wird.

Die Standard-Testwerkzeuge wurden behutsam sinnvoll ergänzt, andere Module werden dadurch überflüssig. is und like können nun zum Beispiel auch Datenstrukturen vergleichen. Dafür musste bisher ein eigenes Testmodul geladen werden.

Ein kurzes Beispiel:

use Test2::V0;

my $some_hash = { a => 1, b => 2, c => 3 };

is(
    $some_hash,
    { a => 1, b => 2, c => 4 },
    "The hash we got matches our expectations"
);

done_testing;

Wenn man dieses Testprogramm mit dem prove-Ersatz yath aufruft, erhält man folgende Ausgabe:

$ yath test2-tools-compare-is.t 

** Defaulting to the 'test' command **

[  FAIL  ]  job  1  + The hash we got matches our expectations
[  DEBUG ]  job  1    test2-tools-compare-is.t line 5
(  DIAG  )  job  1    +------+-----+----+-------+
(  DIAG  )  job  1    | PATH | GOT | OP | CHECK |
(  DIAG  )  job  1    +------+-----+----+-------+
(  DIAG  )  job  1    | {c}  | 3   | eq | 4     |
(  DIAG  )  job  1    +------+-----+----+-------+
(  DIAG  )  job  1    Seeded srand with seed '20200721' from local date.
( FAILED )  job  1    test2-tools-compare-is.t
< REASON >  job  1    Test script returned error (Err: 1)
< REASON >  job  1    Assertion failures were encountered (Count: 1)

The following jobs failed:
+--------------------------------------+--------------------------+
| Job ID                               | Test File                |
+--------------------------------------+--------------------------+
| 3DC85D18-CB27-11EA-A9FD-3FD6B4ADC425 | test2-tools-compare-is.t |
+--------------------------------------+--------------------------+

                                Yath Result Summary
-----------------------------------------------------------------------------------
     Fail Count: 1
     File Count: 1
Assertion Count: 1
      Wall Time: 0.30 seconds
       CPU Time: 0.49 seconds (usr: 0.12s | sys: 0.03s | cusr: 0.27s | csys: 0.07s)
      CPU Usage: 165%
    -->  Result: FAILED  <--

Im Gegensatz zum alleinigen TAP kann Test2 beliebige Ausgaben erzeugen, die man als Plugins nachrüsten kann. TAP ist nur eine Variante.

Das Programm prove wurde durch yath (yet another test harness) ersetzt. Die damit erzeugte Ausgabe ist für Menschen lesbar, und nicht für Maschinen. yath protokolliert Testergebnisse als Ereignisse in Form von JSON in Dateien, die dann gut automatisiert verarbeitet werden können. Dieses Protokoll kannst du hinterher mit Bordmitteln von yath oder durch andere Programme untersuchen.

Es ist möglich, eine Testsuite mit yath ohne jegliche Ausgabe in zum Beispiel einer CI-Umgebung laufen zu lassen und das Protokoll des Testlaufs bei einem Fehlschlag beliebig oft zu untersuchen. Der folgende Aufruf ruft alle Tests im Verzeichnis t/ rekursiv auf und speichert das Protokoll des Testlaufs in komprimierter Form:

$ yath -q -B

** Defaulting to the 'test' command **


Wrote log file: /private/var/folders/cz/3j2yfjp51xq3sm7x1_f77wdh0000gn/T/2020-07-21_17:25:38_6E265890-CB66-11EA-B019-30DDB4ADC425.jsonl.bz2

The following jobs failed:
+--------------------------------------+-----------+
| Job ID                               | Test File |
+--------------------------------------+-----------+
| 6E274872-CB66-11EA-B019-30DDB4ADC425 | t/fail.t  |
+--------------------------------------+-----------+

                                Yath Result Summary
-----------------------------------------------------------------------------------
     Fail Count: 1
     File Count: 2
Assertion Count: 4
      Wall Time: 0.34 seconds
       CPU Time: 0.56 seconds (usr: 0.13s | sys: 0.03s | cusr: 0.31s | csys: 0.09s)
      CPU Usage: 162%
    -->  Result: FAILED  <--

Dieses Protokoll kann dann beispielsweise auf einen Entwicklerrechner heruntergeladen und dort untersucht werden. Beispielsweise kannst du die Ereignisse eines fehlgeschlagenen Tests über dessen UUID erneut abspielen, ohne den Test selbst laufen zu lassen. Du erhältst dann exakt die gleiche Ausgabe wie bei der Ausführung der Tests:

$ yath replay /private/var/folders/cz/3j2yfjp51xq3sm7x1_f77wdh0000gn/T/2020-07-21_17:25:38_6E265890-CB66-11EA-B019-30DDB4ADC425.jsonl.bz2 6E274872-CB66-11EA-B019-30DDB4ADC425
[  FAIL  ]  job  1  + The hash we got matches our expectations
[  DEBUG ]  job  1    t/fail.t line 5
(  DIAG  )  job  1    +------+-----+----+-------+
(  DIAG  )  job  1    | PATH | GOT | OP | CHECK |
(  DIAG  )  job  1    +------+-----+----+-------+
(  DIAG  )  job  1    | {c}  | 3   | eq | 4     |
(  DIAG  )  job  1    +------+-----+----+-------+
(  DIAG  )  job  1    Seeded srand with seed '20200721' from local date.
( FAILED )  job  1    t/fail.t
< REASON >  job  1    Test script returned error (Err: 1)
< REASON >  job  1    Assertion failures were encountered (Count: 1)

The following jobs failed:
+--------------------------------------+-----------+
| Job ID                               | Test File |
+--------------------------------------+-----------+
| 6E274872-CB66-11EA-B019-30DDB4ADC425 | t/fail.t  |
+--------------------------------------+-----------+

          Yath Result Summary
---------------------------------------
     Fail Count: 1
     File Count: 2
Assertion Count: 4
    -->  Result: FAILED  <--

STDOUT und STDERR können selbst bei Trennung von nebenläufig ausgeführten Tests über die erwähnten Ereignisse synchronisiert werden. Es ist also problemlos möglich, die Diagnosemeldungen einzelnen Tests zuzuordnen (das ist in obigem Beispiel über die angegebene Jobnummer zu erkennen). Insbesondere die Ausgabe von nebenläufig ausgeführten Tests kann weiterhin den Tests einfach zugeordnet werden. Das war mit prove bisher nicht möglich.

In der Distribution Test2::Harness::UI wird eine Webanwendung geliefert, mit der du die Ergebnisse des Testlaufs untersuchen kannst. Sie ist noch in einem frühen Entwicklungsstadium, funktioniert aber bereits. Beispiele dafür kannst du in einem Vortrag von seinem Autor Chad Granum sehen (YouTube-Video).

yath kann als Prozess laufen und Module vorladen, sodass sie nicht in jedem Testprogramm geladen werden müssen. Das bringt enorme Geschwindigkeitsvorteile – der Entwickler erhält schneller Rückmeldung und muss nicht auf Testergebnisse warten.

Testprogramm können mit Kommentaren versehen werden, die yath auswertet. Beispielsweise zeichnet der Kommentar #HARNESS-CAT-LONG ein Testprogramm als Langläufer aus, der bei nebenläufigem Aufruf vor den Kurzläufern gestartet wird, damit man nicht am Ende einer Testsuite noch auf den Langläufer warten muss.

Migrationspfad von Test::More zu Test2

Grundsätzlich sind Test2 und yath stabil und für den Produktiveinsatz geeignet.

Tatsächlich wird Test2 bereits seit Jahren von Test::More verwendet, da dies teilweise darin neu implementiert wurde (schau dir mal Test::Builder.pm an).

Eine Umstellung von Testprogrammen von Test::More auf Test2 ist in der Regel mit wenig Aufwand möglich. Es gibt dafür in der Test2-Distribution eine ausführliche Anleitung, in der die wenigen Inkompatibilitäten erklärt sind.

yath kannst du sofort ohne weiteren Aufwand als Ersatz für prove einsetzen.

Zum Weiterlesen und -sehen


Permalink: