Symfony Entwicklung

So entwicklen wir testgetrieben mit Symfony in München

Bei uns gibt es keine glänzenden Management-Präsentationen oder Marketing-Versprechen!
Auf dieser Seite möchten wir mit einfachen Code-Beispielen aufzeigen, wie wir testgetrieben Webseiten entwickeln.

Warum arbeiten wir testgetrieben?

Diese Frage lässt sich am Besten anhand eines kleinen Code-Beispiels beantworten:

Data-Klasse

Die Klasse Data wurde nach dem weitverbreiteten „einfach-mal-Losprogrammieren“-Konzept implementiert. Würden wir im nächsten Schritt ein data-Objekt erstellen und dem Konstruktor beim Initialisieren ein Array von Objekten übergeben, so könnten wir über die get()-Funktion auf diese zugreifen. Was aber würde passieren, wenn:

  • wir dem Konstruktor kein Array von Objekten übergeben,
  • die Objekte nicht public $blist enthalten oder
  • die Klasse erweitert werden muss?

Tritt einer der ersten beiden Fälle ein, dann bricht der Programmdurchlauf mit einer relativ unspezifischen Fehlermeldung ab. Bei einer geringen Menge Code reicht diese Fehlermeldung vielleicht noch aus, um die betreffende Stelle im Code ausfindig zu machen und das Problem zu beheben. Je komplexer der Code im Laufe des Entwicklungsprozesses aber wird, desto schwieriger bzw. zeitaufwändiger (und damit kostenintensiver) wird eine solche Fehlerbehebung. Es sollte also immer oberstes Gebot sein, die Code-Komplexität so gering wie möglich zu halten und mögliche Fehler aussagekräftig abzufangen. Wollen wir die Klasse erweitern, so sollte außerdem sichergestellt werden, dass bestehende Funktionalitäten erhalten bleiben.

<?php
  class data
  {
    private $data;

    public function __construct($data)
    {
      $this->data = $data;
    }

    public function get()
    {
      $fp = array();
      foreach ($this->data as $o) {
        if ($o->bList == 1) { // What is bList?
          $fp[] = $o;
        }
      }

      return $fp;
    }
  }

Was ist testgetriebene Entwicklung genau?

Um aussagekräftige Fehlermeldungen und eine störungsfreie Weiterentwicklung sicherzustellen, setzen wir auf testgetriebene Entwicklung: Bevor wir auch nur eine Zeile Code schreiben, definieren wir über Tests bereits im Vorfeld unsere Erwartungshaltung, damit wir später genau das Richtige implementieren. Diese Methode folgt dabei stets dem RED » GREEN » REFACTOR-Zyklus.

Test-Zyklus

  • RED
    Da wir den Test vor der eigentlichen Implementierung schreiben, schlägt dieser zuerst immer fehl.
  • GREEN
    Durch die Implementierung erfüllen wir dann unsere Testbedingungen und bringen den Test so zum Laufen.
  • REFACTOR
    Im letzten Schritt erfolgt das Aufräumen. Hier wird der Code hinsichtlich der Lesbarkeit und Performance optimiert.

Dieser Zyklus wird ständig wiederholt. Im Folgenden wenden wir das Ganze für unsere data-Klasse unter Verwendung von PHPUnit an. PHPUnit bietet eine Sammlung von Methoden, welche zum Schreiben von UnitTests benötigt werden. Ein UnitTest soll immer nur einen sehr kleinen Teil (ein Unit) des Codes testen. UnitTests werden verwendet, um das Verhalten einer Komponente isoliert, d. h. ohne ihre Abhängigkeiten zu anderen Komponenten, zu überprüfen.

Nun entwickeln wir die oben genannte Data-Klasse testgetrieben:

Erste Iteration

RED

Im ersten Zyklus-Schritt wird der Test für die Data-Klasse geschrieben, welche wir im Folgenden als ProductList-Klasse spezifizieren:

ProductList-Test

<?php

namespace Tests\Demo;

use Demo\ProductList;

class ProductListTest extends \PHPUnit_Framework_TestCase
{
  public function testProductList()
  {
    $productList = new ProductList();
  }
}

Die Testklasse ProductListTest erbt die für den UnitTest benötigten Methoden von \PHPUnit_Framework_TestCase. Wird jetzt der Test ausgeführt, so schlägt dieser fehl, da die Klasse ProductList noch nicht existiert:

FAILURES!

Tests: 1, Assertions: 0, Errors: 1.

GREEN

Der Test gibt uns also den nächsten Arbeitsschritt vor: Wir müssen die Klasse ProductList implementieren:

ProductList-Klasse

<?php

namespace Demo;

class ProductList
{

}

Jetzt führen wir den Test erneut aus:

OK (1 test, 0 assertions)

Der Test funktioniert! Als nächstes erweitern wir unseren Test um die gewünschten Funktionalitäten von ProductList. Die Klasse ProductList soll eine Funktion getListedProducts() enthalten und jedes Produkt muss außerdem eine Funktion isListed() besitzen – das schließen wir quasi vertraglich über ein Interface ProductListInterface ab:

ProductList-Interface

<?php

namespace Demo;

interface ProductListInterface
{
 /**
 * Checks if the product is listed.
 *
 * @return bool
 */
public function isListed();
}

Als Nächstes wird der Test erweitert. Im jetzten Zustand gibt es keine Verbesserungen am Code, so dass wir den Schritt Refactor überspringen können.

Zweite Iteration

RED

Mit Hilfe der prophesize-Funktion prophezeien wir ein Objekt der Klasse ProductListInterface. Das resultierende listedProduct ist ein Objekt der Klasse ObjectProphecy, welches das zukünftige Verhalten einer Instanz von ProductListInterface beschreiben soll. Durch den Aufruf von isListed()->willReturn(true)/(false) erzeugen wir uns das gewünschte Verhalten eines gelisteten und eines nicht gelisteten Produkts. Die beiden Prophezeiungen werden dann in der ProductList gespeichert und über ->reveal() aktiviert bzw. in ein dummy-Objekt überführt. Dieses versucht dann die Prophezeiung zu erfüllen. Zum Schluss werden über Assertions die Erwartungen mit dem Ist-Zustand verglichen:

  • Wird wirklich nur ein gelistetes Produkt zurückgegeben?
  • Enthält productList auch das übergebene listedProduct?
class ProductListTest extends \PHPUnit_Framework_TestCase
{
  public function testProductList()
  {
    $listedProduct = $this->prophesize(ProductListInterface::class);
    $listedProduct->isListed()->willReturn(true);
    $notListedProduct = $this->prophesize(ProductListInterface::class);
    $notListedProduct->isListed()->willReturn(false);

    $productList = new ProductList(
      [
        $listedProduct->reveal(),
        $notListedProduct->reveal(),
      ]
    );

    $this->assertCount(1, $productList->getListedProducts());
    $this->assertContains($listedProduct->reveal(),
    $productList->getListedProducts());
  }
}

Jetzt wird der ProductListTest erneut ausgeführt. Dieser schlägt wieder fehl, da die Klasse ProductList noch keine Funktionen enthält.

There was 1 error:

1) Tests\Demo\ProductListTest::testProductList
Error: Call to undefined method Demo\ProductList::getListedProducts()

FAILURES!

Tests: 1, Assertions: 0, Errors: 1.

GREEN

Jetzt müssen wir die Klasse ProductList entwickeln, sodass unsere im Test formulierten Erwartungen erfüllt werden:

Testgetriebene ProductList-Klasse

class ProductList
{
  private $products = [];

  public function __construct(array $productList)
  {
    foreach ($productList as $product) {
      $this->addProduct($product);
    }
  }

  public function addProduct(ProductListInterface $product)
  {
    array_push($this->products, $product);
  }

  public function getListedProducts()
  {
    return array_filter(
      $this->products,
      function (ProductListInterface $product) {
        return $product->isListed();
      }
    );
  }
}

Zum Vergleich: Data-Klasse

class data
{
  private $data;

  public function __construct($data)
  {
    $this->data = $data;
  }

  public function get()
  {
    $fp = array();
    foreach ($this->data as $o) {
      if ($o->bList == 1) {
        $fp[] = $o;
      }
    }

    return $fp;
  }
}

Gerade in der Gegenüberstellung wird die deutliche Verbesserung der Code-Qualität mittels der testgetriebenen Entwicklung sichtbar.

Fazit

Die durch testgetriebene Entwicklung implementierte ProductList-Klasse erfüllt jetzt alle Testbedingungen. Sie ist leicht erweiterbar, da die Tests die bereits bestehende Logik absichern. Da die Produkte das ProductListInterface implementieren müssen, ist außerdem sichergestellt, dass isListed() vorhanden ist. Außerdem kann nur noch ein Array an den Konstruktor übergeben werden. Der direkte Vergleich zur anfänglichen Data-Klasse soll verdeutlichen, warum es sich lohnt, testgetrieben zu arbeiten und den Weg des qualitativ hochwertigen Codes zu gehen.

Mit diesem Beispiel haben wir die testgetriebene Entwicklung veranschaulicht und gehen jetzt im zweiten Teil speziell auf das Symfony Framework ein.


Warum arbeiten wir mit Symfony?

Symfony Logo
© Symfony Logo: Fabien Potencier

Weitere Informationen zu Symfony:
symfony.com

Symfony ist ein PHP Framework, welches in erster Linie eine Sammlung von vorgefertigten, schnell integrierbaren Softwarekomponenten bereitstellt. Das bedeutet kurz und knapp weniger Code schreiben und ein geringeres Fehler-Risiko. Im Umkehrschluss resultiert daraus mehr Zeit für die wesentlichen Entwicklungspunkte der Webapplikation. Der zweite wichtige Aspekt bei der Entwicklung mit Symfony ist eine strukturierte Methodik – quasi eine Baugerüst für Webapplikationen. Durch die Vorgabe und Einhaltung von Best Practices kann eine stabile, wartbare und erweiterbare Software erzielt werden.

Symfony

  • ist Open Source,
  • besteht aus insgesamt 36 einzelnen PHP Bibliotheken,
  • bildet basierend auf den Komponenten ein vollständiges Framework,
  • wird in vielen Projekten verwendet und
  • hat über 300.000 aktive Entwickler.

Am besten lassen sich die zahlreichen Vorteile von Symfony anhand kurzer Beispiele aufzeigen. Nachfolgend zeigen wir am Beispiel einer Newsletter E-Mail, welche Probleme Symfony sehr einfach für uns lösen kann.

Beispiel: Versand einer Newsletter E-Mail

Wieder starten wir nach dem weitverbreiteten „einfach-mal-Losprogrammieren“-Konzept. Der Klasse Newsletter wird ein Array von Empfängern übergeben. Innerhalb der Newsletter-Klasse wird dann ein Mailer instanziiert, und dessen send-Funktion für jeden Empfänger ausgeführt.

Der Programmablauf gestaltet sich wie folgt:

Newsletter Beispiel

Klasse Mailer

<?php

class Mailer
{
  private $fromEmail;
  private $fromName;
  private $header;

  public function __construct($fromEmail, $fromName, $header)
  {
    $this->fromEmail = $fromEmail;
    $this->fromName = $fromName;
    $this->header = "From: $fromName <$fromEmail>\n".$header;
  }

  public function send($to, $subject, $message)
  {
    mail($to, $subject, $message, $this->header);
  }
}

Klasse Newsletter

<?php

class Newsletter
{
  private $mailer;

  public function __construct()
  {
    $this->mailer = new Mailer('news@demo.de','Demo News','Reply-To: reply@demo.de');
  }

  public function send($recipients, $subject, $message) {
    foreach ($recipients as $to) {
      $this->mailer->send($to, $subject, $message);
    }
  }
}

Sehen wir uns die Newsletter Applikation genauer an, so finden wir eine direkte Abhängigkeit der Newsletter-Klasse von Mailer, da dieser Klassenintern instanziiert wird. Das macht die Softwarekomponente nicht testbar, erweiterbar und wiederverwendbar. Diese nicht testbaren Abhängigkeiten machen ein Fehlerhandling so gut wie unmöglich. Zum Glück können wir dieses Problem beheben, indem wir den Symfony-Weg testgetrieben beschreiten.

Optimierungspotentiale beider Klassen

  • Dependency Injection löst die Abhängigkeiten auf.
  • Kleine testgetriebene Klassen ermöglichen das Fehlerhandling und verringern die Komplexität.
  • Die Symfony Config stellt eine einfache Konfiguration bereit.
  • Die Debug-Komponente hilft beim Testen.
  • Über das Framework können wir einfach die Bibliothek SwiftMailer integrieren und konfigurieren.

The Symfony Way

Um die Abhängigkeiten aufzulösen, können wir den von Symfony bereitgestellten Service Container verwenden. Ein Service Container ist ein einfaches PHP Objekt, das sich um die Instanziierung von Services kümmert. Es gibt mehrere Wege um einen Service im Container zu registrieren. Für dieses Beispiel wird die Konfiguration mittels YML-Dateien verwendet.

Dependency Injection Konfiguration

parameters:
    mailer.transport: sendmail
services:
    mailer: sendmail
        class: Mailer
        arguments: ['%mailer.transport%']
    newsletter_manager:
        class: NewsletterManager
        calls:
            - [setMailer‚ ['@mailer']]

Jetzt haben wir die beiden Services im Container registriert. Ein Service-Objekt wird immer nur konstruiert, wenn es auch gebraucht bzw. aufgerufen wird. Ein weiterer Vorteil: ein Service muss immer nur ein mal instanziiert und bei jedem Aufruf an dieselbe Instanz zurückgegeben werden. Das bringt zusätzliche Performance! Wie oben bereits beschrieben, muss im Folgenden erst wieder der Test für die Klasse NewsletterManager geschrieben werden.

Newsletter Manager Test

Wir stellen die Erwartung, dass die Methode send mindestens einmal aufgerufen wird. Über Prophesize wird deshalb wieder das Verhalten der Mailer-Klasse vorhergesagt. Der einzige Unterschied zu oben ist, dass anstatt eines dummy-Objekts ein Stub-Objekt instanziiert wird. Ein Stub ist quasi eine Erweiterung des Dummy-Objekts um eine Logikkomponente.

Danach kann der NewsletterManager testgetrieben implementiert werden.

<?php

namespace Tests\Demo;

use Demo\MailerInterface;
use Demo\Message;
use Demo\NewsletterManager;

class NewsletterManagerTest extends \PHPUnit_Framework_TestCase
{
  public function testNewsletterManager()
  {
    $message = new Message('Test Betreff', 'Test-Inhalt Newsletter');
    $mailer = $this->prophesize(MailerInterface::class);
    $mailer->send('max@mustermann.de', $message)->shouldBeCalled();

    $newsletterManager = new NewsletterManager();
    $newsletterManager->setMailer($mailer->reveal());
    $newsletterManager->sendMail(['max@mustermann.de'], $message);
  }
}

Testgetriebener Newsletter-Manager

<?php

namespace Demo;

class NewsletterManager
{
  private $mailer;

  public function setMailer(MailerInterface $mailer)
  {
    $this->mailer = $mailer;
  }

  public function sendMail(array $recipients, Message $message)
  {
    foreach ($recipients as $recipient) {
      $this->mailer->send($recipient, $message);
    }
  }
}

Der Test läuft! Die Verwendung des NewsletterManagers ist jetzt ganz einfach möglich.

Klasse Newsletter

<?php

class Newsletter
{
  private $mailer;

  public function __construct()
  {
    $this->mailer = new Mailer('news@demo.de','Demo News','Reply-To: reply@demo.de');
  }

  public function send($recipients, $subject, $message) {
    foreach ($recipients as $to) {
      $this->mailer->send($to, $subject, $message);
    }
  }
}

Gerade jetzt in der Gegenüberstellung werden die Unterschiede deutlich. Mit Hilfe des Symfony Service Containers werden Abhängigkeiten zwischen Klassen intelligent verwaltet. UnitTests sind auch ohne Abhängigkeiten deutlich einfacher zu schreiben.

Verwendung des Newsletter-Managers

Jetzt haben wir zwar einen einfachen Newsletter-Manager, jedoch muss der Versand der E-Mail auch gestartet werden. In den meisten Fällen ist das entweder ein Webseite-Abruf – der Symfony Controller wird aufgerufen – oder wir starten den Versand über die Console – ein Command wird ausgeführt.

Dafür muss im Controller oder Command einfach der Service Container aufgerufen werden:

$newsletterManager = $this->get('newsletter_manager');
$newsletterManager->send($recipients, $message);

Symfony liest die Konfiguration aus und erstellt den NewsletterManager mit allen Abhängigkeiten.

Konfiguration E-Mail-Versand

Symfony kann aber noch mehr. Es werden Konfigurationsmöglichkeiten bereitgestellt, um weitere Anforderungen abdecken zu können, wie z.B. dass:

  • im Test- & Entwicklungssystem keine E-Mails versendet werden sollen,
  • der Absender leicht konfigurierbar ist und
  • E-Mails über Gmail versendet werden können.

Dafür wird wieder das YML-Format verwendet:

Versand deaktivieren

swiftmailer:
    disable_delivery: true

Versand an Test-Empfänger

swiftmailer:
    delivery_address: me@example.com

Versand über Gmail

swiftmailer:
    transport: gmail
    username:  me@gmail.com
    password:  myGmailPassword

Fazit

So einfach ist es, einen wiederverwendbaren NewsletterManager mit Symfony zu generieren. Dieses Beispiel zeigt dabei nur eine kleine Kostprobe der Möglichkeiten, die das Framework mitbringt.

Das Zusammenspiel von testgetriebener Entwicklung und Symfony führt fast zwangsläufig zu einem sauberen, leicht erweiterbaren, wartbaren und wiederverwendbaren Code.

Fragen & Kontakt

Wir freuen uns auf Ihr Feedback und erklären Ihnen gerne auch ausführlich die Code-Beispiele auf dieser Seite.

Sebastian Blum

 sebastian.blum@sblum.de
08167 532987-1