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

Pas 17: Testare

5.0 version
Maintained

Testare

Din moment ce adăugăm din ce în ce mai multe funcționalități în aplicație, este probabil momentul potrivit pentru a vorbi despre testare.

Fapt amuzant: am găsit o eroare în timp ce scriam testele în acest capitol.

Symfony se bazează pe PHPUnit pentru teste unitare. Să-l instalăm:

1
$ symfony composer req phpunit

Elaborarea testelor unitare

SpamChecker este prima clasă pentru care vom scrie teste. Generează un test unitar:

1
$ symfony console make:unit-test SpamCheckerTest

Testarea SpamChecker este o provocare, deoarece cu siguranță nu vrem să atingem API-ul Akismet. Drept alternativă vom simula API-ul.

Să elaborăm un prim test pentru situația în care API-ul returnează o eroare:

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);
     }
 }

Clasa MockHttpClient face posibilă simularea oricărui server HTTP. Este nevoie de o serie de instanțe MockResponse care conțin anteturile preconizate ale corpului și răspunsului.

Apoi, apelăm la metoda getSpamScore() și verificăm dacă o excepție este aruncată prin metoda expectException() a PHPUnit.

Execută testele pentru a verifica dacă acestea trec:

1
$ symfony php bin/phpunit

Să adăugăm teste pentru calea fericită:

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

Furnizorii de date PHPUnit ne permit să reutilizăm aceeași logică de testare pentru mai multe cazuri de testare.

Redactarea testelor funcționale pentru controlere

Testarea controlerelor este puțin diferită de testarea unei clase PHP „obișnuite”, deoarece dorim să le executăm în contextul unei solicitări HTTP.

Instalează dependențele suplimentare necesare testelor funcționale:

1
$ symfony composer require browser-kit --dev

Creează un test funcțional pentru controlerul Conference:

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

Acest prim test verifică dacă pagina principală returnează un răspuns HTTP 200.

Variabila $client simulează un browser. Totuși, în loc să efectueze apeluri HTTP către server, apelează direct aplicația Symfony. Această strategie are mai multe avantaje: este mult mai rapidă decât efectuarea transmiterea mesajelor dus-întors între client și server, dar permite și testele să introspecte starea serviciilor după fiecare solicitare HTTP.

Validări precum assertResponseIsSuccessful sunt adăugate peste cele existente în PHPUnit pentru a-ți ușura munca. Există multe astfel de validări definite de Symfony.

Sfat

Am folosit / pentru URL în loc să-l generăm prin router. Acest lucru se face în mod intenționat, deoarece testarea adreselor URL ale utilizatorului final face parte din ceea ce dorim să testăm. Dacă schimbi calea rutelor, testele vor da eroare, amintindu-ți că ar trebui să redirecționezi vechiul URL-ul vechi către unul nou, pentru a păstra funcționale legăturile din motoarele de căutare și de la alte site-urile web.

Notă

Am fi putut genera testul prin pachetul maker:

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

Testele PHPUnit sunt executate într-un mediu test dedicat. Trebuie să setăm secretul AKISMET_KEY pentru acest mediu:

1
$ APP_ENV=test symfony console secrets:set AKISMET_KEY

Execută noile teste doar transmițând calea către clasa lor:

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

Sfat

Când un test nu trece, poate fi utilă analizarea obiectului Response. Accesează-l prin $client->getResponse() și echo pentru a vedea cum arată.

Definirea datelor de test

Pentru a putea testa lista de comentarii, paginarea și trimiterea formularului, trebuie să populăm baza de date cu ceva informații. Și dorim ca datele să fie aceleași între teste pentru ca acestea să fie executate cu succes. Datele de testare sunt exact ceea ce avem nevoie.

Instalează pachetul Doctrine Fixtures:

1
$ symfony composer req orm-fixtures --dev

În timpul instalării a fost creat un nou director src/DataFixtures/, cu o clasă exemplu, gata de a fi personalizată. Adaugă acum două conferințe și un singur comentariu:

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();
     }

Când vom încărca datele de testare, toate datele vor fi eliminate; inclusiv utilizatorul admin. Pentru a evita acest lucru, să adăugăm utilizatorul administrator în 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
--- 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();
     }
 }

Sfat

Dacă nu îți amintești ce serviciu trebuie să utilizezi pentru o sarcină dată, utilizează comanda debug:autowiring urmată de un cuvânt cheie:

1
$ symfony console debug:autowiring encoder

Încărcarea datelor de testare

Încarcă datele de test în baza de date. Fii atent această acțiune va șterge toate datele stocate în prezent în baza de date (dacă dorești să eviți acest comportament, continuă să citești).

1
$ symfony console doctrine:fixtures:load

Navigarea unui site web în teste funcționale

Așa cum am văzut, clientul HTTP utilizat în teste simulează un browser, astfel încât putem naviga prin intermediul site-ului ca și cum am folosi un browser headless.

Adaugă un test nou care face clic pe o pagină de conferință din pagina principală:

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

Să descriem ce se întâmplă în acest test într-un limbaj simplu:

  • Ca și primul test, navigăm spre pagina principală;
  • Metoda request() returnează o instanță` Crawler care ajută la găsirea de elemente pe pagină (cum ar fi link-uri, formulare sau orice ce poate fi accesat cu selectoare CSS sau XPath);
  • Mulțumită unui selector CSS, afirmăm că avem două conferințe listate pe pagina principală;
  • Facem clic apoi pe linkul „View” (deoarece nu se poate face clic pe mai multe linkuri simultan, Symfony alege automat primul pe care îl găsește);
  • Verificăm titlul paginii, răspunsul și pagina <h2> pentru a fi siguri că suntem pe pagina corectă (am fi putut verifica și traseul corespunzător);
  • În cele din urmă, verificăm că există 1 comentariu pe pagină. div: contains() nu este un selector CSS valid, dar Symfony are câteva completări frumoase, împrumutate de la jQuery.

În loc să facem clic pe text (adică View), am fi putut selecta și linkul prin intermediul unui selector CSS:

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

Verificați dacă noul test se execută cu succes:

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

Lucrul cu o bază de date de testare

În mod implicit, testele sunt rulate în mediul test Symfony așa cum este definit în fișierul phpunit.xml.dist:

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

Dacă dorești să utilizezi o bază de date diferită pentru testele tale, suprascrie variabila de mediu DATABASE_URL în fișierul .env.test:

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

Încarcă datele de testare pentru mediul/baza de date test:

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

În continuarea acestui pas, nu vom redefini variabila de mediu DATABASE_URL. Utilizarea aceleiași baze de date ca mediul dev pentru teste are câteva avantaje pe care le vom vedea în secțiunea următoare.

Trimiterea unui formular într-un test funcțional

Vrei să treci la nivelul următor? Încearcă să adaugi un comentariu nou cu o fotografie la o conferință, de la un test, simulând o expediere a formularului. Asta pare ambițios, nu-i așa? Uită-te la codul necesar: nu e mai complex decât ceea ce am elaborat deja:

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

Pentru a expedia un formular prin submitForm(), găsește numele de intrare datorită instrumentului DevTools a browser-ului sau prin panoul Symfony Profiler Form. Observă reutilizarea inteligentă a imaginii „În construcție”!

Execută testele din nou pentru a verifica dacă validările trec cu succes:

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

Un avantaj al utilizării bazei de date „dev” pentru teste este că poți verifica rezultatul într-un browser:

Reîncărcarea datelor de testare

Dacă execuți testele a doua oară, acestea ar trebui să eșueze. Deoarece acum există mai multe comentarii în baza de date, validarea care verifică numărul de comentarii este invalidă. Trebuie să resetăm starea bazei de date între fiecare execuție, reîncărcând datele de test înainte de fiecare execuție:

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

Automatizarea fluxului de lucru cu un Makefile

Necesitatea de a-ți aminti o secvență de comenzi pentru a rula testele este incomodă. Acest lucru ar trebui cel puțin să fie documentat. Dar documentația ar trebui să fie o ultimă soluție. În schimb, cum rămâne cu automatizarea activităților de zi cu zi? Aceasta ar servi drept documentare, ar ajuta alți dezvoltatori și ar face viața dezvoltatorilor mai ușoară și rapidă.

Folosirea unui Makefile este o modalitate de a automatiza comenzile:

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

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

Rețineți opțiunea -n pentru comanda Doctrine; este o opțiune globală pentru comenzile Symfony care le face să nu fie interactive.

Ori de câte ori dorești să rulezi testele, folosește make tests:

1
$ make tests

Resetarea bazei de date după fiecare test

Resetarea bazei de date după fiecare testare este plăcută, dar executarea testelor independent este și mai bine. Nu dorim ca un test să se bazeze pe rezultatele celor anterioare. Modificarea ordinii testelor nu ar trebui să schimbe rezultatul. După cum o să ne dăm seama acum, nu este cazul deocamdată.

Mută testul testConferencePage după testul testCommentSubmission:

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

Testele acum eșuează.

Pentru a reseta baza de date între teste, instalează DoctrineTestBundle:

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

Va trebui să confirmi execuția rețetei (deoarece nu este un pachet „oficial” acceptat):

 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

Activează ascultătorul PHPUnit:

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>

Și gata. Orice modificări efectuate în teste sunt acum revizuite automat la sfârșitul fiecărui test.

Testele ar trebui să fie din nou validate:

1
$ make tests

Utilizarea unui browser real pentru teste funcționale

Testele funcționale utilizează un browser special care apelează direct stratul Symfony. Dar, de asemenea, poți utiliza un browser real și stratul HTTP real datorită Symfony Panther:

Atenționare

În momentul în care am scris acest alineat, nu a fost posibil să instalezi Panther pe un proiect Symfony 5, deoarece o dependență nu era încă compatibilă.

1
$ symfony composer req panther --dev

Poți scrie teste care utilizează un browser Google Chrome real cu următoarele modificări:

 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();

Variabila de mediu SYMFONY_DEFAULT_ROUTE_URL conține adresa URL a serverului web local.

Executarea testelor funcționale în formatul cutiei negre cu Blackfire

Un alt mod de a rula teste funcționale este de a utiliza Blackfire player. Pe lângă ceea ce poți face cu teste funcționale, poate efectua și teste de performanță.

Consultă punctul „Performanță” pentru a afla mai multe.


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