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: