Refaktorisierung mit Symfony (Teil 2/5)

Dies ist der zweite Teil von “Refaktorisierung mit Symfony”, eine freie, deutsche Übersetzung und leicht abgewandelte Version des Originalartikels “Call the expert: A refactoring story (Part 2/5)” veröffentlicht im offziellen Symfony-Blog. Teil 1 der Serie befindet sich hier: Refaktorisierung mit Symfony (Teil 1/5).

Anmerkung: Texte und Kommentare wurden, soweit möglich, ins Deutsche übersetzt, damit sie dem Verständnis helfen. Den Sourcecode selbst habe ich Englisch gelassen, da Symfony-Funktionen und -Methoden, sowie PHP selbst, in Englisch sind und daher eine Übersetzung keinen Sinn machen würde. Im Allgemeinen empfiehlt es sich Sourcecode immer auf englisch zu schreiben. Bei den Kommentaren und der Dokumentation kommt es auf den Einzelfall an.

Die Geschichte

Refaktorisierung bedeutet eine Menge an Code zu verändern. Deshalb braucht man eine Möglichkeit zu Überprüfen, dass man dabei nichts kaputt macht. Also fragte Fabien, vor dem Beginn der Refaktorisierung, Vince nach seinen Unit- und Funktionstests.

Aber Vince hatte leider keine Unit- oder Funktionstests. Also entschieden sie sich, vor Beginn der Refaktorisierung, zunächst ein paar dieser Tests zu schreiben.

Der Symfony-Browser

In Symfony kann man, dank der sfTestBrowser-Klasse, seine Anwendung testen in dem man das Verhalten eines Browsers simuliert. Die Klasse verhält sich wie ein echter Browser aber sie nutzt nicht das HTTP-Protokoll um Symfony-Module aufzurufen. Das hat zwei Vorteile: Es ist einfach schneller und man hat zusätzlich die Möglichkeit Symfony-Objekte nach jedem Request direkt zu überprüfen.

// test/functional/frontend/productActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestBrowser();

Die Testdaten (Fixtures)

Da der Test reproduzierbar sein soll, müssen wir sicherstellen, dass die Daten in der Datenbank auch immer die Selben sind, wenn wir den Test starten. Also ging Vince hin und erstellte ein paar Testdaten:

// data/fixtures/product.yml
// Angepasste, eingedeutschte Daten
Category:
  toy_story:
    name: Toy Story
 
  Wall-E:
    name: Wall-E
 
Product:
  U-Command:
    title: Ferngesteuerter Wall-E
    image: walle.jpg
    description: |
      Ein ferngesteuerter Wall-E mit ganz vielen Funktionen.
      Unverzichtbar für jeden "Wall-E"-Fan!
    price:  59.99
    is_new: true
    is_in_stock: true
    category_id: Wall-E
 
  Interaction-Eve:
    title: Interaktive Eve
    image: eve.jpg
    description: |
      Eine sehr interaktive Eve.
      Sollte jeder zu Hause haben!
    price: 65.99
    is_new: true
    is_in_stock: true
    category_id: Wall-E
 
  vending:
    title: Wall-E Figurenset
    image: set.jpg
    description: |
      Komplettes Set mit 8 Figuren.
      Wer das nicht hat ... der hat's nicht!
    price: 6.99
    is_new: true
    is_in_stock: false
    category_id: Wall-E
 
  woody:
    title: Toy Story Woody
    image: woody.jpg
    description: |
      Woody aus dem bekannten Film Toy Story.
    price: 24.99
    is_new: false
    is_in_stock: true
    category_id: toy_story

In dieser Fixure-Datei haben wir zwei Kategorien und vier Produkte definiert. Bis auf das “Wall-E Figurenset” sind alle Produkte im Bestand.

Um die Daten aus der Fixure-Datei zu laden, benutzen wir die sfPropelData-Klasse. Standardmäßig löscht sfPropelData alle Daten aus den Datenbanktabellen, in die wir importieren, so dass jedes mal eine saubere Datenbank für unsere Tests zur Verfügung steht.

// initialize the database with fixtures
$databaseManager = new sfDatabaseManager($configuration);
$loader = new sfPropelData();
$loader->loadData(sfConfig::get('sf_data_dir').'/fixtures');

Symfony benutzt die Datenbank-Konfiguration der Testumgebung, wenn es für einen Funktionstest (functional test) genutzt wird. Wenn man sich also nicht die Standarddatenbank, die man zum Entwickeln benutzt, zerstören will, sollte man sich durch einen “test”-Eintrag in der database.yml-Konfigurationsdatei, eine spezielle Konfiguration für die Tests anlegen.

Jetzt wird bei jedem Start des Skripts die Datenbank aufgeräumt und die Fixture-Daten geladen. Selbst wenn unser Test die Daten verändert, wird dies keine Auswirkung auf den nächsten Testdurchlauf haben.

CSS3-Selektoren

Auf der Startseite müssen wir sicherstellen, dass wir eine Liste von Produkten vorfinden und das alle Produkte, die angezeigt werden, im Bestand sind. Wir testen jetzt ob das Produkt “Toy Story Woody” angezeigt wird, aber nicht das “Wall-E Figurenset”.

$browser->
  get('/')->
  isStatusCode(200)->
  isRequestParameter('module', 'product')->
  isRequestParameter('action', 'index')->
  checkResponseElement('body', '/Toy Story Woody/')->
  checkResponseElement('body', '!/Wall-E Figurenset/')
;

Das Skript ist selbsterklärend:

  • Ruf die Startseite ab (/).
  • Überprüfe, dass der Body (<body>) den Produkttitel enthält.
  • Überprüfe, dass die Seite nicht das Produkt enthält, dass nicht im Bestand ist.

Wenn ein Produkt neu ist (is_new Spalte in der Datenbank), wird der Text “NEU!” an den Titel angehängt. Bevor wir den Test schreiben, der überprüft ob das korrekt funktioniert, schauen wir uns das Template der Startseite an:

<h1>Unsere Produkte</h1>
 
<?php foreach ($products as $product): ?>
  <div>
    <h2>
      <?php echo $product->getTitle() ?>
      <?php if ($product->getIsNew()): ?><span style="margin-left: 10px; color: #e55">NEU!</span><?php endif; ?>
    </h2>
    <div style="margin-bottom: 10px">
      <em>Kategorie</em>: <?php echo $product->getCategory()->getName() ?> -
      <em>Preis</em>: Only $<?php echo $product->getPrice() ?> - 
      <?php if (in_array($product->getId(), array_keys($sf_user->getAttribute('favorites', array())))): ?>
        <a href="<?php echo url_for('product/removeFromFavorites?id='.$product->getId()) ?>"><img src="/images/favorite.png" /></a>
      <?php else: ?>
        <small><?php echo link_to('Zu meinen Favoriten hinzufügen', 'product/addToFavorites?id='.$product->getId()) ?></small>
      <?php endif; ?>
    </div>
    <div>
      <div style="float: left">
        <img width="100px" src="/images/products/<?php echo $product->getImage() ?>" />
      </div>
      <p>
        <?php echo $product->getDescription() ?>
        <?php if ($sf_user->isAuthenticated()): ?>
          <p style="text-align: right"><a href="<?php echo url_for('product/edit?id='.$product->getId()) ?>">Produkt editieren</a></p>
        <?php endif; ?>
      </p>
      <br style="clear: both" />
    </div>
    <div style="text-align: right">
      <?php echo link_to(image_tag('/images/add_to_cart.png'), 'product/buy?id='.$product->getId()) ?>
    </div>
    <hr />
  </div>
<?php endforeach; ?>

Um zu Überprüfen ob der Text “NEU” hinter den Produkttitel gesetzt wird, können wir nicht einfach überprüfen ob “NEU” im Body-Tag steht. Das wäre etwas ungenau und für einen brauchbareren Test müssen wir den Test präziser schreiben. In Symfony ist dies ziemlich einfach, da die checkResponseElement()-Methode CSS3-Selektoren als erstes Argument annimmt:

$browser->
  get('/')->
  checkResponseElement('h2:contains("NEU")', 2)
;

Hier überprüfen wir, dass es genau zwei H2-Tags haben, die das Wort “NEU” enthalten.

Jetzt müssen wir noch das Editieren von Produkten testen. Das Szenario dafür ist das folgende:

  • Einloggen als Administrator
  • Auf den “Produkt editieren”-Link klicken
  • Das Formular mit neuen Daten ausfüllen und eine neue Datei hochladen
  • Das Formular abschicken
  • Überprüfen, dass die abgeschickten Daten auf der Homepage angezeigt werden
  • Ausloggen

Um uns als Administrator einzuloggen, müssen wir auf den “Einloggen”-Link klicken:

$browser->
  click('Einloggen')->
  isRedirected()->
  isRequestParameter('module', 'user')->
 
  isRequestParameter('action', 'signin')->
  followRedirect()->
  isStatusCode(200)->
  isRequestParameter('module', 'product')->
  isRequestParameter('action', 'index')->
  checkResponseElement('body', '!/Einloggen/')->
  checkResponseElement('body', '/Ausloggen/')
;

Nach der Authentifizierung, leitet die signin-Action den Benutzer bzw. Browser auf die Startseite. Wir überprüfen dann, dass der “Einloggen”-Link nicht mehr existiert und durch einen “Ausloggen”-Link ersetzt wurde.

Jetzt wo wir eingeloggt sind, können wir auf den “Produkt editieren”-Link klicken. Aber auf der Startseite sind mehrere Links mit diesem Namen. Wir nehmen also an, wir wollen auf den zweiten Link klicken:

// Das position-Attribute ist neu in Symfony 1.2
$browser->
  click('Edit this product', array(), array('position' => 2))->
  isStatusCode(200)->
  isRequestParameter('module', 'product')->
  isRequestParameter('action', 'edit')->
  checkResponseElement('h2', '/Ferngesteuerter Wall-E/')
;

Nach ein paar grundlegenden Überprüfungen auf der Seite, sind wir bereit das Formular abzuschicken:

$browser->
  click('Save', array('product' => array(
    'price'  => '10',
    'image'  => dirname(__FILE__).'/../../../web/images/products/eve.jpg',
    'is_new' => false,
  )))
;

Wenn man mit click() einen Button anklickt, kann man die Werte für Felder, die wir überschreiben wollen, mit übergeben. In diesem Beispiel ändern wir die Werte der Felder price, is_new und wir laden ein Bild hoch in dem wir den vollständigen Pfad zu der Datei angeben, die wir hochladen wollen.

Nachdem wir überprüft haben, dass wir wieder auf die Startseite weitergeleitet worden sind, können wir testen ob unsere Änderungen aufgenommen wurden:

$browser->
  isRedirected()->
  followRedirect()->
  isStatusCode(200)->
  isRequestParameter('module', 'product')->
  isRequestParameter('action', 'index')
  checkResponseElement('h2:contains("NEU")', 1)->
  checkResponseElement(sprintf('img[src$="%s"]', sha1('eve.jpg').'.jpg'))
;

Jetzt können wir uns ausloggen:

$browser->
  click('Ausloggen')->
  isRequestParameter('module', 'user')->
  isRequestParameter('action', 'signout')->
  isRedirected()->
  followRedirect()->
  isStatusCode(200)->
  isRequestParameter('module', 'product')->
  isRequestParameter('action', 'index')->
  checkResponseElement('body', '/Einloggen/')->
  checkResponseElement('body', '!/Ausloggen/')
;

Ab jetzt, immer wenn wir Änderungen am Code vornehmen, lassen wir die Funktionstests laufen, um sicher zu sein, dass wir keine Features dabei kaputt gemacht haben:

$ php symfony test:functional frontend productActions

Anwendungsspezifische Browser

Die Funktionstests, die wir geschrieben haben, sind relativ simpel und natürlich müssen wir noch etwas mehr Code schreiben um alle Features der Webseite abzudecken. Und während die Test-Suite wächst, werden wir voraussichtlich einiges an Code kopieren und mehrfach nutzen. So zum Beispiel der Einloggen- und Ausloggen-Prozess. Um zu verhindern den selben Prozess, immer und immer wieder zu wiederholen, ist es eine gute Idee, eine, auf die Anwendung zugeschnittene, Browser-Klasse zu schreiben:

class StoreBrowser extends sfTestBrowser
{
  public function signin() // einloggen
  {
    return $this->
      get('/user/signin')->
      isRedirected()->
      isRequestParameter('module', 'user')->
      isRequestParameter('action', 'signin')->
      followRedirect()->
      isStatusCode(200)->
      isRequestParameter('module', 'product')->
      isRequestParameter('action', 'index')->
      checkResponseElement('body', '!/Einloggen/')->
      checkResponseElement('body', '/Ausloggen/')
    ;
  }
 
  public function signout() // ausloggen
  {
    return $this->
 
      get('/user/signout')->
      isRequestParameter('module', 'user')->
      isRequestParameter('action', 'signout')->
      isRedirected()->
      followRedirect()->
      isStatusCode(200)->
      isRequestParameter('module', 'product')->
      isRequestParameter('action', 'index')->
      checkResponseElement('body', '/Einloggen/')->
      checkResponseElement('body', '!/Ausloggen/')
    ;
  }
}

Hier ist ein simpler Test, der sich nur einloggt und dann wieder ausloggt:

include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
// Datenbank mit fixtures initialisieren
$databaseManager = new sfDatabaseManager($configuration);
$loader = new sfPropelData();
$loader->loadData(sfConfig::get('sf_data_dir').'/fixtures');
 
$browser = new StoreBrowser();
 
$browser->
  signin()->
 
  // tue etwas während eingeloggt
 
  signout()
;

Das ist alles für Heute. Da wir nun von unserer Test-Suite unterstützt werden, kann Vince bequem in Teil 3 mit der Refaktorisierung anfangen.

http%3A%2F%2Fwww.anti-hype.de%2F2008%2F09%2F24%2Frefaktorisierung-mit-symfony-teil-25& layout=standard&show-faces=true&width=500& action=like&colorscheme=light" scrolling="no" frameborder="0" allowTransparency="true" style="border:none; overflow:hidden; width:500px; height:60px">

Kommentare (1)

[...] symfony-Blog gemacht. Mittlerweile sind die ersten beiden Teile komplett übersetzt und hier und hier verfügbar. Sehr [...]

Kommentieren?

Dein Kommentar