SilverStripe und PHPUnit

In meinem Leben als Programmierer gibt es einige Dinge, die ich unbedingt lernen wollte, im Tagesgeschäft aber nie Zeit dazu hatte. Jetzt über die Osterfeiertage habe ich endlich einmal Muße und kann mich dem Thema PHPUnit zuwenden.

Das CMS und das Framework Sapphire von SilverStripe besitzen zusammen schon an die 150 Testklassen mit 2 bis 10 Tests, die die Methoden der Klassen auf ihre korrekte Funktionalität hin prüfen. Es gibt in der SilverStripe Doku auch eine kurze Einführung zu PHPUnit, oder besser gesagt zu dessen Implementierung in SilverStripe. Allerdings liegen die Tücken wie so oft in den Details und diese spart die Doku dann doch eher aus. Genau an dieser Stelle soll mein kleiner Artikel ins Spiel kommen und dem geneigten Leser einen Informationsvorsprung bieten, den ich selbst nicht hatte.

Ich habe keine Ahnung, wie man PHPUnit auf einem Server installiert, diese Arbeit hat mir dankenswerterweise Sebastian abgenommen. Ich gehe bei meinen Ausführungen einfach davon aus, dass PHPUnit installiert ist, sonst läuft nämlich goanix.

Konvention vor Konfiguration

"Convention over Configuration", nach diesem Prinzip funktioniert das Erstellen von Unit-Tests mit SilverStripe Bordmitteln. Neue Tests werden von der Klasse SapphireTest abgeleitet und im Verzeichnis "tests" in einem Modul oder im Projektverzeichnis abgelegt. Zu jeder Testklasse kann man eine YAML-Datei anlegen, in der die Testobjekte definiert werden. Man muss nämlich wissen, dass bei einem Testlauf nicht die Datenbank der Installation benutzt wird, sondern eine temporäre Datenbank erzeugt wird. Dies hat zwar den Vorteil, dass die Konfiguration und der Zustand der Installation nicht verändert werden, erfordert aber eine Definition aller Objekte, die während des Tests benötigt werden.

Auf die Plätze, fertig, ...

Ein SapphireTest lässt sich auf unterschiedliche Weise ansteuern. Über den Aufruf der URL [www.mysite.de]/dev/tests bekommt man eine Übersicht aller verfügbarer Tests. Mit  [www.mysite.de]/dev/tests/all kann man diese sukzessive Ablaufen lassen. Hier kann es zum Timeout des Servers kommen, ein einziger Test kann nämlich eine Sekunde und länger dauern. Mit  [www.mysite.de]/dev/tests/[TestKlasse] kann eine einzelne Testklasse aufgerufen werden, zum Entwickeln eines Test sehr nützlich. Nachdem ich einen halben Tag mit Sapphiretests experimentiert hatte, hatte ich so an die 50 Testdatenbanken unwissentlich erzeugt. Diese erkennt man immer am Namen "tmpdb" konkatiniert mit einer siebenstelligen Zufallszahl. Nach Abschluss der Testerei kann man diese mit  [www.mysite.de]/dev/tests/cleanupdb auf einen Schlag löschen.

Jetzt aber mal Butter bei die Fische

Ich will als keines Beispiel den Entwicklungsweg zu den ersten Tests für unser geliebtes Modul SilverCart heranziehen. Ich habe mit Test für die Klasse SilvercartProduct begonnen. Ein Test untersucht immer das Verhalten einer Methode. Führt diese Manipulationen eines Objektes richtig aus? Liefert diese den richtigen Rückgabewert und diese hoffentlich für viele verschiedene Parameter? Eine Testmethode muss gemäß der Konvention immer mit dem Namenspräfix "test" beginnen.

Als Erstes will ich das Verhalten eines Setters und eines Getter untersuchen:

/**
     * get all required attributes as an array.
     *
     * @return array the attributes required to display an product in the frontend
     * @author Roland Lehmann 
     * @since 23.10.2010
     */
    public static function getRequiredAttributes() {
        return self::$requiredAttributes;
    }

    /**
     * define all attributes that must be filled out to show products in the frontend.
     *
     * @param string $concatinatedAttributesString a string with all attribute names, seperated by comma, with or without whitespaces
     *
     * @since 23.10.2010
     * @return void
     * @author Roland Lehmann
     */
    public static function setRequiredAttributes($concatinatedAttributesString) {
        $requiredAttributesArray = array();
        $requiredAttributesArray = explode(",", str_replace(" ", "", $concatinatedAttributesString));
        self::$requiredAttributes = $requiredAttributesArray;
    }

Mit Hilfe des Setters sollen Pflichtattribute von Produkten definiert werden. Sind diese Attribute nicht gepflegt, werden die Produkte im Frontend nicht angezeigt. Der Test dazu sieht dann wie folgt aus:

class SilvercartProductTest extends SapphireTest {
    
    public static $fixture_file = 'silvercart/tests/SilvercartProductTest.yml';

/**
     * tests the reqired attributes system for products
     * 
     * @author Roland Lehmann 
     * @since 28.4.2011
     * @return void
     */
    public function testRequiredAttributes() {
        
        //two attributes
        SilvercartProduct::setRequiredAttributes("Price, Weight");
        $attributes = SilvercartProduct::getRequiredAttributes();
        $this->assertEquals(array("Price", "Weight"), $attributes, "Something went wrong setting two required attributes.");
        
        //four attributes
        SilvercartProduct::setRequiredAttributes("Price, Weight, ShortDescription, LongDescription");
        $attributes = SilvercartProduct::getRequiredAttributes();
        $this->assertEquals(array("Price", "Weight", "ShortDescription", "LongDescription"), $attributes, "Something went wrong setting four required attributes.");
    }
}

Im Test werden jetzt erst einmal die beiden Attribute "Price" und "Weight" gesetzt. Dann werden sie mit Hilfe des Getters wieder geladen. Wir nehmen jetzt natürlich an, dass der Getter die Attribute "Price" und "Weight" in einem Array zurückliefert. Diese Annahme wird mit $this->assertEquals() in Code gegossen. Deren erster Parameter ist der erwartete Wert, der mit dem zweiten Parameter, dem tatsächlichen Wert verglichen wird. Falls die Annahme falsch ist, gilt der Test als fehlgeschlagen und der dritte Parameter wird als Fehlermeldung ausgegeben. Ein Test ist nur dann erfolgreich, wenn alle seine Annahmen (engl. asserts) richtig sind.

Crash Test Dummies

In diesem Beispiel werden jetzt Testobjekte verwendet. Diese werden, wie schon gesagt, in einer YAML-Datei definiert. Der Code dazu ist wirklich minimalistisch:

SilvercartTax:
    VAT19:
        Title: 19%
        Rate: 19
        
SilvercartProduct:
    ProductWithPrice:
        isFreeOfCharge: 0
        Title: Product with price
        ShortDescription: This is the short description of the product with price
        LongDescription: This is the long description of the product with price
        isActive: 1
        PriceNetAmount: 90.00
        PriceNetCurrency: EUR
        PriceGrossAmount: 99.99
        PriceGrossCurrency: EUR
        SilvercartTax: =>SilvercartTax.VAT19

Auf der ersten Ebene wird die Klasse (SilvercartTax) definiert, von der Objekte erzeugt werden. Die zweite Ebene ist der Identifikator eines Objekts, der in SilverStripe ja eigentlich die ID ist. Dazu aber gleich noch mehr. Auf der dritten Ebene werden Werte von Objektattributen (Title: 19%) oder Objektrelationen (SilvercartTax: =>SilvercartTax.VAT19) definiert. Bei einer Relation funktioniert das immer so:

NameDerRelation: =>[Klassenname].[Identifikator]

Das SilvercartTax-Objekt muss in der YAML-Datei vor dem SilvercartProduct-Objekt stehen. Dieses Prinzip gilt für alle Relationen.

Jetzt aber zum eigentlichen Test mit Objekten. Der Test testGetTaxRate()

     /**
     * Is tax rate returned correctly?
     * 
     * @author Roland Lehmann 
     * @since 24.4.2011
     * @return void
     */
    public function testGetTaxRate() {
        $productWithTax = $this->objFromFixture("SilvercartProduct", "ProductWithPrice");
        $taxRate = $productWithTax->getTaxRate();
        $this->assertEquals(19, $taxRate, "The tax rate is not correct.");
    }

soll die Methode getTaxRate()

    /**
     * Returns the tax rate in percent. The attribute 'Rate' of the relation
     * 'SilvercartTax' is not used to handle with complex tax systems without
     * clearly defined product taxes.
     *
     * @return float the tax rate in percent
     *
     * @author Sebastian Diel 
     * @since 23.03.2011
     */
    public function getTaxRate() {
        return $this->SilvercartTax()->getTaxRate();
    }

auf ihre korrekte Funktion hin überprüfen. Mit Hilfe der Methode objFromFixture() wird jetzt ein Produkt geladen, das in der YAML-Datei definiert wurde. Der erste Parameter steht für die Klasse, der zweite für den Identifikator des Objekts. Dieser Test ist an sich nicht besonders sinnvoll, prüft er doch lediglich das Ansprechen von Relationen. Dafür gibt es sicherlich einen Test im Framework. Er verdeutlicht aber hoffentlich den Umgang mit Objekten.

Das nur am Rande

Für den ein oder anderen ist es vielleicht wichtig zu wissen, dass während eines Tests ein Admin User erzeugt und eingeloggt wird. Dies geschieht irgendwo in der Methode  runTests() der Klasse TestRunner. Da viele der Methoden unseres SilverCart-Moduls auf die Klasse des eingeloggten Nutzers reagieren, ist das wichtig zu wissen.

Beim Laden eines Objekts mit $obj = DataObject::get_one() ist man es ja gewohnt, dass Manipulationen an Attributen und Relationen auch an $obj stattfinden. Läd man ein Objekt aus der YAML-Datei mit $this->objFromFixture(), ist das nicht der Fall:

        $productWithPrice = $this->objFromFixture("SilvercartProduct", "ProductWithPrice");
        $productWithPrice->addToCart($cart->ID, 2);
        $cartPosition = $this->objFromFixture("SilvercartShoppingCartPosition", "ShoppingCartPosition");
        $position = DataObject::get_by_id("SilvercartShoppingCartPosition", $cartPosition->ID);
        $this->assertEquals(3, $position->Quantity, "The quantity of the overwritten shopping cart position is incorrect.");

Im Codebeispiel wird ein Produkt geladen und in den Einkaufswagen gelegt. Da es schon eine Position mit diesem Produkt gibt, wird deren Anzahl (Quantity) von 1 auf 3 erhöht. Die Position muss aber mit DataObject::get_one() neu geladen werden, da sich $cartPosition->Quantity nicht manipulieren lässt. $cartPosition hat immer die in der YAML-Datei definierten Werte.

Tags: