Features am Fließband (Linux-Magazin, August 2013)

Test-Driven-Development verspricht Code mit weniger Fehlern und einer nebenbei generierten Testsuite. Das Tutorial nutzt ein neues CPAN-Modul.

Vor einigen Wochen hat mich die Firma auf einen Kurs zum Thema "Test Driven Development" (TDD) geschickt und um die neu erworbenen Kenntnisse in die Praxis umzusetzen, geht der Perl-Snapshot heute nach "agilen" Prinzipien vor. Schnelle und flexible Entwickler legen sofort los, ohne viel über Details nachzudenken. Da sie immer zuerst einen Test schreiben, bevor sie sich an die Implementierung einer Funktion machen, wächst die Testsuite automatisch mit relevanten Tests zu Funktionen des Systems mit. Unsauberen Code säubern sie später mittels Refactoring, was dank des durch die Testsuite bereitgestellten Sicherheitsnetzes gefahrlos möglich ist.

FAIL am Anfang

Die vor dem Schreiben einer Funktion geschriebenen Tests schlagen naturgemäß zunächst fehl, da das gewünschte Feature entweder noch gar nicht existiert oder nur teilweise oder fehlerhaft implementiert ist. Steht der Code schließlich, schaltet die Testsuite auf grün, was eine Entwicklungsumgebung wie Eclipse tatsächlich optisch so anzeigt.

Listing 1: Basic.pm

    1 package TestsFor::User;
    2 use Test::Class::Moose;
    3 
    4 sub test_constructor {
    5   can_ok 'User', 'new';
    6 }
    7 
    8 1;

Listing 2: runtests

    1 #!/usr/local/bin/perl -w
    2 use Test::Class::Moose::Load qw(t .);
    3 Test::Class::Moose->new->runtests;

Um zum Beispiel eine Klasse User.pm für ein Login-System zu schreiben, das später Methoden wie login() unterstützen soll, legt der TDD-Apostel zunächst einen Testfall an, der prüft, ob sich die gewünschte Klasse überhaupt instantiieren lässt. Listing 1 zeigt die Testdatei für einfache Testfälle, Basic.pm, die in Verzeichnis t liegt und welches das Modul brandneue Modul Test::Class::Moose vom CPAN nutzt. Letzteres führt alle Methoden, die mit dem Präfix test_ beginnen, mit den darin enthaltenen Testroutinen aus. Listing 1 definiert test_constructor() und setzt darin den Befehl

    can_ok 'User', 'new';

aus dem Modul Test::More ab, prüft also, ob die Klasse User ihren Konstruktor new aufrufen kann. Listing 2 zeigt ein Skript, das die Testsuite ablaufen lässt. Zunächst lädt es mittels Load alle Perl-Module mit der Endung .pm, die es in den angegebenen Unterverzeichnissen . und t findet. Die Methode runtests() durchstöbert anschließend alle test_*-Routinen. Zu diesem Zeitpunkt des Projekts existiert allerdings die Klasse User noch nicht, und so schlägt die Test-Suite wie in Abbildung 2 gezeigt in test_constructor fehl.

Abbildung 1: Zunächst schlägt die Testsuite Alarm, denn die Klasse "User" existiert noch nicht.

Erfolgserlebnisse

Der TDD-Entwickler hat dies zweifellos erwartet und setzt nun alles daran, Code hinzuzufügen, bis die Testsuite erfolgreich durchläuft. Da die Klasse nicht existiert, legt er eine neue Datei User.pm an und schreibt

    package User;
    use Moose;
    1;

hinein. Graue Perlpanther reiben sich hier vielleicht ungläubig ihre müden Augen, denn das Package User definiert keinen Konstruktor new(), der einen Objekthash $self mittels bless() mit einem Paket verschweißt. Das CPAN-Modul Moose erledigt all dies hinter den Kulissen, so dass jedes Paket, das Moose hereinzieht, automatisch einen Konstruktor new() besitzt. Ein erneuter Aufruf der Testsuite findet die neue .pm-Datei, die in ihr enthaltene Klasse, und führt den Konstruktor new() erfolgreich aus:

    $ ./runtests
    ...
    ok 1 - TestsFor::User

Wieder rein ins Gewimmel

Grünes Licht -- das Signal für den TDD-Entwickler, ein neues Feature anzugehen! Das User-Objekt braucht Methoden, um die Email-Adresse des Users zu setzen und abzufragen. Ein normal arbeitender Entwickler würde jetzt vielleicht gleich anfangen, den scheinbar einfachen Code einzutippen. Nicht so der TDD-Anhänger, denn der schreibt zunächst einen fehlschlagenden Test. Listing 3 definiert die Methode test_accessors, die das Test-Modul später ebenfalls aufgrund des Präfixes finden und aufrufen wird. Sie erzeugt ein neues Objekt vom Typ User und übergibt dem Konstruktor das Parameterpaar email => 'a@b.com'.

Listing 3: Accessors.pm

    01 package TestsFor::User;
    02 use Test::Class::Moose;
    03 
    04 sub test_accessors {
    05 
    06   my $email1 = 'a@b.com';
    07   my $email2 = 'c@d.com';
    08 
    09   my $user = User->new( 
    10     email => $email1,
    11   );
    12   is $user->email(), $email1;
    13 
    14     # Setter
    15   $user->email( $email2 );
    16   is $user->email(), $email2;
    17 }
    18 
    19 1;

Eine Zeile weiter holt der noch nicht definierte Accessor den per Konstruktor gesetzten Email-String hervor und die Funktion is aus dem Modul Test::More vergleicht den Wert mit dem vorher gesetzten. Stimmen beide überein, schreibt is den String "ok" in die TAP-Ausgabe der Testsuite, die Testsuite erkennt dies als erfolgreich ausgeführten Testfall. Es folgt ein Test des sogenannten Setters, der mittels der Methode email() einen neuen Wert für die Emailadresse des Users setzt und später mit dem Accessor (ebenfalls email(), diesmal ohne Argument) den gespeicherten Wert wieder hervorholt und mit dem Original vergleicht. Doch noch verfügt die Klasse User.pm noch nicht einmal über den notwendigen Code und der neue Test schlägt sofort fehl.

Getter und Setter

Damit der Konstruktor der Klasse User den Email-String als benamten Parameter entgegennimmt, eine gleichnamige Accessor-Methode ihn wieder ausspuckt, und ein Setter neue Werte setzen kann, mussten Perl-Hacker der alten Garde vor dem Auftauchen von Moose noch dutzende von Codezeilen händisch einfügen. Mit Moose ist dies Klacks, denn dessen Funktion has definiert ein Attribut einer Klasse, das sich gleichzeitig über einen Konstruktor-Parameter, einen Getter (email() und einen Setter (email( $email )) ansprechen lässt. Listing 4 zeigt eine fortgeschrittenere Version der Klasse User, die mit has das Attribut email definiert. Ihr Parameter is gibt mit "rw" an, dass der Wert sowohl geschrieben also auch gelesen werden kann. Den Typ gibt isa mit "Str" an, also einen beliebigen Zeichenstring.

Listing 4: User.pm

    1 package User;
    2 use Moose;
    3 
    4 has 'email' => 
    5   (is => 'rw', isa => 'Str');
    6 
    7 1;

Abbildung 2: Grünes Licht: Die Testsuite läuft ohne Fehler durch, die Entwicklung kann fortschreiten.

Der Chef wünscht mehr

Das erneute Anstoßen der Testsuite in Abbildung 2 zeigt, dass nun alle drei definierten Testfälle erfolgreich durchlaufen. Es kann weitergehen! Als nächstes schreibt das Pflichtenheft des Kunden vor, dass User unter ihrer Email in einer Kundendatenbank registriert werden. Getreu den TDD-Prinzipien definiert Listing 5 zuerst den Testfall mit der Routine test_customers(). Es erzeugt mittels des Konstruktors new() der Klasse Customers eine neue Kundendatei. Dann speist es zwei neue User mit unterschiedlichen Email-Adressen mittels der noch nicht existierenden Methode sign_up() in die Datenbank ein. In der zweiten for-Schleife ab Zeile 17 prüft die Testroutine dann mittels ok und der zu implementierenden Methode user_find_by_email, ob das Kundendateiobjekt die gerade registrierten Kunden auch wieder findet. In diesem Fall wird die Methode per Vorschrift einen wahren Wert zurückliefern.

Listing 5: Register.pm

    01 package TestsFor::Customers;
    02 use Test::Class::Moose;
    03 
    04 sub test_customers {
    05 
    06   my $customers = Customers->new();
    07 
    08   my @users = qw( a@b.com c@d.com );
    09 
    10   for my $email ( @users ) {
    11     my $user = User->new( 
    12       email => $email,
    13     );
    14     $customers->sign_up( $user );
    15   }
    16 
    17   for my $email ( @users ) {
    18     ok $customers->user_find_by_email( 
    19           $email 
    20     );
    21   }
    22 }
    23 
    24 1;

Wieder stehen zunächst alle Räder still, denn die Testsuite zeigt einen Fehler an. Um ihn zu beheben, implementiert Listing 6 die Klasse Customers, ebenfalls wieder mit Moose und zwei zusätzlichen Methoden. Perls Objektsystem übergibt ihnen wie üblich als erstes Argument eine Referenz auf das Objekt. Die Klasse definiert einen globalen Hash %USERS, in denen die Methode sign_up() das ihr übergebene Objekt vom Typ User unter dessen Email-Adress abliegt. Die Lookup-Methode user_find_by_email() sieht mittels exists im globalen Hash nach und liefert entweder das gefundene User-Objekt zurück, falls dieser vorher registriert wurde, oder undef falls sie ihn nicht findet. Sobald der Code in Listing 6 fehlerfrei ist, leuchtet grünes Licht auf und ein weiterer Meilenstein im Projekt ist unter Dach und Fach.

Listing 6: Customers.pm

    01 package Customers;
    02 use Moose;
    03 
    04 our %USERS = ();
    05 
    06 sub sign_up {
    07   my( $self, $user ) = @_;
    08 
    09   $USERS{ $user->email() } = $user;
    10 }
    11 
    12 sub user_find_by_email {
    13   my( $self, $email ) = @_;
    14 
    15   return exists $USERS{ $email };
    16 }
    17 
    18 1;

Das CPAN-Modul Test::Class::Moose befindet sich noch in der Beta-Phase, und tatsächlich habe ich von seiner Existenz erst auf der YAPC-Konferenz Anfang Juni im texanischen Austin, Stunden vor Redaktionsschluss, erfahren. Es sieht nach meinem ersten Eindruck sehr stabil aus, aber der Autor des Moduls nimmt etwaige Bug-Reports oder Patches gerne entgegen.

Agil mit TDD

Der Vorteil der Entwicklung nach der Test-Driven-Development-Methode ist zweifellos die stets wachsende Testsuite, die, falls der Entwickler nach Plan vorgeht, praktisch 100% Codeabdeckung bietet. Kommt der Kunde dann urplötzlich mit Änderungswünschen mitten im Projekt daher, kann der TDD-Werker diese sorglos einbauen, denn die Testsuite garantiert, dass sich nicht nebenbei fatalen Fehler einschleichen. Agile Entwickler sollten sich auch nicht zu sehr den Kopf darüber zerbrechen, was nun die eleganteste Methode ist, ein bestimmtes Feature zu erstellen. Der einfachste Weg genügt, und sobald die Testsuite Grün meldet, geht es weiter zum nächsten Feature.

Nach einiger Zeit der Entwicklung im Schnell-Schnell-Verfahren entstehen so naturgemäß hässliche Codestücke, die alle paar Iterationen korrigiert gehören, damit die Software wartbar bleibt. Findet sich ein dupliziertes Codestück, lässt es sich meist in eine Funktion auslagern. Oder falls sich Teile des Systems als bekannte Software-Patterns herauskristallisieren, sollte der Entwickler sie in deren Referenzimplementierungen umwandeln. Dieses Refactoring ist natürlicher Bestandteil des Verfahrens und verursacht normalerweise keine Probleme, ebenfalls wegen der bereits bestehenden Testsuite und deren weitflächigen Code-Abdeckung. Zeigt die Testsuite grünes Licht, war der Frühjahrsputz erfolgreich.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2013/08/Perl

[1]

Test::Class::Moose: http://search.cpan.org/~ovid/Test-Class-Moose-0.12/lib/Test/Class/Moose.pm

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.