Welcher Test ist der richtige?

In der Entwicklung mit FLOW3 spielen Tests eine große Rolle. Das Tests dabei wartbar und aussagekräftig sind, erfordert einiges an Erfahrung beim Test-Driven-Development. Wichtig ist zwischen den verschiedenen Arten von Tests zu unterscheiden und für den richtigen Einsatzzweck anzuwenden:

Unit-Tests sind ein wichtiger Bestandteil zur Steigerung der Qualität und für eine größere Sicherheit bei zukünftigen Änderungen. Dabei wird jeweils nur eine Code-Einheit (z.B. eine Klasse) ohne größere Abhängigkeiten getestet. Ein fehlgeschlagener Unit-Test kann die Frage nach dem genauen Ort eines Problems oder einer inkompatiblen Änderung beantworten. Auch sind Unit-Tests die eigentliche Grundlage für Test-Driven-Development mit einem Test-First-Ansatz (also zuerst einen Test schreiben der fehlschlägt, dann die Implementierung).

Aber: selbst wenn alle Unit-Tests laufen, heißt das noch nicht, dass das Gesamtsystem zuverlässig läuft. Ausserdem sind Unit-Tests aus meiner Erfahrung bei der ersten Implementierung von ganz neuen Features (z.B. ein neues Persistenz-Backend wie das CouchDB-Package) unzureichend und störend, da die Richtung der Entwicklung oftmals noch nicht klar ist und über viele Schichten des Systems hinweg gearbeitet wird.

Functional-Tests in FLOW3 sind ein noch recht neues Feature um das System mit (fast) allen Abhängigkeiten testen zu können. Dabei steht das komplette FLOW3 mit Dependency Injection, AOP etc. in einem speziellen Kontext Testing zur Verfügung. So kann z.B. einfach der Versand von E-Mails oder die Datenbankverbindung für Tests umkonfiguriert werden.

Gerade für das Implementieren neuer Features können am Anfang gut Functional-Tests für eine automatische Überprüfung der Ziele benutzt werden. Wer im Browser in einer Webanwendung immer dieselben Schritte macht bis der Code das richtige tut, sollte vielleicht überlegen, ob Functional-Tests nicht Zeit einsparen könnten.

Der wichtigste Punkt für Functional-Tests ist für mich aber die Sicherheit, dass ein Feature im kompletten System mit allen Packages und Abhängigkeiten funktioniert. Generell sollte für jedes Szenario einer User-Story, also jedes zentrale Feature einer Applikation ein Functional-Test hinterlegt werden. Dabei ist es nicht wichtig jeden Ausführungspfad zu testen, das können auch Unit-Tests erledigen.

Functional-Test Tricks

1. Partielles Mocken von Objekten

Dabei werden Abhängigkeiten eines Objekts zum Teil durch ein Mock ersetzt. Nützlich für schwierig zu konfigurierende und zu testende Abhängigkeiten, z.B. Mailversand, externe Webservices oder Simulation eines Requests (die Helper-Methode sendWebRequest der FunctionalTestCase-Klasse nutzt selber diesen Trick). Richtig angewendet kann man damit zwar viele Schichten des Systems testen, aber bestimmte Abhängkeiten ausschließen.

2. Fixture Factories für Testdaten

Natürlich brauchen Functional-Tests irgendwann auch ein fertig instanziiertes Model als Grundlage. Recht lästig wird es, wenn jedesmal bestimmte Eigenschaften gesetzt werden müssen und dieses dann in verschiedenen Tests wiederholt wird. Auch Tests sollten nicht zu viel Redundanz aufweisen. Änderungen am Model und Änderungen in der Validierung verursachen dann viel Arbeit.

Model. Bauen. Redundanz? Da gibt es doch dieses Factory-Pattern? Genau. Ein wunderbarer Anwendungsfall. Mit ein bisschen Flexibilität gepaart, können durch eine Fixture-Factory viele Zeilen Testcode vereinfacht werden.

Nehmen wir z.B. ein Customer-Model für Kundendaten. Eine solche Factory könnte eine Methode buildValidCustomerbereitstellen, die ein Objekt baut und gleich mit den richtigen Beispiel-Eigenschaften versieht. Wenn dann noch Eigenschaften in einem Array zum Überschreiben übergeben werden können, sind auch Tests mit Abhängigkeiten auf bestimmte Werte in Eigenschaften gut lesbar und unterschiedliche Varianten eines Objektes möglich.

Mit einer zusätzlichen Method createValidCustomer könnte man dann auch das Repository im Functional-Test sparen und das Objekt direkt zum Repository hinzufügen.

class Customers {

  protected $validCustomerProperties = array(
    'customerNumber' => '123456',
    'emailAddress' => 'john.doe@example.com',
    'firstname' => 'John',
    'lastname' => 'Doe'
  );

  public function buildValidCustomer(array $overrideProperties) {
    $properties = array_merge($this->validCustomerProperties, $overrideProperties);
    $customer = new \F3\MyPackage\Domain\Model\Customer();
    foreach ($properties as $propertyName => $propertyValue) {
      if (\F3\FLOW3\Reflection\ObjectAccess::isPropertySettable($customer, $propertyName)) {
        \F3\FLOW3\Reflection\ObjectAccess::setProperty($customer, $propertyName, $propertyValue);
      }
    }
    return $customer;
  }

}
class CustomersTest extends \F3\FLOW3\Tests\FunctionalTestCase {

  static protected $testablePersistenceEnabled = TRUE;

  /**
   * @var \F3\MyPackage\Tests\Function\Fixtures\Domain\Model\Customers
   */
  protected $customers;

  public function setUp() {
    parent::setUp();
    $this->customers = $this->objectManager->get('F3\MyPackage\Tests\Function\Fixtures\Domain\Model\Customers');
  }

  /**
   * @test
   * @expectedException \F3\FLOW3\Persistence\Generic\Exception\ObjectValidationFailedException
   */
  public function customerEmailAddressHasToBeUnique() {
    $customer1 = $this->customers->buildValidCustomer(array('emailAddress' => 'foo@bar.com'));
    $customer2 = $this->customers->buildValidCustomer(array('emailAddress' => 'foo@bar.com'));
    $this->customerRepository->add($customer1);
    $this->customerRepository->add($customer2);
    $this->persistenceManager->persistAll();
  }

}

Was uns direkt zum nächsten "Trick" bringt: dem effektiven Testen von Exceptions in Tests.

4. Test von Exceptions

PhpUnit bringt eine einfache Möglichkeit mit sich Exceptions zu erwarten. Ein mit @expectedException (wie im Beispiel oben) annotierter Test stellt sicher, dass eine Exception mit dem angegebenen Typ geworfen wurde.

Was aber, wenn die Eigenschaften einer Exception relevant sind? Nehmen wir z.B. eine Exception, die bei Validierungen geworfen wird, und die ein Validierungsergebnis (F3\FLOW3\Error\Result) beinhaltet. Wenn ein Test sicherstellen soll, dass die richtige Eigenschaft als Fehlerhaft markiert wurde, macht es Sinn, die Exception abzufangen, die Testerwartung durch ein Assert anzugeben und die Exception wiederum zu werfen, damit ein @expectedException für uns den Rest erledigt und den Test gut lesbar dokumentiert.

class CustomersTest extends \F3\FLOW3\Tests\FunctionalTestCase {

  // ... Set up dependencies

  /**
   * @test
   * @expectedException \F3\MyPackage\Exception\ValidationException
   */
  public function createCustomerWithMissingEmailAddressThrowsValidationException() {
    try {
      $this->customerService->createCustomer($this->customers->buildValidCustomer('emailAddress' => ''));
    } catch (\F3\MyPackage\Exception\ValidationException $exception) {
      $this->assertTrue($exception->getResult()->forProperty('emailAddress')->hasErrors());
      throw $exception;
    }
  }

}

Testen testen testen ...

Es gibt noch viele weitere interessante Bereiche des Testings von Webanwendungen mit PHP und FLOW3. Dazu gehört sicherlich auch die richtige Anwendung von Mock-Objects und vor allem das Schreiben von aussagekräftigen und lesbaren Tests. Es bleibt also noch Platz für den einen oder anderen Folgeartikel...

Aus aktuellem Anlass: Wir suchen erfahrene PHP-Entwickler, die unser Team verstärken wollen und Lust auf spannende PHP-Projekte der nächsten Generation z.B. mit FLOW3 Entwicklung haben.