Testen
Da wir nun mehr und mehr Funktionalität in die Anwendung einbauen, ist jetzt wahrscheinlich der richtige Zeitpunkt um über das Testen zu sprechen.
Fun Fact: Ich habe beim Schreiben der Tests in diesem Kapitel einen Fehler gefunden.
Symfony setzt bei Unit-Tests auf PHPUnit. Lass es uns installieren:
1
$ symfony composer req phpunit --dev
Unit-Tests schreiben
SpamChecker
ist die erste Klasse, für die wir Tests schreiben werden. Generiere einen Unit-Test:
1
$ symfony console make:test TestCase SpamCheckerTest
Das Testen des SpamCheckers ist eine Herausforderung, da wir die Akismet-API sicherlich nicht ständig aufrufen wollen. Wir werden die API mocken (simulieren).
Lasse uns einen ersten Test für den Fall schreiben, dass die API einen Fehler zurückgibt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -2,12 +2,26 @@
namespace App\Tests;
+use App\Entity\Comment;
+use App\SpamChecker;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Contracts\HttpClient\ResponseInterface;
class SpamCheckerTest extends TestCase
{
- public function testSomething(): void
+ public function testSpamScoreWithInvalidRequest(): void
{
- $this->assertTrue(true);
+ $comment = new Comment();
+ $comment->setCreatedAtValue();
+ $context = [];
+
+ $client = new MockHttpClient([new MockResponse('invalid', ['response_headers' => ['x-akismet-debug-help: Invalid key']])]);
+ $checker = new SpamChecker($client, 'abcde');
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Unable to check for spam: invalid (Invalid key).');
+ $checker->getSpamScore($comment, $context);
}
}
Die MockHttpClient
-Klasse ermöglicht es, jeden beliebigen HTTP-Server zu simulieren. Es wird eine Reihe von MockResponse
-Instanzen benötigen, die den erwarteten Body und die Response-Header enthalten.
Anschließend rufen wir die getSpamScore()
-Methode auf und überprüfen, mit Hilfe der expectException()
-Methode von PHPUnit, ob eine Ausnahme ausgelöst wird.
Führe die Tests aus, um sicherzustellen, dass sie erfolgreich sind:
1
$ symfony php bin/phpunit
Lasst uns Tests für den happy path hinzufügen:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -24,4 +24,32 @@ class SpamCheckerTest extends TestCase
$this->expectExceptionMessage('Unable to check for spam: invalid (Invalid key).');
$checker->getSpamScore($comment, $context);
}
+
+ /**
+ * @dataProvider getComments
+ */
+ public function testSpamScore(int $expectedScore, ResponseInterface $response, Comment $comment, array $context)
+ {
+ $client = new MockHttpClient([$response]);
+ $checker = new SpamChecker($client, 'abcde');
+
+ $score = $checker->getSpamScore($comment, $context);
+ $this->assertSame($expectedScore, $score);
+ }
+
+ public function getComments(): iterable
+ {
+ $comment = new Comment();
+ $comment->setCreatedAtValue();
+ $context = [];
+
+ $response = new MockResponse('', ['response_headers' => ['x-akismet-pro-tip: discard']]);
+ yield 'blatant_spam' => [2, $response, $comment, $context];
+
+ $response = new MockResponse('true');
+ yield 'spam' => [1, $response, $comment, $context];
+
+ $response = new MockResponse('false');
+ yield 'ham' => [0, $response, $comment, $context];
+ }
}
Der PHPUnit Data Provider ermöglicht es uns, die gleiche Testlogik für mehrere Testfälle wiederzuverwenden.
Funktionale Tests für Controller schreiben
Das Testen von Controllern ist etwas anders als das Testen einer "normalen" PHP-Klasse, da wir sie im Rahmen einer HTTP-Anfrage ausführen wollen.
Erstelle einen funktionalen Test für den Conference-Controller:
Wenn wir Symfony
anstelle von PHPUnit\Framework\TestCase
als Basis-Klasse für unsere Tests nutzen, haben wir eine gute Abstraktion für unsere Funktionalen Tests.
Die $client
-Variable simuliert einen Browser. Anstatt jedoch HTTP-Anfragen an den Server zu senden, ruft dieser die Symfony-Anwendung direkt auf. Dieses Vorgehen hat mehrere Vorteile: Es ist viel schneller als eine tatsächliche Kommunikation zwischen Client und Server, und sie ermöglicht es auch, in den Tests den Zustand der Services nach jedem HTTP-Request zu überprüfen.
Dieser erste Test prüft, ob die Homepage eine HTTP-Response mit Status 200 zurückgibt.
Assertions wie assertResponseIsSuccessful
werden zusätzlich zu PHPUnit hinzugefügt, um Dir die Arbeit zu erleichtern. Symfony stellt viele solcher Assertions zur Verfügung.
Tip
Wir haben /
fix als URL verwendet, anstatt sie über den Router zu generieren. Dies geschieht absichtlich, da das Testen von Produktiv-URLs Teil dessen ist, was wir testen wollen. Sobald Du den Routenpfad änderst, werden die Tests fehlschlagen und dich dadurch freundlich daran erinnern, dass Du die alte URL wahrscheinlich auf die neue umleiten solltest, um gegenüber Suchmaschinen und Websites, die auf Deine Website verweisen, nett zu sein.
Konfiguration der Test-Umgebung
Standardmäßig werden PHPUnit-Tests in der test
Symfony-Umgebung ausgeführt. Das ist in der PHPUnit-Konfigurations-Datei festgelegt:
Damit Tests funktionieren, müssen wir das AKISMET_KEY
-Secret für diese Umgebung festlegen:
1
$ symfony console secrets:set AKISMET_KEY --env=test
Mit einer Testdatenbank arbeiten
Wie wir schon eher gesehen haben, stellt die Symfony CLI automatisch die DATABASE_URL
-Environment-Variable (Umgebungsvariable) bereit. Genauso als wenn PHPUnit ausgeführt wurde und verändert damit den Datenbank-Namen von main
zu main_test
, so dass die Tests ihre eigene Datenbank haben. Das ist sehr wichtig, weil wir auch stabile Daten brauchen um unsere Tests auszuführen, und wir sicherlich nicht die Daten in unserer Development-Datenbank überschreiben wollen.
Bevor wir unsere Tests ausführen können, müssen wir die test
-Datenbank "initialisieren" (Datenbank erstellen und migrieren):
1 2
$ symfony console doctrine:database:create --env=test
$ symfony console doctrine:migrations:migrate -n --env=test
On Linux and similiar OSes, you can use
APP_ENV=prod
instead of--env=prod
:1
$ APP_ENV=prod symfony console doctrine:database:create
Wenn Du nun die Tests ausführst, wird PHPUnit nicht mehr mit Deiner Development-Datenbank kommunizieren. Um nur die neuen Tests auszuführen, füge den Pfad zu deren Klassenpfad hinzu:
1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php
Tip
Wenn ein Test fehlschlägt, kann es sinnvoll sein, sich das Response-Objekt anzusehen. Greife über $client->getResponse()
und echo
darauf zu, um zu sehen, wie es aussieht.
Fixtures erstellen
Um die Kommentarliste, Pagination und die Formularübermittlung testen zu können, müssen wir die Datenbank mit Daten befüllen. Außerdem wollen wir, dass die Daten bei allen Testläufen identisch sind, damit die Tests erfolgreich durchlaufen. Fixtures sind genau das, was wir brauchen.
Installiere das Doctrine Fixtures Bundle:
1
$ symfony composer req orm-fixtures --dev
Während der Installation wurde ein neues src/DataFixtures/
-Verzeichnis mit einer Beispielklasse erstellt, die angepasst werden kann. Füge vorerst zwei Konferenzen und einen Kommentar hinzu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,6 +2,8 @@
namespace App\DataFixtures;
+use App\Entity\Comment;
+use App\Entity\Conference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@@ -9,8 +11,24 @@ class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
- // $product = new Product();
- // $manager->persist($product);
+ $amsterdam = new Conference();
+ $amsterdam->setCity('Amsterdam');
+ $amsterdam->setYear('2019');
+ $amsterdam->setIsInternational(true);
+ $manager->persist($amsterdam);
+
+ $paris = new Conference();
+ $paris->setCity('Paris');
+ $paris->setYear('2020');
+ $paris->setIsInternational(false);
+ $manager->persist($paris);
+
+ $comment1 = new Comment();
+ $comment1->setConference($amsterdam);
+ $comment1->setAuthor('Fabien');
+ $comment1->setEmail('fabien@example.com');
+ $comment1->setText('This was a great conference.');
+ $manager->persist($comment1);
$manager->flush();
}
Wenn wir die Fixtures laden, werden alle Daten entfernt, einschließlich der Admin-User. Um das zu vermeiden, fügen wir den Admin-User den Fixtures hinzu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,13 +2,22 @@
namespace App\DataFixtures;
+use App\Entity\Admin;
use App\Entity\Comment;
use App\Entity\Conference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
+use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
class AppFixtures extends Fixture
{
+ private $passwordHasherFactory;
+
+ public function __construct(PasswordHasherFactoryInterface $passwordHasherFactory)
+ {
+ $this->passwordHasherFactory = $passwordHasherFactory;
+ }
+
public function load(ObjectManager $manager): void
{
$amsterdam = new Conference();
@@ -30,6 +39,12 @@ class AppFixtures extends Fixture
$comment1->setText('This was a great conference.');
$manager->persist($comment1);
+ $admin = new Admin();
+ $admin->setRoles(['ROLE_ADMIN']);
+ $admin->setUsername('admin');
+ $admin->setPassword($this->passwordHasherFactory->getPasswordHasher(Admin::class)->hash('admin', null));
+ $manager->persist($admin);
+
$manager->flush();
}
}
Tip
Falls Du Dich nicht mehr daran erinnerst, welchen Service Du für eine bestimmte Aufgabe verwenden musst, verwende debug:autowiring
mit einem Keyword:
1
$ symfony console debug:autowiring hasher
Fixtures laden
Lade die Fixtures für die test
-Environment/Datenbank:
1
$ symfony console doctrine:fixtures:load --env=test
Eine Website in Funktionalen Tests crawlen
Wie wir gesehen haben, simuliert der in den Tests verwendete HTTP-Client einen Browser, sodass wir durch die Website navigieren können, als würden wir einen Headless-Browser verwenden.
Füge einen neuen Test hinzu, der von der Homepage aus auf eine Konferenzseite klickt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -14,4 +14,19 @@ class ConferenceControllerTest extends WebTestCase
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h2', 'Give your feedback');
}
+
+ public function testConferencePage()
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/');
+
+ $this->assertCount(2, $crawler->filter('h4'));
+
+ $client->clickLink('View');
+
+ $this->assertPageTitleContains('Amsterdam');
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+ $this->assertSelectorExists('div:contains("There are 1 comments")');
+ }
}
Lasse uns in einfachen Worten beschreiben, was in diesem Test passiert:
- Wie beim ersten Test gehen wir auf die Homepage;
- Die
request()
-Methode gibt eineCrawler
-Instanz zurück, die hilft, Elemente auf der Seite zu finden (wie Links, Formulare oder alles, was Du mit CSS-Selektoren oder XPath erreichen kannst); - Mit Hilfe eines CSS-Selektors prüfen wir, dass zwei Konferenzen auf der Homepage aufgelistet sind;
- Dann klicken wir auf den Link "View" (Symfony kann nicht mehr als einen Link gleichzeitig anklicken, darum wählt es automatisch den ersten, den es findet);
- Wir testen den Seitentitel, die Response und die Seitenüberschrift
<h2>
, um sicher zu gehen, dass wir auf der richtigen Seite sind (wir hätten auch die zugehörige Route überprüfen können); - Schließlich prüfen wir, dass es einen Kommentar auf der Seite gibt.
div:contains()
ist zwar kein gültiger CSS-Selektor, Symfony hat sich jedoch einige nützliche Ergänzungen von jQuery abgeschaut.
Anstatt auf den Text zu klicken (z.B. View
), hätten wir den Link auch über einen CSS-Selektor auswählen können:
1
$client->click($crawler->filter('h4 + p a')->link());
Überprüfe, ob der neue Test grün ist:
1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php
Ein Formular in einem Funktionalen Test abschicken
Möchtest Du das nächste Level erreichen? Versuche, einen neuen Kommentar mit einem Foto auf einer Konferenz aus einem Test heraus hinzuzufügen, indem Du das Abschicken eines Formulares simulierst. Das scheint ehrgeizig zu sein, nicht wahr? Schaue Dir den benötigten Code an: nicht komplexer als das, was wir bereits geschrieben haben:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -29,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
$this->assertSelectorTextContains('h2', 'Amsterdam 2019');
$this->assertSelectorExists('div:contains("There are 1 comments")');
}
+
+ public function testCommentSubmission()
+ {
+ $client = static::createClient();
+ $client->request('GET', '/conference/amsterdam-2019');
+ $client->submitForm('Submit', [
+ 'comment_form[author]' => 'Fabien',
+ 'comment_form[text]' => 'Some feedback from an automated functional test',
+ 'comment_form[email]' => 'me@automat.ed',
+ 'comment_form[photo]' => dirname(__DIR__, 2).'/public/images/under-construction.gif',
+ ]);
+ $this->assertResponseRedirects();
+ $client->followRedirect();
+ $this->assertSelectorExists('div:contains("There are 2 comments")');
+ }
}
Um ein Formular über submitForm()
abzuschicken, kannst Du die Namen der Felder über die Browser-DevTools oder über den Formular-Tab des Symfony Profilers finden. Beachte die clevere Wiederverwendung des "under construction"-Bildes!
Führe die Tests erneut durch, um sicherzustellen, dass alles grün ist:
1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php
Wenn Du das Ergebnis in einem Browser überprüfen willst, stoppe den Webserver und starte ihn noch einmal in der test
-Umgebung:
1 2
$ symfony server:stop
$ symfony server:start -d --env=test
Fixtures erneut laden
Wenn Du die Tests ein zweites Mal ausführst, sollten sie fehlschlagen. Da es nun mehr Kommentare in der Datenbank gibt, ist die Assertion, welche die Anzahl der Kommentare überprüft, nicht mehr korrekt. Wir müssen den Zustand der Datenbank zwischen jedem Durchlauf zurücksetzen, indem wir die Fixtures vor jedem Durchlauf neu laden:
1 2
$ symfony console doctrine:fixtures:load --env=test
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php
Deinen Workflow mit einem Makefile automatisieren
Es ist ärgerlich, sich eine Reihe von Befehlen merken zu müssen, um die Tests auszuführen. Dies sollte zumindest dokumentiert werden. Eine Dokumentation sollte jedoch nur der letzte Ausweg sein. Wie sieht es stattdessen mit der Automatisierung der täglichen Aktivitäten aus? Das würde als Dokumentation dienen, anderen Entwickler*innen helfen, sie zu entdecken und ihre Arbeit erleichtern und beschleunigen.
Die Verwendung von einem Makefile
ist eine Möglichkeit, Befehle zu automatisieren:
Warning
Nach einer Regel für Make-Dateien (Makefiles) muss die Einrückung aus einem einzelnen Tabulator-Zeichen anstelle von Leerzeichen bestehen.
Beachte das -n
-Flag des Doctrine Befehls; es ist ein globales Flag für Symfony Befehle, das sie nicht interaktiv macht.
Wann immer Du die Tests ausführen möchtest, verwende make tests
:
1
$ make tests
Die Datenbank nach jedem Test zurücksetzen
Das Zurücksetzen der Datenbank nach jedem Testlauf ist schön, aber wirklich unabhängige Tests sind noch besser. Wir wollen nicht, dass sich ein Test auf die Ergebnisse der vorherigen stützt. Eine Änderung der Reihenfolge der Tests sollte das Ergebnis nicht verändern. Wie wir jetzt herausfinden werden, ist dies im Moment nicht der Fall.
Verschiebe den testConferencePage
-Test hinter den testCommentSubmission
-Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -15,21 +15,6 @@ class ConferenceControllerTest extends WebTestCase
$this->assertSelectorTextContains('h2', 'Give your feedback');
}
- public function testConferencePage()
- {
- $client = static::createClient();
- $crawler = $client->request('GET', '/');
-
- $this->assertCount(2, $crawler->filter('h4'));
-
- $client->clickLink('View');
-
- $this->assertPageTitleContains('Amsterdam');
- $this->assertResponseIsSuccessful();
- $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
- $this->assertSelectorExists('div:contains("There are 1 comments")');
- }
-
public function testCommentSubmission()
{
$client = static::createClient();
@@ -44,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
$client->followRedirect();
$this->assertSelectorExists('div:contains("There are 2 comments")');
}
+
+ public function testConferencePage()
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/');
+
+ $this->assertCount(2, $crawler->filter('h4'));
+
+ $client->clickLink('View');
+
+ $this->assertPageTitleContains('Amsterdam');
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+ $this->assertSelectorExists('div:contains("There are 1 comments")');
+ }
}
Jetzt schlagen die Tests fehl.
Installiere das DoctrineTestBundle, um die Datenbank zwischen den Tests zurückzusetzen:
1
$ symfony composer req "dama/doctrine-test-bundle:^6" --dev
Du musst die Ausführung des Recipes bestätigen (da es sich nicht um ein "offiziell" unterstütztes Bundle handelt):
1 2 3 4 5 6 7 8 9 10 11
Symfony operations: 1 recipe (a5c79a9ff21bc3ae26d9bb25f1262ed7)
- WARNING dama/doctrine-test-bundle (>=4.0): From github.com/symfony/recipes-contrib:master
The recipe for this package comes from the "contrib" repository, which is open to community contributions.
Review the recipe at https://github.com/symfony/recipes-contrib/tree/master/dama/doctrine-test-bundle/4.0
Do you want to execute this recipe?
[y] Yes
[n] No
[a] Yes for all packages, only for the current installation session
[p] Yes permanently, never ask again for this project
(defaults to n): p
Aktiviere den PHPUnit-Listener:
1 2 3 4 5 6 7 8 9 10 11 12 13
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -29,6 +29,10 @@
</include>
</coverage>
+ <extensions>
+ <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
+ </extensions>
+
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
Und fertig. Alle Änderungen, die in Tests vorgenommen werden, werden nun am Ende jedes Tests automatisch zurückgesetzt.
Die Tests sollten wieder grün sein:
1
$ make tests
Einen echten Browser für Funktionale Tests verwenden
Funktionale Tests verwenden einen speziellen Browser, der den Symfony-Layer direkt aufruft. Aber Du kannst auch einen echten Browser und den echten HTTP-Layer dank Symfony Panther verwenden:
1
$ symfony composer req panther --dev
Du kannst dann Tests schreiben, die einen echten Google Chrome-Browser verwenden. Dazu benötigst Du die folgenden Änderungen:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -2,13 +2,13 @@
namespace App\Tests\Controller;
-use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\Panther\PantherTestCase;
-class ConferenceControllerTest extends WebTestCase
+class ConferenceControllerTest extends PantherTestCase
{
public function testIndex()
{
- $client = static::createClient();
+ $client = static::createPantherClient(['external_base_uri' => $_SERVER['SYMFONY_PROJECT_DEFAULT_ROUTE_URL']]);
$client->request('GET', '/');
$this->assertResponseIsSuccessful();
Die Environment-Variable SYMFONY_PROJECT_DEFAULT_ROUTE_URL
enthält die URL des lokalen Webservers.
Den richtigen Test Typen wählen
Bisher haben wir drei verschiedene Test Typen erstellt. Während wir das Maker-Bundle nur genutzt haben um Unit-Test-Klassen zu generieren, könnten wir es auch zur Generierung der anderen Test-Klassen nutzen:
1 2 3
$ symfony console make:test WebTestCase Controller\\ConferenceController
$ symfony console make:test PantherTestCase Controller\\ConferenceController
Das Maker-Bundle unterstützt die Generierung der folgenden Test Typen, abhängig davon wie Du Deine Applikation testen möchtest:
TestCase
: Standard PHPUnit-Tests;KernelTestCase
: Standard Tests die Zugang zu Symfony Diensten haben;WebTestCase
: um Browser-ähnliche Szenarios, aber ohne Javascript Code auszuführen;ApiTestCase
: für API-orientierte Szenarios;PantherTestCase
: für End-zu-End Szenarios; welche einen echten Browser oder HTTP-Client und einen echten Web-Server nutzen.
Funktionale "Black Box"-Tests mit Blackfire durchführen
Eine weitere Möglichkeit, Funktionale Tests durchzuführen, ist die Verwendung des Blackfire-Players. Zusätzlich zu dem, was Du mit Funktionalen Tests machen kannst, kann der Blackfire-Player auch Performance Tests durchführen.
Schau Dir den Schritt über Performance an, um mehr zu erfahren.