SymfonyWorld Online 2020
100% online
30+ talks + workshops
Live + Replay watch talks later

Stap 17: Testen

5.0 version
Maintained

Testen

Nu we meer en meer functionaliteit aan de applicatie aan het toevoegen zijn moeten we eens praten over hoe we deze gaan testen.

Leuke anekdote: Ik vond een bug tijdens het schrijven van de tests in dit hoofdstuk.

Symfony maakt gebruik van PHPUnit voor unit tests. We installeren dit:

1
$ symfony composer req phpunit

Unit tests schrijven

De eerste class waarvoor we tests gaan schrijven is SpamChecker. Genereer een unittest:

1
$ symfony console make:unit-test SpamCheckerTest

Het testen van de SpamChecker is een uitdaging, want we willen niet telkens de Akismet API aanroepen. We gaan de API mocken.

We schrijven een eerste test voor wanneer de API een fout teruggeeft:

patch_file
 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()
+    public function testSpamScoreWithInvalidRequest()
     {
-        $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);
     }
 }

De MockHttpClient class maakt het mogelijk om elke HTTP server te mocken. Deze aanvaard een array met MockResponse instanties die de verwachte body en Response headers bevatten.

Vervolgens roepen we de getSpamScore() methode aan en controleren we of er een exception wordt getriggerd via de expectException() methode van PHPUnit.

Voer de tests uit om te controleren of ze slagen:

1
$ symfony php bin/phpunit

Laten we tests toevoegen voor de happy flow:

patch_file
 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];
+    }
 }

PHPUnit-dataproviders stellen ons in staat om dezelfde testlogica voor meerdere testcases te hergebruiken.

Functionele tests schrijven voor controllers

Het testen van controllers is een beetje anders dan het testen van een “gewone” PHP class, omdat we ze willen uitvoeren in de context van een HTTP request.

Installeer enkele extra dependencies die nodig zijn voor functionele tests:

1
$ symfony composer require browser-kit --dev

Maak een functionele test voor de Conference-controller:

tests/Controller/ConferenceControllerTest.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ConferenceControllerTest extends WebTestCase
{
    public function testIndex()
    {
        $client = static::createClient();
        $client->request('GET', '/');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h2', 'Give your feedback');
    }
}

Deze eerste test controleert of de homepage een 200 HTTP response terugstuurt.

De $client variabele simuleert een browser. In plaats van HTTP-calls naar de server uit te voeren, wordt de Symfony-toepassing rechtstreeks opgeroepen. Deze strategie heeft een aantal voordelen: het is veel sneller dan te moeten communiceren tussen de client en de server, maar het maakt het ook mogelijk om na elke HTTP-request, de staat van de services te inspecteren.

Assertions zoals assertResponseIsSuccessful worden boven op PHPUnit toegevoegd om het werk te vergemakkelijken. Zo zijn er veel van dit soort assertions door Symfony toegevoegd.

Tip

We hebben / gebruikt voor de URL, in plaats van deze te genereren via de router. Dit doen we bewust zo, omdat het testen van de URL’s van eindgebruikers deel uitmaakt van wat we willen testen. Als het routepad wijzigt, zullen de tests falen en je herinneren dat je waarschijnlijk de oude URL moet omleiden naar de nieuwe, om zoekmachines en externe websites die er naar linken tevreden te houden.

Notitie

We hadden de test ook kunnen genereren via de maker bundle:

1
$ symfony console make:functional-test Controller\\ConferenceController

PHPUnit-tests worden uitgevoerd in een speciale test omgeving. We moeten het AKISMET_KEY secret voor deze omgeving toevoegen:

1
$ APP_ENV=test symfony console secrets:set AKISMET_KEY

Voer alleen de nieuwe tests uit door het pad naar de class mee te geven:

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Tip

Als een test mislukt, kan het nuttig zijn om het Response-object nader te onderzoeken. Je kan het object benaderen via $client->getResponse() en middels echo bekijken hoe het eruitziet.

Fixtures definiëren

Om de lijst van reacties, de paginering en het indienen van het formulier te kunnen testen, moeten we de database vullen met enkele gegevens. En we willen dat de gegevens tussen de testruns hetzelfde zijn om de tests te laten slagen. Fixtures zijn precies wat we nodig hebben.

Installeer de Doctrine Fixtures bundle:

1
$ symfony composer req orm-fixtures --dev

Tijdens de installatie is een nieuwe src/DataFixtures/ directory aangemaakt met een voorbeeldclass, klaar om te worden aangepast. Voeg voor nu twee conferenties en een reactie toe:

patch_file
 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\Common\Persistence\ObjectManager;

@@ -9,8 +11,24 @@ class AppFixtures extends Fixture
 {
     public function load(ObjectManager $manager)
     {
-        // $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('[email protected]');
+        $comment1->setText('This was a great conference.');
+        $manager->persist($comment1);

         $manager->flush();
     }

Wanneer we de fixtures laden, worden alle gegevens verwijderd; inclusief de admin-gebruiker. Om dat te voorkomen, voegen we de admin-gebruiker toe aan de fixtures:

 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\Common\Persistence\ObjectManager;
+use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;

 class AppFixtures extends Fixture
 {
+    private $encoderFactory;
+
+    public function __construct(EncoderFactoryInterface $encoderFactory)
+    {
+        $this->encoderFactory = $encoderFactory;
+    }
+
     public function load(ObjectManager $manager)
     {
         $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->encoderFactory->getEncoder(Admin::class)->encodePassword('admin', null));
+        $manager->persist($admin);
+
         $manager->flush();
     }
 }

Tip

Als je niet meer weet welke service je voor een bepaalde taak moet gebruiken, gebruik dan debug:autowiring met een bepaald trefwoord:

1
$ symfony console debug:autowiring encoder

Fixtures laden

Laad de fixtures in de database. Wees gewaarschuwd dat het alle gegevens die momenteel in de database zijn opgeslagen zal verwijderen (als je dit gedrag wil vermijden, blijf dan lezen).

1
$ symfony console doctrine:fixtures:load

Het crawlen van een website in functionele tests

Zoals we hebben gezien, simuleert de HTTP-client die in de tests wordt gebruikt een browser, zodat we door de website kunnen navigeren alsof we een headless browser gebruiken.

Voeg een nieuwe test toe die op een conferentiepagina van de homepage klikt:

patch_file
 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")');
+    }
 }

Laten we in eenvoudige taal beschrijven wat er in deze test gebeurt:

  • Net als bij de eerste test gaan we naar de homepage;
  • De request() methode geeft een Crawler instantie terug die je helpt elementen op de pagina te vinden (zoals links, formulieren, of alles wat je kan bereiken met CSS-selectors of XPath);
  • Dankzij een CSS-selector kunnen we vaststellen dat er twee conferenties op de homepage staan vermeld;
  • Vervolgens klikken we op de link “Bekijken” (omdat er niet meer dan één link tegelijk kan worden aangeklikt, kiest Symfony automatisch de eerste die gevonden wordt);
  • We checken op de paginatitel, de response en de <h2> van de pagina, om er zeker van te zijn dat we op de juiste pagina zitten (we hadden ook de route kunnen vergelijken);
  • Tot slot stellen we vast dat er 1 reactie op de pagina staat. div:contains() is geen geldige CSS-selector, maar Symfony heeft een aantal leuke toevoegingen, ontleend aan jQuery.

In plaats van op tekst te klikken (d.w.z. Bekijken ), hadden we de link ook via een CSS-selector kunnen aanduiden:

1
$client->click($crawler->filter('h4 + p a')->link());

Controleer of de nieuwe test groen is:

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Werken met een testdatabase

Standaard worden de tests uitgevoerd in de Symfony test-omgeving, zoals aangeduid in het phpunit.xml.dist bestand:

phpunit.xml.dist
1
2
3
4
5
<phpunit>
    <php>
        <server name="APP_ENV" value="test" force="true" />
    </php>
</phpunit>

Als je een andere database wilt gebruiken voor je tests, overschrijf dan de DATABASE_URL omgevingsvariabele in het .env.test bestand:

1
2
3
4
5
6
7
8
--- a/.env.test
+++ b/.env.test
@@ -1,4 +1,5 @@
 # define your env variables for the test env here
+DATABASE_URL=postgres://main:[email protected]:32773/test?sslmode=disable&charset=utf8
 KERNEL_CLASS='App\Kernel'
 APP_SECRET='$ecretf0rt3st'
 SYMFONY_DEPRECATIONS_HELPER=999999

Laad de fixtures voor de test omgeving/database:

1
$ APP_ENV=test symfony console doctrine:fixtures:load

Voor de rest van deze stap zullen we de DATABASE_URL omgevingsvariabele niet opnieuw definiëren. Het gebruik van dezelfde database als de dev-omgeving voor tests heeft een aantal voordelen die we in de volgende sectie zullen zien.

Een formulier indienen in een functionele test

Wil je naar een hoger niveau? Probeer een nieuwe reactie met foto toe te voegen op een conferentie via een test door het indienen van een formulier te simuleren. Dat lijkt ambitieus, nietwaar? Kijk naar de benodigde code: niet complexer dan wat we al schreven:

patch_file
 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]' => '[email protected]',
+            'comment_form[photo]' => dirname(__DIR__, 2).'/public/images/under-construction.gif',
+        ]);
+        $this->assertResponseRedirects();
+        $client->followRedirect();
+        $this->assertSelectorExists('div:contains("There are 2 comments")');
+    }
 }

Om een formulier in te dienen via submitForm(), vind je de veldnamen via de browser DevTools of via het Symfony Profiler Form-panel. Merk het slimme hergebruik van het under construction plaatje op!

Voer de tests opnieuw uit om te controleren of alles groen is:

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Een voordeel van het gebruik van de “dev” database voor tests is dat je het resultaat kan controleren in een browser:

De fixtures herladen

Als je de tests een tweede keer uitvoert, zouden ze moeten mislukken. Aangezien er nu meer reacties in de database staan, is de bewering van het aantal reacties niet langer geldig. We moeten de data in de database tussen elke run resetten door de fixtures voor elke run opnieuw te laden:

1
2
$ symfony console doctrine:fixtures:load
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Jouw workflow automatiseren met een Makefile

Het is vervelend om een reeks commando’s te moeten onthouden bij het uitvoeren van de tests. Dit moet minstens worden gedocumenteerd. Maar documentatie moet een laatste redmiddel zijn. Kunnen we de day-to-day handelingen automatiseren? Dat zou kunnen dienen als documentatie, andere ontwikkelaars helpen om taken te terug te vinden en vergemakkelijkt en versnelt het ontwikkelen.

Het gebruik van een Makefile is een manier om commando’s te automatiseren:

Makefile
1
2
3
4
5
6
SHELL := /bin/bash

tests:
    symfony console doctrine:fixtures:load -n
    symfony php bin/phpunit
.PHONY: tests

Merk de -n optie op bij het Doctrine commando; het is een globale parameter op Symfony commando’s, die ze niet-interactief maakt.

Wanneer je de tests wilt uitvoeren, gebruik dan make tests:

1
$ make tests

De database resetten na elke test

Het resetten van de database na elke test is leuk, maar het is nog beter om echt onafhankelijke tests te hebben. We willen niet dat één test afhankelijk is van de resultaten van de vorige. Het wijzigen van de volgorde van de tests mag de uitkomst niet veranderen. Zoals we nu gaan ontdekken, is dat op dit moment niet het geval.

Verplaats de testConferencePage test na de testCommentSubmission test:

patch_file
 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
         $crawler = $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")');
+    }
 }

Tests falen nu.

Om de database tussen de tests door te resetten, installeer je de DoctrineTestBundle:

1
$ symfony composer require dama/doctrine-test-bundle --dev

Je moet het uitvoeren van de recipe bevestigen (aangezien het geen “officieel” ondersteunde bundle is):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Symfony operations: 1 recipe (d7f110145ba9f62430d1ad64d57ab069)
  -  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

Activeer de PHPUnit listener:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -27,6 +27,10 @@
         </whitelist>
     </filter>

+    <extensions>
+        <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
+    </extensions>
+
     <listeners>
         <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
     </listeners>

En klaar. Eventuele datawijzigingen gemaakt door tests worden nu aan het einde van elke test automatisch teruggedraaid.

De tests moeten weer groen zijn:

1
$ make tests

Gebruik van een echte browser voor functionele tests

Functionele tests maken gebruik van een speciale browser die de Symfony-laag rechtstreeks aanroept. Maar je kunt ook een echte browser en de echte HTTP-laag gebruiken dankzij Symfony Panther:

Waarschuwing

Op het moment dat ik deze paragraaf schreef, was het niet mogelijk om Panther op een Symfony 5 project te installeren omdat één afhankelijkheid nog niet ondersteund was.

1
$ symfony composer req panther --dev

Je kan dan tests schrijven die gebruik maken van een echte Google Chrome-browser door het maken van volgende wijzigingen:

 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_DEFAULT_ROUTE_URL']]);
         $client->request('GET', '/');

         $this->assertResponseIsSuccessful();

De SYMFONY_DEFAULT_ROUTE_URL omgevingsvariabele bevat de URL van de lokale webserver.

Black-box functionele tests draaien met Blackfire

Een andere manier om functionele tests uit te voeren is via de Blackfire player. Naast wat je kan testen met de functionele tests, kan je ook performance-tests uitvoeren.

Raadpleeg de stap over “Prestaties” voor meer informatie.


This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.