Paso 17: Pruebas

5.0 version
Maintained

Pruebas

Ahora que empezamos a añadir más y más funcionalidades a la aplicación, es el momento ideal para hablar sobre las pruebas.

Curiosidad : Encontré un error al escribir las pruebas en este capítulo.

Symfony se basa en PHPUnit para las pruebas unitarias (unit tests). Vamos a instalarlo:

1
$ symfony composer req phpunit

Escribiendo pruebas unitarias

SpamChecker es la primera clase para la que vamos a escribir las pruebas. Generemos una prueba unitaria:

1
$ symfony console make:unit-test SpamCheckerTest

Probar el SpamChecker es un reto, ya que ciertamente no queremos llegar a la API de Akismet. Vamos a «burlarnos» de la API. En el ámbito de las pruebas, un mock (burla en inglés) es una clase o método propio que suplanta a uno real.

Escribamos una primera prueba para cuando la API devuelva un error:

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

La clase MockHttpClient permite hacer un mock de cualquier servidor HTTP. Para ello toma un array de instancias MockResponse que contienen el cuerpo esperado y las cabeceras de cada respuesta.

Más tarde llamamos al método getSpamScore() y comprobamos que se lanza una excepción mediante el método expectException() de PHPUnit.

Ejecuta las pruebas para comprobar que se han superado:

1
$ symfony php bin/phpunit

Añadamos pruebas para el happy path (probaremos las respuestas que puede generar la API sin tener en cuenta los eventos excepcionales que puedan suceder):

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

Los proveedores de datos de PHPUnit nos permiten reutilizar la misma lógica de prueba para varios casos de prueba.

Escribiendo pruebas funcionales para controladores

Probar controladores es un poco diferente a probar una clase «normal» de PHP, ya que queremos ejecutarlos en el contexto de una petición HTTP.

Instala algunas dependencias adicionales necesarias para las pruebas funcionales:

1
$ symfony composer require browser-kit --dev

Creando una prueba funcional para el controlador 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');
    }
}

Esta primera prueba comprueba que la página de inicio devuelve una respuesta HTTP 200.

La variable $client simula un navegador. En lugar de hacer llamadas HTTP al servidor, llama directamente a la aplicación Symfony. Esta estrategia tiene varios beneficios: es mucho más rápida que tener viajes de ida y vuelta entre el cliente y el servidor, pero también permite que las pruebas puedan inspeccionar el estado de los servicios después de cada petición HTTP.

Con el fin de facilitarnos la vida, a PHPUnit se le han incorporado comprobaciones (asserts) del tipo assertResponseIsSuccessful (comprobar si la respuesta es exitosa). Existen muchas de estas comprobaciones definidas por Symfony.

Truco

Hemos utilizado la URL / en lugar de generarla a través del enrutador. Esto se hace a propósito ya que probar las URLs de los usuarios finales es parte de lo que queremos probar. Si cambias la ruta, las pruebas fallarán para recordarte que, probablemente, deberías redirigir la URL antigua a la nueva para no entorpecer a los motores de búsqueda y los sitios web que enlazan con tu sitio web.

Nota

Podríamos haber generado la prueba a través de maker:

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

Las pruebas de PHPUnit se ejecutan en un entorno dedicado test. Debemos establecer el valor del secreto AKISMET_KEY en este entorno:

1
$ APP_ENV=test symfony console secrets:set AKISMET_KEY

Ejecuta las nuevas pruebas sólo pasando la ruta a su clase:

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

Truco

Cuando una prueba falla, puede ser útil una introspección del objeto Response. Accede a él a través de $client->getResponse() y echo para ver su aspecto.

Definiendo fixtures

Para poder probar la lista de comentarios, la paginación y el envío del formulario, necesitamos que la base de datos contenga algunos datos. Y queremos que los datos sean los mismos entre prueba y prueba para que se pueda comprobar si pasan con éxito. Los fixtures son exactamente lo que necesitamos.

Instala el bundle Doctrine Fixtures:

1
$ symfony composer req orm-fixtures --dev

Durante la instalación se ha creado un nuevo directorio src/DataFixtures/ con una clase de ejemplo, lista para ser personalizada. Añade dos conferencias y un comentario por ahora:

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

Cuando carguemos los fixtures, se eliminarán todos los datos, incluido el usuario administrador. Para evitar eso, agreguemos el usuario admin a los 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();
     }
 }

Truco

Si no recuerdas qué servicio se necesita utilizar para una tarea determinada, utiliza la opción debug:autowiring con alguna palabra clave:

1
$ symfony console debug:autowiring encoder

Cargando fixtures

Carga los fixtures en la base de datos. Ten cuidado, se borrarán todos los datos actualmente almacenados en la base de datos (si deseas evitar este comportamiento, sigue leyendo).

1
$ symfony console doctrine:fixtures:load

Rastreo de un sitio web en pruebas funcionales

Como hemos visto, el cliente HTTP utilizado en las pruebas simula un navegador, por lo que podemos navegar por la web como si estuviéramos utilizando un navegador tradicional.

Agrega una nueva prueba que haga clic en una página de la conferencia desde la página 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")');
+    }
 }

Describamos lo que sucede en esta prueba en un lenguaje sencillo:

  • Como en la primera prueba, vamos a la página de inicio;
  • El método request() devuelve una instancia Crawler que ayuda a encontrar elementos en la página (como enlaces, formularios, o cualquier cosa a la que se pueda llegar con selectores CSS o XPath);
  • Gracias a un selector CSS, nos aseguramos de que tenemos dos conferencias listadas en la página de inicio;
  • Luego hacemos clic en el enlace «Ver» (como no puede hacer clic en más de un enlace a la vez, Symfony elige automáticamente el primero que encuentra);
  • Verificamos el título de la página, la respuesta y el <h2> de la página para asegurarnos de que estamos en la página correcta (también podríamos haber comprobado que la ruta coincide);
  • Finalmente, verificamos que hay 1 comentario en la página. div:contains() no es un selector de CSS válido, pero Symfony incluye algunas mejoras prestadas de jQuery.

En lugar de hacer clic en el texto (es decir, View), también podríamos haber seleccionado el enlace a través de un selector CSS:

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

Comprueba que la nueva prueba está en verde:

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

Trabajando con una base de datos de prueba

Por defecto, las pruebas se ejecutan en el entorno test de Symfony tal y como se define en el archivo phpunit.xml.dist:

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

Si deseas utilizar una base de datos diferente para tus pruebas, sustituye la variable de entorno DATABASE_URL del archivo .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

Carga los fixtures para el entorno/base de datos test:

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

Para el resto de este paso, no redefiniremos la variable de entorno``DATABASE_URL``. Usar la misma base de datos que el entorno``dev`` para las pruebas tiene algunas ventajas que veremos en la siguiente sección.

Enviando un formulario en una prueba funcional

¿Quieres pasar al siguiente nivel? Inténtalo añadiendo un nuevo comentario con una foto en una conferencia desde una prueba simulando el envío de un formulario. Eso parece ambicioso, ¿no? Mira el código necesario: no es más complejo que el que ya hemos escrito:

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 un formulario a través de submitForm(), busca los nombres de los inputs gracias al navegador DevTools o a través del panel del Symfony Profiler Form. ¡Observa la elegante reutilización de la imagen en construcción!

Vuelve a realizar las pruebas para comprobar que todo está en verde:

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

Una ventaja de utilizar la base de datos «dev» para las pruebas es que se puede comprobar el resultado en un navegador:

Recargando los fixtures

Si haces las pruebas por segunda vez, deberían fallar. Como ahora hay más comentarios en la base de datos, la comprobación que verifica el número de comentarios fallará. Necesitamos restablecer el estado de la base de datos entre cada ejecución, recargando los fixtures antes de cada ejecución:

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

Automatizando el flujo de trabajo con un Makefile

Tener que recordar la secuencia de comandos que ejecuta las pruebas es molesto. Debería, al menos, estar documentado. Pero la documentación debe ser el último recurso. En cambio, ¿qué hay de la automatización de las actividades cotidianas? Eso serviría como documentación, ayudaría a otros desarrolladores a descubrirlo y les haría la vida más fácil y rápida.

Usar un Makefile es una 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

Observa el parámetro -n en el comando Doctrine; es un parámetro global de los comandos Symfony que los hace no interactivos.

Siempre que desees ejecutar las pruebas, utiliza make tests:

1
$ make tests

Restableciendo la base de datos después de cada prueba

Reiniciar la base de datos después de cada prueba es bueno, pero tener pruebas verdaderamente independientes es aún mejor. No queremos que una prueba se base en los resultados de las anteriores. Cambiar el orden de las pruebas no debe cambiar el resultado. Como vamos a descubrir ahora, éste no es el caso por el momento.

Mueve la prueba testConferencePage después de 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")');
+    }
 }

Las pruebas ahora fallan.

Para restablecer la base de datos entre pruebas, instala DoctrineTestBundle:

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

Deberás confirmar la ejecución de la receta (ya que no es un paquete soportado «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

Habilita el oyente de 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>

Y hecho. Cualquier cambio realizado en las pruebas se retrotrae automáticamente al final de cada prueba.

Las pruebas deberían de nuevo estar en verde:

1
$ make tests

Usando un navegador real para pruebas funcionales

Las pruebas funcionales utilizan un navegador especial que llama directamente a la capa de Symfony. Pero también puedes usar un navegador real y la capa HTTP real gracias a Symfony Panther:

Advertencia

En el momento en que escribí este párrafo, no era posible instalar Panther en un proyecto Symfony 5 ya que una dependencia aún no era compatible.

1
$ symfony composer req panther --dev

Puedes escribir pruebas que utilicen un navegador Google Chrome real con los siguientes cambios:

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

La variable de entorno SYMFONY_DEFAULT_ROUTE_URL contiene la URL del servidor web local.

Ejecutando pruebas funcionales de caja negra (Black Box) con Blackfire

Otra forma de realizar pruebas funcionales es utilizar el reproductor Blackfire. Además de lo que puedes hacer con las pruebas funcionales, también puedes realizar pruebas de rendimiento.

Consulta el paso «Rendimiento» para obtener más información.


  • « Previous Paso 16: Previniendo spam con una API
  • Next » Paso 18: Volviéndonos asíncronos

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