test.ical.ly | getting the web by the balls

Mar/10

11

Mehr zu PHPUnit Tests in symfony Plugins

MockUpGestern hab ich mich etwas darüber ausgelassen, wie doof doch einige Code Koppelungen von symfony mir das Testen behinderten.

Vor allem drei Dinge haben mir das Leben schwer gemacht: die Konfiguration, sfContext und Datenbankabhängigkeiten. Und ich habe für alle drei auch Antworten finden können. Vielleicht nicht die endgültigen, aber schon sehr akzeptable, wie ich meine.

Vielleicht sag ich erstmal, was überhaupt meine Probleme waren.

Zum einen ist es nicht immer ganz leicht herauszufinden, was man nun alles initialisieren muss, um eine Klasse zu Testen. Und für die Dinge, die initialisiert werden sollen, ist es nicht immer ganz einfach herauszufinden, was man dazu alles hochfahren und welche Konfiguration geladen werden muss.

Das führte dazu, dass ein Test alleine durchlaufen konnte, aber es in einer Test Suite nicht mehr tat. Und wenn man ihn da zum Laufen bekommen hat, konnte er immer noch auf dem Continuous Integration Server fehlschlagen.

Ein anderes Problem war die Ausführungszeit der Tests vor allem auf dem CI Server. Spitzenzeiten von bis zu 5 Minuten finde ich mehr als grenzwertig, wenn man sich mal den Umfang meines Projektes vergegenwärtigt (ca. 13 überschaubare Klassen inklusive der Controller).

Unterm Strich waren meine Tests alle zusammen wenig stabil und zu langsam.

Als Grund für die lange Ausführungszeit konnte ich ganz klar den Bootstrap von symfony ausmachen. Das ständige Hochfahren des Frameworks ging extrem auf die Performance.

alltests2

Bei dieser Testdauer Übersicht kann man schon ahnen, wo die Probleme liegen. Hier mal die teuren vier:

  1. sfImageTransformExtraPluginConfigurationTest testet die Plugin Configuration, die bereits alle anderen Configs benötigt, um überhaupt richtig instanziiert werden zu können
  2. sfImageSourceTemplateTest testet ein Doctrine Behaviour, welches ein Doctrine_Record benötigt, um die einzelnen Methoden aufrufen zu können
  3. sfImageTransformManagerTest ist tatsächlich nicht ganz sauber entworfen.. (Das habe ich aber gestern bereits korrigiert!)
  4. sfImageSourceDoctrineTest testet einen Stream Wrapper, der eine Resource zurückliefert, die er mit Hilfe der Datenbank bestimmt

Mittlerweile sieht die gleiche Übersicht wie folgt aus:

alltest_new

Das kann sich schon eher sehen lassen. Eine Ausführzeit von 6 Sekunden ist auf jeden Fall akzeptabel, was ja nicht nur für den CI Server sondern vor allem für die manuelle Ausführung während der Entwicklung kritisch ist.

Aber was habe ich gemacht?

Separation des Plugins

Unbedingt notwendig für eine höhere Stabilität der Tests unter verschiedenen Umgebungen (Entwicklungsumgebung und Continuous Integration Server) war es das zu testende Plugin aus dem Projekt zu lösen. Das heisst:

  • Eine separate Entwicklungsumgebung. Um zu vermeiden, dass man doch Projekt-Abhängigkeiten einbaut und mittestet, sollte die Entwicklung des Plugins separat stattfinden oder zumindest das Testen.
  • Ein separater PHPUnit Aufruf. Damit bekommt man auch Konflikte wie gleich benannte Klassen in den Griff. Überhaupt besteht im Grunde genommen kein Grund in einem Projekt auch alle darin genutzten Plugins zu testen, denn diese sollen ja vor allem Projekt-unabhängig funktionieren.
  • Ein separater CI Job. Zu finden unter http://automat.ical.ly/job/sfImageTransformExtraPlugin/
  • Ein separates Repository. Das macht es dem CI Server einfacher, einfach Subversion nach updates zu befragen, ohne dass Plugin und Projekt Updates sich gegenseitig ins Testing schiessen.

Als “Entwicklungsumgebung” reicht es mir aus, das Plugin irgendwo ohne symfony Projekt abzulegen und lediglich die symfony Bibliotheken bereitzustellen (export SYMFONY=…./symfony/lib). Sobald kein Projekt mehr vorhanden ist und die Tests trotzdem durchlaufen, kann man sicher sein, dass es keine Abhängigkeiten gibt.

Der separate PHPUnit Aufruf hat zwei Vorteile. Zum einen umgeht es den Namenskonflikt, über den ich vorgestern gestolpert bin. Zum anderen werde ich solche Aufrufe nur noch direkt aus dem Plugin Basisverzeichnis ausführen. Denn sobald ich eine phpunit.xml anlege, weiss ich, dass die darin enthaltenen Pfade relativ zum Plugin sind.

Ein separater CI Job macht ebenfalls total Sinn, denn dann kann ich dort separat konfigurieren und bringe nicht eventuelle Projekt-Abhängigkeiten in die Testumgebung.

Das separate Repository ist erstmal optional, aber sicher sinnig, wenn ich dieses Plugin auch in anderen Projekten nutzen will.

Doctrine Verbindung mocken

Die Doctrine Datenbank Verbindung kostet Zeit und ist bisher aber gar nicht wirklich nötig, weil ich gar keine Modelle im Projekt habe, sondern lediglich mit Modellen aus dem Projekt zusammenarbeiten will.

Eine DSN habe ich wie folgt angegeben, aber obwohl SQLite für Testzwecke sicher besser ist, als MySQL o.ä. ist die Auswirkung auf die Performance recht gross.

all:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: sqlite:////fixture.db

Deshalb habe ich in den setUp() Methoden der TestCases, die irgendwie mit Doctrine arbeiten folgenden Schnipsel eingebaut:

    $this->dbh = new Doctrine_Adapter_Mock('mysql');
    $this->conn = Doctrine_Manager::getInstance()->openConnection($this->dbh, 'mysql', true);

Das true ist wichig, denn es aktiviert auch gleich die so übergebene Verbindung.

Im Ergebnis wird die fixtures.db gar nicht mehr angelegt. Die DSN wird lediglich zum initialisieren benötigt, Doctrine danach aber wieder umkonfiguriert.

Im Sourcecode von Doctrine habe ich auch entdeckt, dass man eine DSN mit dem mock:// Protokoll angeben kann, aber das habe ich noch nicht zum Laufen bekommen. Sollte ich das noch hinbekommen, könnte ich mir aber sogar den Schnipsel sparen.

Ein Doctrine_Record mocken

Wenn ich keine eigenen Modelle habe und mich auch nicht Daten aus irgendeiner Datenbank bedienen möchte (und darf!), dann brauche ich ein Mock-Doctrine_Record und eine dazugehörige Mock-Doctrine_Table.

Das require() ich einfach überall, wo ich sowas benötige und implementiere dort alles, was ich von diesem Record in Tests benötige. Ich muss allerdings aufpassen, nichts Test-kritisches zu mocken, was eventuell zu einem allways-pass führt. Nur Sachen mocken, um Abhängigkeiten aufzulösen!

sfContext nicht öfter instanziieren als notwendig

Ich habe gelernt, dass die setUp() Methode in einem TestCase mit dessen Instanziierung ausgeführt wird. Damit hatte ich angenommen, dass sie einmal vor allen im TestCase enthaltenen Test-Methoden läuft. Das stimmt aber so nicht.

Ein TestCase wird für jede in ihm enthaltene Test Methode erneut instanziiert!

Wenn ich dann in der setUp() Methode ein sfContext Objekt erzeuge, dann mache ich das erneut pro Test Methode. Doof.

Folgender Schnipsel hilft da gewaltig:

if(!sfContext::hasInstance('frontend'))
    {
      sfContext::createInstance($this->projectConfiguration->getApplicationConfiguration('frontend', 'test', true));
    }

So wird die Instanz nur noch erzeugt, wenn es noch keine gibt.

Ich werde mir meine TestCases immer wieder ansehen. Sollte ich dabei feststellen, dass ich diesen Schnipsel mehr als nur wenige Male verwende, dann werde ich ihn wohl in die setUp() Methode der TestSuite umziehen, die kann dann für alle Tests was vorbereiten.

So, ich bin erstmal zufrieden mit mir. Der Release Tag kommt näher! :)

· · · · · ·



  • http://blog.nevalon.de Gordon Franke

    Interessanter Artikel besonders Doctrine_Adapter_Mock(‘mysql’); kannte ich noch nicht ;)

    Bei PHPUnit git es einmal die setUp und zum anderen die setUpBeforeClass Methode evt. ist es die eleganter Variante als hasInstance calls.

    “In addition, the setUpBeforeClass() and tearDownAfterClass() template methods are called before the first test of the test case class is run and after the last test of the test case class is run, respectively.”

    Ach ja ein kleiner Formatierungsfehler am ende der Seite letztes Code-Beispiel.

  • http://www.robo47.net/ robo47

    Gerade für unittests nutze ich bei PDO/Doctrine immer sqlite via :memory:, das dürte wohl mit eine der schnellsten Möglichkeiten sein, man muss sich auch nicht ums aufräumen von irgendwelchen dateien kümmern, weil die Datenbank im RAM nach dem durchlaufen des scripts weg ist, es erfordert auch keine Extra Mockup-Connection ich muss lediglich in der config die dsn für unittests ändern.

  • http://test.ical.ly Christian

    @Gordon Danke fuer die Hinweise. Formatierung ist korrigiert und die setUpBeforeClass() und tearDownAfterClass() werd ich mir mal aus der Naehe anschauen.

  • http://test.ical.ly Christian

    @robo47 Hmm ein Kollege berichtete mir, das SQLite/memory nicht ganz so performant sei, wie er erwartet hat. Aber ich schau mir das nochmal an.
    Kannst du vielleicht mal eine beispiel DSN hier posten?

  • http://www.robo47.net/ robo47

    Also ich bin im vergleich zu mysql bzw. sqlite auf dateibasis recht zufrieden, eine größere test-suite lief als ich das das erste mal umgestellt hatte knapp 20% schneller, das allerdings auch schon etwas her und ich hab das seit dem nicht neu überprüft ob mysql aufgeholt hat oder ähnliches und ich arbeite halt an einem notebook mit einer recht genügsamen hdd, das kann durchaus auch einen unterschied machen.

    DSN: sqlite://:memory:

  • http://test.ical.ly Christian

    @robo47
    Naja im Vergleich zu MySQL und SQLite auf Dateibasis hätte ich auch nichts anderes erwartet.
    In meinem obigen Beispiel könnte statt mysql auch oracle stehen. Das signalisiert Doctrine lediglich, welche API es bedienen soll. Da das Ganze aber ja eine Mock Connection ist, wird keine SQL ausgeführt.
    Ist natürlich nicht nutzbar, wenn man die Persistierung testen will..

<<

>>

Theme Design by devolux.nh2.me