Symfony 2: Parallele Ausführung datenbankbasierter Tests

Die Implementierung funktionaler Tests wird in der offiziellen Symfony 2 Dokumentation ausführlich erläutert. Zentral ist die Klasse Symfony\Bundle\FrameworkBundle\Test\WebTestCase, die selbst wiederum von der Klasse \PHPUnit_Framework_TestCase aus dem PHPUnit-Framework abgeleitet ist. Eigene Testfälle leitet man von WebTestCase ab und benutzt darin die statische Methode createClient um einen virtuellen Browser (sprich: HTTP-Client, vgl. Symfony\Bundle\FrameworkBundle\Client) zu erzeugen, mit dem man dann HTTP-Requests auf die eigene Applikation abfeuern und deren Ergebnisse prüfen kann. Die Ausführung der Tests übernimmt das PHPUnit-Kommandozeilenskript phpunit, welches bei der Installation des phpunit Pakets über composer automatisch im bin-Verzeichnis der Symfony-Applikation landet.

Damit an dieser Stelle keine Missverständnisse entstehen: Um funktionale Tests mit Symfony 2 durchzuführen braucht man keinen Webserver. Die einzelnen Komponenten eines HTTP-Zugriffs – insbesonder Request und Response – sind im Symfony 2 Framework so abstrahiert, dass der HTTP-Client, der in der WebTestCase-Klasse genutzt wird, keinen Webserver braucht, um einen HTTP-Request durchzuführen. Im Konstruktur der Client-Klasse wird direkt der HTTP-Kernel – so ziemlich die zentralste Entität, die es in Symfony 2 gibt – gebootet. Normalerweise findet das im app(_dev).php-Controller statt, der im Webserverkontext für die Abarbeitung von Requests zuständig ist.

Da die Tests komplett ohne Webserver laufen ist es z.B. auch möglich die funktionalen Tests in einer CI-Umgebung oder mittels selbstgebauter VCS-Hooks laufen zu lassen, ohne dass man sich um Installation und Konfiguration eines Webservers Gedanken machen muss. Allerdings sollte man im Hinterkopf behalten, dass man so auch keine Fehler abfängt, die unmittelbar mit der Webserverkonfiguration zusammenhängen. Wenn man z.B. mittels Webserver im Testsystem eine HTTP-Authentifizierung vor die Applikation schaltet, dann werden die Testskripte davon nichts mitkriegen. Das mag in diesem Fall auch sinnvoll sein. Es sind aber auch Konstellationen denkbar – z.B. automatische Weiterleitungen oder Filtern von URLs durch den Webserver – die dann trotz positiver Testläufe in der Entwicklungs- oder Testumgebung im Echtbetrieb, auch bei eigentlich erfolgreich getesteten Seiten und Workflows, zu Fehlern führen können.

Nachdem man nun begonnen hat Testfälle zu schreiben und die eigene Test-Suite langsam wächst, trifft man wahrscheinlich relativ schnell auf eins oder mehrere der folgenden Probleme:

  • Überschreiten der maximalen Anzahl von gleichzeitig offenen Datenbankverbindungen – too many connections
  • Probleme mit den php-Einstellungen zum memory limit
  • Die Gesamtlaufzeit der Testsuite steigt immer weiter an
  • Diese Probleme wollen wir im Folgenden einzeln beleuchten…

PHP memory limit

Glücklicherweise erhält man auf der Kommandozeile von phpunit direkt eine Ausgabe des Speicherverbrauchs. Man wird mit steigender Anzahl der Test-Cases etwas erschreckt zur Kenntnis nehmen, dass der Verbrauch relativ schnell ansteigt und man nicht mehr hinterherkommt den Wert der php-Konfigurationseinstellung zum memory_limit hochzusetzen – sei es durch Anpassung der php.ini oder indem man den Wert direkt beim Aufruf von phpunit mit dem Parameter -d übergibt.

PHPUnit sammelt im Hintergrund Referenzen zu den einzelnen Test-Cases, was wiederum den Speicherbedarf der gesamten Test-Suite mit Zunahme der Testfälle immer weiter ansteigen lässt. Vielleicht wird dieses Problem in einer zukünftigen Version von PHPUnit gelöst (vgl. z.B. https://github.com/sebastianbergmann/phpunit/issues/10). Aktuell hilft hier nur die process-isolation-Option (entweder als Kommandozeilen-Switch oder alternativ über die Konfigurationsdatei phpunit.xml) von PHPUnit wirklich weiter. Damit bringt man PHPUnit dazu die einzelnen Test-Cases jeweils in einem separaten PHP-Prozess laufen zu lassen. Nachdem der Test-Case abgearbeitet wurde, wird auch der Prozess beendet und der Arbeitsspeicher damit definitiv wieder freigegeben.

MySQL-Fehler: too many connections

Da die in den funktionalen Tests aufgerufenen Controller der Symfony-Webapplikation üblicherweise auf das (Doctrine-)Datenmodell zugreifen, werden dabei im Hintergrund natürlich auch Datenbankqueries abgeschickt. Dabei stellt uns die WebTestCase-Klasse aus dem Symfony-Framework vor ein neues Problem: Die innerhalb der einzelnen TestCase-Methoden – durch den Aufruf von createClient – geöffneten Datenbankverbindungen werden nach Abschluss des Tests nicht geschlossen.

Dieses Problem tritt normalerweise erst auf, wenn man eine größere Zahl von Test-Funktionen geschrieben hat, da der Default-Wert vom MySQL-Server für die maximale Anzahl gleichzeitiger Verbindungen immerhin 151 beträgt. Auf Entwicklungs- oder Testsystemen – auf denen die Test-Suite üblicherweise laufen wird – ist diese MySQL-Einstellung wahrscheinlich auch nicht angepasst worden. Da dieses Problem im täglichen Entwicklergeschäft etwas weniger häufig anzutreffen als das oben geschilderte memory_limit-Problem, kann man von dieser Fehlermeldung u.U. zunächst etwas unvorbereitet getroffen werden. Das Problem lässt sich aber leicht reproduzieren, wenn man die max_connections in der MySQL-Konfig auf einen sehr niedrigen Wert (z.B.: 2) stellt und dann einige wenige Testfälle laufen lässt.

Abhilfe schafft hier wiederum der PHPUnit-Parameter processIsolation: Die Test-Cases laufen in separaten Prozessen und bei der Beendigung eines Prozesses werden auch dessen Datenbankverbindungen automatisch geschlossen.

Falls die Option processIsolation aus irgendeinem Grund nicht genutzt werden soll, bleibt nur die Möglichkeit in der tearDown-Methode der Test-Cases die Datenbankverbindung(en) explizit zu schließen, was aber im Einzelfall ziemlich umständlich werden kann.

Gesamtlaufzeit der Testsuite

Ein weiteres Ärgernis bei steigender Anzahl und Komplexität der Test-Cases ist deren Gesamtlaufzeit. Bei Unit-Tests wird generell angeraten, dass die Tests möglichst schnell abgearbeitet werden sollten, so dass der Entwickler den Aufruf der Tests problemlos in seinen Arbeitsalltag integrieren und sich so bei jeder Anpassung am Quellcode rückversichern kann, dass er keine Spezifikation verletzt und keine unerwünschten side effects auftreten.

Man kann zwar die Menge der abzuarbeitenden Tests mit diversen phpunit-Kommandozeilenparametern einschränken, allerdings wird dann eben auch nur ein Teil der Tests durchlaufen und man übersieht u.U. Nebeneffekte und Fehler. Außerdem sollte zumindest vorm Commit ein Komplettdurchlauf der Test-Suite erfolgen. Zwar kann man die Testabarbeitung auch komplett auf den CI-Server verlagern, allerding erhält man das Feedback dann erst später und man muss auch eine funktionsfähige und stabile CI-Umgebung im Einsatz haben. Zumal auch nichts dagegen spricht die Testabarbeitung auf CI-Seite zu beschleunigen.

Unser Ansatz die Tests zu beschleunigen besteht in der parallelen Abarbeitung der einzelnen Test-Cases. Heutzutage hat praktisch jeder Desktop-Rechner und erst recht jeder (CI-)Server mehrere CPU-Kerne und selbst bei nur einem Kern kann die Parallelität die Gesamtlaufzeit positiv beeinflussen, wenn die Abarbeitungsgeschwindigkeit nicht nur durch die CPU begrenzt ist. Da PHPUnit (noch) nicht die Option bietet Tests parallel ablaufen zu lassen, nutzen wir zu diesem Zweck ein selbstenwickeltes Bash-Skript. Die aktuellste Version kann man sich von github holen: https://github.com/jphelf/parallel-test.

Damit das Skript laufen kann muss sichergestellt werden, dass die beiden folgenden Kommandozeilentools installiert und verfügbar sind:

  • flock – um den parallelen Zugriff auf Shared-State-File zu synchronisieren, Teil des util-linux-ng Pakets
  • gnu parallel – übernimmt die Aufgabe PHPUnit in separaten Prozessen zu starten
    Schauen wir uns also an, wie das Skript funktioniert und was konfiguriert werden muss:

Zunächst gilt es ein Kriterium zu finden mithilfe dessen die Test-Suite in kleine Häppchen aufgeteilt wird, die dann parallel abgearbeitet werden. Dazu gibt es unterschiedliche Möglichkeiten, aber der einfachste Ansatz ist zunächst die Aufteilung nach Test-Cases: Das Skript durchsucht dazu den Testordner im Bundle-Verzeichnis (vgl. die Variable TESTCASEDIR im Skript) nach den Test-Case-Namen und nutzt schließlich die –filter-Option von phpunit, um jeden Test-Case einzeln ablaufen zu lassen.

Die Anzahl der maximal gleichzeitig betriebenen Testläufe stellt man in der Variablen MAXPROCS im Skript ein und sollte sich an den verfügbaren freien CPU-Ressourcen orientieren. Ein guter Startwert auf Systemen ohne sonstige Last ist: Anzahl der CPUs- bzw. Kerne + 1.

Da wir mehrere Tests parallel laufen lassen möchten, benötigen wir dazu mit hoher Wahrscheinlichkeit auch mehrere Datenbankzustände, um unterschiedliche Fixtures einzuspielen und die testspezifischen Datenbankqueries auszuführen. Den direkten Weg hierzu bieten zusätzliche Test-Environments innerhalb der Symfony-Applikation. Das Skript geht davon aus, dass diese Environments durchnummeriert sind: test1, test2, test3 usw. Um ein zusätzliches Test-Environment für den parallelen Testbetrieb bereitzustellen, muss man zunächst eine entsprechende Config-Datei anlegen:

app/config/config_test1.yml

imports:
- { resource: config_test.yml }
parameters:
database_name: "testdb-1"

Beim Überschreiben von Parametern sollte man sich zurückhalten, da Unterschiede der Test-Evironments zu der Basis-Test-Konfiguration nicht wünschenswert sind: Wir möchten schon gerne die Applikation annährend so testen, wie sie später auch im Produktivbetrieb laufen soll und nicht mit einer Fantasiekonfiguration – Ausnahmen bestätigen die Regel. Die Testdatenbanken werden vom Skript bei jedem Testlauf komplett geleert und neu aufgesetzt. Hier muss man also sicherstellen, dass man nicht zufällig eine Datenbank konfiguriert, deren Inhalt man noch benötigt. Zusätzlich zu den neuen Konfigurationsdateien, müssen wir auch noch eine kleine Anpassung am AppKernel vornehmen. Für dev- und test-Environment werden einige zusätzliche Bundles geladen, z.B. die Profiler-Komponente, die benötigen wir für unsere parallelen Tests auch:

app/AppKernel.php

<?php use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Config\Loader\LoaderInterface; class AppKernel extends Kernel { public function registerBundles() { ... if (in_array($this->getEnvironment(), array('dev', 'test', 'test1', 'test2', 'test3', 'test4'))) {
    $bundles[] = new Acme\DemoBundle\AcmeDemoBundle();
    $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle();
    $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle();
    $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();
    }

    return $bundles;
    }
    ...

Das Skript muss eine Liste der aktuell freien und der belegten Environments verwalten. Sobald ein neuer Testprozess gestartet wird, holt sich dieser aus der Liste ein freies Environment und kennzeichnet dieses als belegt; sobald der Testprozess beendet ist, gibt er das Environment wieder frei. Zur „Kommunikation“ bzgl. des Belegungszustandes der Environments wird eine Datei genutzt. Der Name dieser Datei lässt sich in der Variable SHARED im Skript ändern. Defaultmässig liegt die Datei im app/cache-Verzeichnis, weil dort wahrscheinlich auch Schreibrechte vorhanden sind.

Nachdem man sich letztendlich durch die Konfiguration gekämpft hat, startet man das Skrip einfach mit

[jhelf@localhost test]$./parallel_test.sh

und freut sich daran wie die Testergebnisse nach und nach in hübsch asynchroner Manier reportet werden.

Das LiipFunctionalTestBundle

Zu guter Letzt soll noch ein Bundle vorgestellt werden: Das LiipFunctionalTestBundle bietet einige nützliche Features, die einem das Schreiben von funktionalen Test deutlich erleichtern. Um das Bundle einzusetzen leitet man die eigenen Test-Cases von der Bundle-Klasse Liip\FunctionalTestBundle\Test\WebTestCase ab. Damit stehen folgende Methoden zur Verfügung:

  • runCommand – Ausführung von Symfony Konsolen-Kommandos
  • loadFixtures – Einspielen von Datenbank-Fixtures

Die Klasse Liip\FunctionalTestBundle\Test\Html5WebTestCase bietet darüberhinaus noch die Möglichkeit einen HTML5-Validator zu integrieren.