Passo 17: Testes

5.0 version
Maintained

Testes

Já que começamos a adicionar mais e mais funcionalidades na aplicação, agora provavelmente é o momento certo para falar sobre testes.

Fato engraçado: Encontrei um bug enquanto escrevia os testes neste capítulo.

O Symfony depende do PHPUnit para testes unitários. Vamos instalá-lo:

1
$ symfony composer req phpunit

Escrevendo Testes Unitários

SpamChecker é a primeira classe para a qual vamos escrever testes. Gere um teste unitário:

1
$ symfony console make:unit-test SpamCheckerTest

Testar o SpamChecker é um desafio, pois certamente não queremos acessar a API do Akismet. Nós vamos simular a API.

Vamos escrever um primeiro teste para quando a API retorna um erro:

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

A classe MockHttpClient torna possível simular qualquer servidor HTTP. Ela recebe um array de instâncias MockResponse que contêm o corpo esperado e os cabeçalhos do Response.

Então, chamamos o método getSpamScore() e verificamos se uma exceção é lançada através do método expectException() do PHPUnit.

Execute os testes para verificar se eles passam:

1
$ symfony php bin/phpunit

Vamos adicionar testes para o caminho feliz:

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

Os provedores de dados do PHPUnit nos permitem reutilizar a mesma lógica de teste para vários casos de teste.

Escrevendo Testes Funcionais para Controladores

Testar os controladores é um pouco diferente de testar uma classe “regular” do PHP, pois queremos executá-los no contexto de uma requisição HTTP.

Instale algumas dependências extras necessárias para testes funcionais:

1
$ symfony composer require browser-kit --dev

Crie um teste funcional para o controlador da conferência:

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

Este primeiro teste verifica se a página inicial retorna uma resposta HTTP 200.

A variável $client simula um navegador. Em vez de fazer chamadas HTTP para o servidor, ela chama a aplicação Symfony diretamente. Essa estratégia tem vários benefícios: é muito mais rápido do que ter várias idas e vindas entre o cliente e o servidor, mas também permite que os testes examinem o estado dos serviços após cada requisição HTTP.

Asserções como assertResponseIsSuccessful são adicionadas ao PHPUnit para facilitar o seu trabalho. Há muitas dessas asserções definidas pelo Symfony.

Dica

Usamos / como URL ao invés de gerá-la através do roteador. Isso é feito de propósito, pois testar URLs de usuários finais faz parte do que queremos testar. Se você alterar o caminho da rota, os testes irão quebrar como um bom lembrete de que você provavelmente deve redirecionar a antiga URL para a nova para ser agradável com os motores de busca e sites com links para o seu site.

Nota

Podíamos ter gerado o teste através do bundle Maker:

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

Os testes do PHPUnit são executados em um ambiente test dedicado. Devemos definir o valor do segredo AKISMET_KEY para esse ambiente:

1
$ APP_ENV=test symfony console secrets:set AKISMET_KEY

Execute os novos testes passando apenas o caminho para a sua classe:

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

Dica

Quando um teste falha, pode ser útil examinar o objeto Response. Acesse-o via $client->getResponse() e use echo nele para ver seu conteúdo.

Definindo Fixtures

Para poder testar a lista de comentários, a paginação e a submissão de formulários, precisamos popular o banco de dados com alguns dados. E queremos que os dados sejam estáveis entre as execuções dos testes para que eles passem. As fixtures são exatamente o que precisamos.

Instale o bundle Doctrine Fixtures:

1
$ symfony composer req orm-fixtures --dev

Um novo diretório src/DataFixtures/ foi criado durante a instalação com uma classe de amostra, pronta para ser personalizada. Adicione duas conferências e um comentário por enquanto:

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

Quando carregarmos as fixtures, todos os dados serão removidos, incluindo o usuário admin. Para evitar isso, vamos adicionar o usuário admin nas 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();
     }
 }

Dica

Se você não lembra qual serviço precisa usar para uma determinada tarefa, use o debug:autowiring com alguma palavra-chave:

1
$ symfony console debug:autowiring encoder

Carregando as Fixtures

Carregue as fixtures no banco de dados. Esteja ciente de que isso irá apagar todos os dados armazenados atualmente no banco de dados (se você quiser evitar esse comportamento, continue lendo).

1
$ symfony console doctrine:fixtures:load

Coletando Dados de um Site em Testes Funcionais

Como vimos, o cliente HTTP usado nos testes simula um navegador, então podemos navegar pelo site como se estivéssemos usando um navegador sem interface.

Adicione um novo teste que clica em uma página de conferência a partir da página inicial:

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

Vamos descrever em inglês o que acontece neste teste:

  • Como no primeiro teste, vamos para a página inicial;
  • O método request() retorna uma instância Crawler que ajuda a encontrar elementos na página (como links, formulários ou qualquer coisa que você possa obter com seletores CSS ou XPath);
  • Graças a um seletor CSS, confirmamos que temos duas conferências listadas na página inicial;
  • Em seguida, clicamos no link “View” (como ele não pode clicar em mais de um link por vez, o Symfony escolhe automaticamente o primeiro que encontrar);
  • Confirmamos o título da página, a resposta e o título <h2> da página para ter certeza de que estamos na página certa (também poderíamos ter verificado a rota correspondente);
  • Finalmente, confirmamos que há 1 comentário na página. div:contains() não é um seletor CSS válido, mas o Symfony tem algumas adições agradáveis, emprestadas do jQuery.

Em vez de clicar no texto (ou seja, View), poderíamos ter selecionado o link através de um seletor CSS também:

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

Verifique se o novo teste está verde:

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

Trabalhando com um Banco de Dados de Teste

Por padrão, os testes são executados no ambiente test do Symfony conforme definido no arquivo phpunit.xml.dist:

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

Se você quiser usar um banco de dados diferente para os seus testes, sobrescreva a variável de ambiente DATABASE_URL no arquivo .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

Carregue as fixtures para o ambiente/banco de dados test:

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

Para o restante deste passo, não vamos redefinir a variável de ambiente DATABASE_URL. Usar o mesmo banco de dados que o ambiente dev para testes tem algumas vantagens que veremos na próxima seção.

Submetendo um Formulário em um Teste Funcional

Quer ir para o próximo nível? Tente adicionar um novo comentário com uma foto em uma conferência a partir de um teste simulando a submissão de um formulário. Parece ambicioso, não parece? Olhe o código necessário: não é mais complexo do que o que já escrevemos:

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

Para enviar um formulário via submitForm(), encontre os nomes dos campos usando as ferramentas de desenvolvedor do navegador ou através do painel Form do Profiler do Symfony. Observe a reutilização inteligente da imagem em construção!

Execute os testes novamente para verificar se está tudo verde:

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

Uma vantagem de usar o banco de dados “dev” para os testes é que você pode verificar o resultado em um navegador:

Recarregando as Fixtures

Se você executar os testes uma segunda vez, eles devem falhar. Como existem agora mais comentários no banco de dados, a assertion que verifica o número de comentários está quebrada. Precisamos resetar o estado do banco de dados entre cada execução recarregando as fixtures antes de cada execução:

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

Automatizando seu Fluxo de Trabalho com um Makefile

Ter que lembrar uma sequência de comandos para executar os testes é irritante. Isso deve, pelo menos, ser documentado. Mas a documentação deve ser um último recurso. Em vez disso, que tal automatizar as atividades diárias? Isso serviria como documentação, ajudaria na descoberta por outros desenvolvedores e tornaria a vida dos desenvolvedores mais fácil e rápida.

Usar um Makefile é uma forma de automatizar comandos:

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

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

Observe a flag -n no comando do Doctrine; é uma flag global nos comandos do Symfony que os torna não interativos.

Sempre que você quiser executar os testes, use make tests`:

1
$ make tests

Resetando o Banco de Dados após Cada Teste

Resetar o banco de dados após cada execução de teste é bom, mas ter testes verdadeiramente independentes é ainda melhor. Não queremos que um teste conte com os resultados dos anteriores. Alterar a ordem dos testes não deve alterar o resultado. Como vamos descobrir agora, esse não é o caso no momento.

Mova o teste testConferencePage para depois do 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")');
+    }
 }

Os testes agora falham.

Para resetar o banco de dados entre os testes, instale o DoctrineTestBundle:

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

Você precisará confirmar a execução da receita (já que não é um bundle suportado “oficialmente”):

 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

Ative o listener do 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>

E pronto. Quaisquer alterações feitas nos testes agora são automaticamente revertidas no final de cada teste.

Os testes devem estar verdes novamente:

1
$ make tests

Usando um Navegador Real para Testes Funcionais

Os testes funcionais usam um navegador especial que chama a camada Symfony diretamente. Mas, você também pode usar um navegador real e a camada HTTP real, graças ao Panther do Symfony:

Aviso

Na época em que escrevi este parágrafo, não era possível instalar o Panther em um projeto Symfony 5, já que uma dependência ainda não era compatível.

1
$ symfony composer req panther --dev

Você pode então escrever testes que utilizem um navegador Google Chrome real com as seguintes alterações:

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

A variável de ambiente SYMFONY_DEFAULT_ROUTE_URL contém a URL do servidor web local.

Executando Testes Funcionais Caixa Preta com Blackfire

Outra forma de realizar testes funcionais é usar o player do Blackfire. Além do que você pode fazer com testes funcionais, ele também pode realizar testes de desempenho.

Consulte a etapa sobre “Desempenho” para saber mais.


  • « Previous Passo 16: Prevenindo Spam com uma API
  • Next » Passo 18: Tornando Assíncrono

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