Buy Access to Course
30.

Pruebas Parte 2: Pruebas funcionales

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Bienvenido de nuevo a la 2ª parte del día 29. Hoy me he saltado las normas y lo he convertido en un artículo doble. Hemos hablado de las pruebas de los componentes Twig y Live... pero también tenemos que hablar de las pruebas funcionales -o de extremo a extremo- en general. Es cuando controlamos mediante programación un navegador, hacemos que haga clic en enlaces, rellene formularios, etc.

Dos cosas sobre esto. Primero, vamos a crear un sistema que me gusta mucho. Y segundo, el camino para conseguirlo va a ser... sinceramente, un poco accidentado. No es un proceso suave y eso es algo en lo que debemos trabajar como comunidad.

zenstruck/navegador

Symfony tiene herramientas de pruebas funcionales integradas, pero a mí me gusta utilizar otra biblioteca. En tu terminal, instálala con:

composer require zenstruck/browser --dev

A continuación, en la carpeta tests/, crearé un nuevo directorio llamado Functional/... luego una nueva clase llamada VoyageControllerTest. Y supongo que también podría ponerla en un directorio Controller/.

Para las tripas, pegaré una prueba terminada:

37 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 2
namespace App\Tests\Functional;
use App\Factory\PlanetFactory;
use App\Factory\VoyageFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Browser\Test\HasBrowser;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class VoyageControllerTest extends WebTestCase
{
use ResetDatabase;
use Factories;
use HasBrowser;
public function testCreateVoyage()
{
PlanetFactory::createOne([
'name' => 'Earth',
]);
VoyageFactory::createOne();
$this->browser()
->visit('/')
->click('Voyages')
->click('New Voyage')
->fillField('Purpose', 'Test voyage')
->selectFieldOption('Planet', 'Earth')
->click('Save')
->assertElementCount('table tbody tr', 2)
->assertSee('Bon voyage')
;
}
}

Vale, estamos utilizando ResetDatabase y Factories... extiende elWebTestCase normal para pruebas funcionales... y luego HasBrowser viene de la biblioteca Browser y nos da la capacidad de llamar a $this->browser() para controlar un navegador con esta API realmente suave. Esto sigue el flujo de ir a la página del viaje, hacer clic en "Nuevo viaje", rellenar el formulario, guardar y aseverar al final. La prueba comienza con un único Voyage en la base de datos, así que después de crear uno nuevo, aseveramos que hay dos en la página.

Para ejecutarlo, utiliza el mismo comando, pero apunta al directorio Functional/:

symfony php vendor/bin/simple-phpunit tests/Functional

Y... ¡realmente pasa! ¡Genial!

Probar JavaScript con Panther

Pero espera un momento. Entre bastidores, esto no está utilizando un navegador real: sólo está haciendo peticiones falsas en PHP. Eso significa que no ejecuta JavaScript. Estamos probando la experiencia que tendría un usuario si tuviera desactivado JavaScript. Eso está bien para muchas situaciones. Sin embargo, esta vez quiero probar toda la fantasía modal.

Para ejecutar la prueba utilizando un navegador real que admita JavaScript -como Chrome- cambia a $this->pantherBrowser():

37 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 11
class VoyageControllerTest extends WebTestCase
{
// ... lines 14 - 17
public function testCreateVoyage()
{
// ... lines 20 - 24
$this->pantherBrowser()
// ... lines 26 - 33
;
}
}

Pruébalo:

symfony php vendor/bin/simple-phpunit tests/Functional

¡Nada! Pero un bonito error: necesitamos instalar symfony/panther. ¡Hagámoslo!

composer require symfony/panther --dev

Panther es una biblioteca PHP que puede controlar mediante programación los navegadores reales de tu máquina. Para utilizarla, también necesitamos ampliar PantherTestCase:

37 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 6
use Symfony\Component\Panther\PantherTestCase;
// ... lines 8 - 11
class VoyageControllerTest extends PantherTestCase
{
// ... lines 14 - 35
}

Inténtalo de nuevo:

symfony php vendor/bin/simple-phpunit tests/Functional

No vemos el navegador -se abre invisiblemente en segundo plano- ¡pero ahora está utilizando Chrome! Y la prueba falla - bastante pronto:

Elemento clicable "Nuevo Viaje" no encontrado.

Hmmm. Ha hecho clic en "Viajes", pero no ha encontrado el botón "Nuevo viaje". Una característica fantástica de zenstruck/browser con Panther es que, cuando falla una prueba, hace una captura de pantalla del fallo.

Dentro del directorio var/... aquí está. Huh, la captura de pantalla muestra que seguimos en la página de inicio, como si nunca hubiéramos hecho clic en "Viajes"... aunque puedes ver que el enlace "Viajes" parece activo.

El problema es que la navegación por la página se realiza mediante Ajax... y nuestras pruebas no saben esperar a que termine. Hace clic en "Viajes"... e inmediatamente intenta hacer clic en "Nuevo viaje". Esto será lo principal que tendremos que arreglar.

Cargando un servidor de desarrollo "de prueba

Pero antes de eso, ¡veo un problema mayor! Mira los datos: ¡no proceden de nuestra base de datos de prueba! Vienen de nuestro sitio de desarrollo

Aunque no podamos verlo, Panther está controlando un navegador real. Y... un navegador real necesita acceder a nuestro sitio utilizando un servidor web real a través de una dirección web real. Como estamos utilizando el servidor web Symfony, Panther lo detectó y... ¡lo utilizó!

Pero... ¡eso no es lo que queremos! ¿Por qué? Nuestro servidor utiliza el entorno dev y la base de datos dev. Nuestras pruebas deberían utilizar el entorno test y la base de datostest.

Para solucionarlo, abre phpunit.xml.dist. Pega dos variables de entorno:

42 lines | phpunit.xml.dist
// ... lines 1 - 3
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
// ... lines 5 - 9
>
<php>
// ... lines 12 - 17
<server name="SYMFONY_PROJECT_DEFAULT_ROUTE_URL" value="" />
<server name="PANTHER_APP_ENV" value="test" />
</php>
// ... lines 21 - 40
</phpunit>

La primera... es una especie de hack. Le dice a Panther que no utilice nuestro servidor. En su lugar, Panther iniciará ahora silenciosamente su propio servidor web utilizando el servidor web PHP incorporado. La segunda línea le dice a Panther que utilice el entorno test cuando haga eso.

En la prueba, para que sea aún más fácil ver si esto funciona, después de hacer clic en viajes, llama a ddScreenshot():

38 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 11
class VoyageControllerTest extends PantherTestCase
{
// ... lines 14 - 17
public function testCreateVoyage()
{
// ... lines 20 - 24
$this->pantherBrowser()
->visit('/')
->click('Voyages')
->ddScreenshot()
// ... lines 29 - 34
;
}
}

Haz una captura de pantalla, luego vuelca y muere.

Ejecuta:

symfony php vendor/bin/simple-phpunit tests/Functional

Lo hace... ¡y guarda una captura de pantalla! ¡Genial! Búscalo en var/. Y... vale. Parece que se está utilizando el nuevo servidor web... ¡pero faltan todos los estilos!

Depurar abriendo el navegador

¡Es hora de hacer un poco de trabajo detectivesco! Para entender qué está pasando, podemos decirle temporalmente a Panther que abra realmente el navegador, por ejemplo, para que podamos verlo y jugar con él.

Después de visitar, digamos ->pause():

39 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 11
class VoyageControllerTest extends PantherTestCase
{
// ... lines 14 - 17
public function testCreateVoyage()
{
// ... lines 20 - 24
$this->pantherBrowser()
->visit('/')
->pause()
// ... lines 28 - 35
;
}
}

Luego, para abrir el navegador, anteponemos al comando de prueba PANTHER_NO_HEADLESS=1:

PANTHER_NO_HEADLESS=1 symfony php vendor/bin/simple-phpunit tests/Functional

Y... ¡woh! Se abrió el navegador y luego se detuvo. Ahora podemos ver el código fuente de la página. Aquí está el archivo CSS. Ábrelo. Es un 404 no encontrado. ¿Por qué?

En el entorno de desarrollo, nuestros recursos se sirven a través de Symfony: no son archivos físicos reales. Si antepones a la URL index.php, funciona. Panther utiliza el servidor web PHP integrado... y necesita una regla de reescritura que le indique que envíe estas URL a través de Symfony. Sinceramente, es un detalle molesto, pero podemos arreglarlo.

De vuelta al terminal, pulsa intro para cerrar el navegador. En tests/, crea un nuevo archivo llamado router.php. Pega el código:

16 lines | tests/router.php
// ... lines 1 - 2
if (is_file($_SERVER['DOCUMENT_ROOT'].\DIRECTORY_SEPARATOR.$_SERVER['SCRIPT_NAME'])) {
return false;
}
$script = 'index.php';
$_SERVER = array_merge($_SERVER, $_ENV);
$_SERVER['SCRIPT_FILENAME'] = $_SERVER['DOCUMENT_ROOT'].\DIRECTORY_SEPARATOR.$script;
$_SERVER['SCRIPT_NAME'] = \DIRECTORY_SEPARATOR.$script;
$_SERVER['PHP_SELF'] = \DIRECTORY_SEPARATOR.$script;
require $script;

Este es un archivo "enrutador" que utilizará el servidor web incorporado. Para decirle a Panther que lo utilice, en phpunit.xml.dist, pegaré otra variable de entorno:PANTHER_WEB_SERVER_ROUTER ajustada a ../tests/router.php:

43 lines | phpunit.xml.dist
// ... lines 1 - 3
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
// ... lines 5 - 9
>
<php>
// ... lines 12 - 19
<server name="PANTHER_WEB_SERVER_ROUTER" value="../tests/router.php" />
</php>
// ... lines 22 - 41
</phpunit>

¡Pruébalo!

PANTHER_NO_HEADLESS=1 symfony php vendor/bin/simple-phpunit tests/Functional

Y ahora... ¡funciona! Pulsa intro para terminar. A continuación, elimina el pause().

Ejecuta de nuevo la prueba, pero sin la variable de entorno:

symfony php vendor/bin/simple-phpunit tests/Functional

Esperando la carga de la página turbo

Genial: ha llegado a nuestra línea de captura de pantalla. Ábrela. Vale, volvemos al problema original: no espera a que se cargue la página después de que hagamos clic en el enlace.

Resolver esto... no es tan sencillo como debería. Di $browser =, ciérralo e inicia una nueva cadena con $browser debajo. Entre medias, pegaré dos líneas. Ésta es de nivel inferior, pero espera a que se añada el atributo aria-busy al elementohtml, cosa que hace Turbo cuando se está cargando. Luego espera a que desaparezca:

42 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 11
class VoyageControllerTest extends PantherTestCase
{
// ... lines 14 - 17
public function testCreateVoyage()
{
// ... lines 20 - 24
$browser = $this->pantherBrowser()
->visit('/')
->click('Voyages')
;
$browser->client()->waitFor('html[aria-busy="true"]');
$browser->client()->waitFor('html:not([aria-busy])');
$browser
->ddScreenshot()
->click('New Voyage')
->fillField('Purpose', 'Test voyage')
->selectFieldOption('Planet', 'Earth')
->click('Save')
->assertElementCount('table tbody tr', 2)
->assertSee('Bon voyage')
;
}
}

Prueba ahora:

symfony php vendor/bin/simple-phpunit tests/Functional

Luego... abre la captura de pantalla. ¡Woh! Ahora está esperando a que termine la llamada Ajax. Pero recuerda: también estamos utilizando transiciones de vista. La página se ha cargado... pero aún está en medio de la transición. Lo arreglaremos en un minuto.

Pero antes, tenemos que limpiar esto: es demasiado trabajo. Lo que me gustaría es un nuevo método en el propio navegador, como waitForPageLoad(). ¡Y podemos hacerlo con una clase de navegador personalizada!

En el directorio tests/, crea una nueva clase llamada AppBrowser. Voy a pegar las tripas:

17 lines | tests/AppBrowser.php
// ... lines 1 - 2
namespace App\Tests;
use Zenstruck\Browser\PantherBrowser;
class AppBrowser extends PantherBrowser
{
public function waitForPageLoad(): self
{
$this->client()->waitFor('html[aria-busy="true"]');
$this->client()->waitFor('html:not([aria-busy])');
return $this;
}
}

Esto extiende la clase normal PantherBrowser y añade un nuevo método que esas mismas dos líneas.

Cuando llamemos a $this->pantherBrowser(), ahora queremos que devuelva nuestroAppBrowser en lugar del PantherBrowser normal. Para ello, lo has adivinado, es una variable de entorno: PANTHER_BROWSER_CLASS ajustada a App\Tests\AppBrowser:

44 lines | phpunit.xml.dist
// ... lines 1 - 3
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
// ... lines 5 - 9
>
<php>
// ... lines 12 - 20
<server name="PANTHER_BROWSER_CLASS" value="App\Tests\AppBrowser" />
</php>
// ... lines 23 - 42
</phpunit>

Para asegurarnos de que esto funciona, dd(get_class($browser));:

43 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 11
class VoyageControllerTest extends PantherTestCase
{
// ... lines 14 - 17
public function testCreateVoyage()
{
// ... lines 20 - 24
$browser = $this->pantherBrowser()
->visit('/')
->click('Voyages')
;
dd(get_class($browser));
// ... lines 30 - 40
}
}

Ejecuta la prueba:

symfony php vendor/bin/simple-phpunit tests/Functional

Y... ¡sí! ¡Obtenemos AppBrowser! Por desgracia, aunque el nuevo método funcionaría, no obtenemos autocompletado. Nuestro editor no tiene ni idea de que hemos intercambiado una subclase.

Para mejorar esto, hagamos una última cosa: en tests/, crea una nueva clase base de prueba: AppPantherTestCase. Pegaré el contenido:

19 lines | tests/AppPantherTestCase.php
// ... lines 1 - 2
namespace App\Tests;
use Symfony\Component\Panther\PantherTestCase;
use Zenstruck\Browser\Test\HasBrowser;
class AppPantherTestCase extends PantherTestCase
{
use HasBrowser {
pantherBrowser as parentPantherBrowser;
}
protected function pantherBrowser(array $options = [], array $kernelOptions = [], array $managerOptions = []): AppBrowser
{
return $this->parentPantherBrowser($options, $kernelOptions, $managerOptions);
}
}

Extiende la clase normal PantherTestCase... luego anula el método pantherBrowser(), llama al padre, pero cambia el tipo de retorno para que sea nuestro AppBrowser.

En VoyageControllerTest, cambia esto a extend AppPantherTestCase , y asegúrate de eliminar use HasBrowser:

37 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 6
use App\Tests\AppPantherTestCase;
// ... lines 8 - 10
class VoyageControllerTest extends AppPantherTestCase
{
use ResetDatabase;
use Factories;
// ... lines 16 - 35
}

Luego podemos ajustar las cosas: vuelve a conectar todos estos puntos... y utiliza el nuevo método: ->waitForPageLoad()... ¡con autocompletar! Elimina elddScreenshot():

36 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 10
class VoyageControllerTest extends AppPantherTestCase
{
// ... lines 13 - 15
public function testCreateVoyage()
{
// ... lines 18 - 22
$this->pantherBrowser()
->visit('/')
->click('Voyages')
->waitForPageLoad()
->click('New Voyage')
->fillField('Purpose', 'Test voyage')
->selectFieldOption('Planet', 'Earth')
->click('Save')
->assertElementCount('table tbody tr', 2)
->assertSee('Bon voyage')
;
}
}

¡Y veamos dónde estamos!

symfony php vendor/bin/simple-phpunit tests/Functional

¡Más lejos!

Campo del formulario "Propósito" no encontrado.

Así que hizo clic en Viajes, hizo clic en "Nuevo viaje"... pero no encontró el campo de formulario. Si miramos hacia abajo en la captura de pantalla del error, podemos ver por qué: ¡el contenido del modal todavía se está cargando! Puede que veas el formulario en la captura de pantalla -a veces la captura de pantalla se produce un momento después, por lo que el formulario es visible-, pero éste es el problema.

Desactivar las transiciones de vista

Ah, pero antes de arreglar esto, también quiero desactivar las transiciones de vista. Entemplates/base.html.twig, la forma más fácil de asegurarse de que las transiciones de vista no estropean nuestras pruebas es eliminarlas. Digamos que si app.environment != 'test, entonces renderiza esta etiqueta meta:

99 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
<head>
// ... lines 4 - 6
{% if app.environment != 'test' %}
<meta name="view-transition">
{% endif %}
// ... lines 10 - 16
</head>
// ... lines 18 - 97
</html>

Esperando a que se cargue el modal

En fin, volvamos a nuestro fallo. Cuando hacemos clic para abrir el modal, lo que necesitamos es esperar a que se abra el modal -que en realidad es instantáneo-, pero también esperar a que termine de cargarse el <turbo-frame>que hay dentro.

Abre AppBrowser. Voy a pegar dos métodos más:

40 lines | tests/AppBrowser.php
// ... lines 1 - 4
use Facebook\WebDriver\WebDriverBy;
// ... lines 6 - 7
class AppBrowser extends PantherBrowser
{
// ... lines 10 - 17
public function waitForDialog(): self
{
$this->client()->wait()->until(function() {
return $this->crawler()->filter('dialog[open]')->count() > 0;
});
if ($this->crawler()->filter('dialog[open] turbo-frame')->count() > 0) {
$this->waitForTurboFrameLoad();
}
return $this;
}
public function waitForTurboFrameLoad(): self
{
$this->client()->wait()->until(function() {
return $this->crawler()->filter('turbo-frame[aria-busy="true"]')->count() === 0;
});
return $this;
}
}

El primero - waitForDialog() - espera hasta que ve un diálogo en la página con un atributo open. Y, si ese dialog abierto tiene un <turbo-frame>, espera a que se cargue: espera hasta que no haya ningún marco aria-busy en la página.

En VoyageControllerTest, después de hacer clic en "Nuevo viaje", digamos ->waitForDialog():

37 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 10
class VoyageControllerTest extends AppPantherTestCase
{
// ... lines 13 - 15
public function testCreateVoyage()
{
// ... lines 18 - 22
$this->pantherBrowser()
// ... lines 24 - 26
->click('New Voyage')
->waitForDialog()
->fillField('Purpose', 'Test voyage')
// ... lines 30 - 33
;
}
}

Y ahora

symfony php vendor/bin/simple-phpunit tests/Functional

¡Tan cerca!

table tbody tr esperaba 2 elementos en la página, pero sólo encontró 1.

¡Eso viene de aquí abajo! ¿Cuál es el problema esta vez? ¡Volvamos a la captura de pantalla de error! Ah: hemos rellenado el formulario, parece que incluso hemos pulsado Guardar... ¡pero estamos afirmando demasiado rápido!

Recuerda: esto se envía a un <turbo-frame>, así que tenemos que esperar a que ese marco termine de cargarse. Y tenemos una forma de hacerlo:->waitForTurboFrameLoad(). También añadiré una línea para afirmar que no podemos ver ningún diálogo abierto: para comprobar que el modal se cerró:

39 lines | tests/Functional/VoyageControllerTest.php
// ... lines 1 - 10
class VoyageControllerTest extends AppPantherTestCase
{
// ... lines 13 - 15
public function testCreateVoyage()
{
// ... lines 18 - 22
$this->pantherBrowser()
->visit('/')
->click('Voyages')
->waitForPageLoad()
->click('New Voyage')
->waitForDialog()
->fillField('Purpose', 'Test voyage')
->selectFieldOption('Planet', 'Earth')
->click('Save')
->waitForTurboFrameLoad()
->assertElementCount('table tbody tr', 2)
->assertNotSeeElement('dialog[open]')
->assertSee('Bon voyage')
;
}
}

Ejecuta la prueba una vez más:

symfony php vendor/bin/simple-phpunit tests/Functional

Pasa. ¡Guau! Lo admito, ha sido trabajo, ¡demasiado trabajo! Pero me encanta el resultado final.

Mañana, en nuestro último día, hablaremos del rendimiento. Y a diferencia de hoy, las cosas se pondrán rápidamente en su sitio, te lo prometo.