Symfony 1.2, la guía definitiva

15.3. Pruebas funcionales

Las pruebas funcionales validan partes de las aplicaciones. Estas pruebas simulan la navegación del usuario, realizan peticiones y comprueban los elementos de la respuesta, tal y como lo haría manualmente un usuario para validar que una determinada acción hace lo que se supone que tiene que hacer. En las pruebas funcionales, se ejecuta un escenario correspondiente a lo que se denomina un "caso de uso".

15.3.1. ¿Cómo son las pruebas funcionales?

Las pruebas funcionales se podrían realizar mediante un navegador en forma de texto y un montón de asertos definidos con expresiones regulares complejas, pero sería una pérdida de tiempo muy grande. Symfony dispone de un objeto especial, llamado sfBrowser, que actua como un navegador que está accediendo a una aplicación Symfony, pero sin necesidad de utilizar un servidor web real (y sin la penalización de las conexiones HTTP). Este objeto permite el acceso directo a los objetos que forman cada petición (el objeto petición, el objeto sesión, el objeto contexto y el objeto respuesta). Symfony también dispone de una extensión de esta clase llamada sfTestBrowser, que está especialmente diseñada para las pruebas funcionales y que tiene todas las características de sfBrowser, además de algunos métodos muy útiles para los asertos.

Una prueba funcional suele comenzar con la inicialización del objeto del navegador para pruebas. Este objeto permite realizar una petición a una acción de la aplicación y permite verificar que algunos elementos están presentes en la respuesta.

Por ejemplo, cada vez que se genera el esqueleto de un módulo mediante las tareas generate:module o propel:generate-module, Symfony crea una prueba funciona de prueba para este módulo. La prueba realiza una petición a la acción por defecto del módulo y comprueba el código de estado de la respuesta, el módulo y la acción calculados por el sistema de enrutamiento y la presencia de una frase específica en el contenido de la respuesta. Si el módulo se llama foobar, el archivo foobarActionsTest.php generado es similar al del listado 15-9.

Listado 15-9 - Prueba funcional por defecto para un módulo nuevo, en tests/functional/frontend/foobarActionsTest.php

<?php

include(dirname(__FILE__).'/../../bootstrap/functional.php');

// Create a new test browser
$browser = new sfTestBrowser();

$browser->
  get('/foobar/index')->
  isStatusCode(200)->
  isRequestParameter('module', 'foobar')->
  isRequestParameter('action', 'index')->
  checkResponseElement('body', '!/This is a temporary page/')
;

Truco Todos los métodos del navegador de Symfony devuelven un objeto sfTestBrowser, por lo que se pueden encadenar las llamadas a los métodos para que los archivos de prueba sean más fáciles de leer. Esta estrategia se llama "interfaz fluida con el objeto", ya que nada puede parar el flujo de llamadas a los métodos del objeto.

Las pruebas funcionales pueden contener varias peticiones y asertos más complejos, como se mostrará en las próximas secciones.

Para ejecutar una prueba funcional, se utiliza la tarea test:functional de la línea de comandos de Symfony, como se muestra en el listado 15-10. Los argumentos que se indican a la tarea son el nombre de la aplicación y el nombre de la prueba (omitiendo el sufijo Test.php).

Listado 15-10 - Ejecutando una prueba funcional mediante la línea de comandos

> php symfony test:functional frontend foobarActions

# get /comment/index
ok 1 - status code is 200
ok 2 - request parameter module is foobar
ok 3 - request parameter action is index
not ok 4 - response selector body does not match regex /This is a temporary page/
# Looks like you failed 1 tests of 4.
1..4

Por defecto, las pruebas funcionales generadas automáticamente para un módulo nuevo no pasan correctamente todas las pruebas. El motivo es que en los módulos nuevos, la acción index redirige a una página de bienvenida (que pertenece al módulo default de Symfony) que contiene la frase "This is a temporary page". Mientras no se modifique la acción index del módulo, las pruebas funcionales de este módulo no se pasarán correctamente, lo que garantiza que no se ejecuten correctamente todas las pruebas para un módulo que está sin terminar.

Nota En las pruebas funcionales, la carga automática de clases está activada, por lo que no se deben incluir los archivos manualmente.

El navegador para pruebas permite realizar peticiones GET y POST. En ambos casos se utiliza una URI real como parámetro. El listado 15-11 muestra cómo crear peticiones con el objeto sfTestBrowser para simular peticiones reales.

Listado 15-11 - Simulando peticiones con el objeto sfTestBrowser

include(dirname(__FILE__).'/../../bootstrap/functional.php');

// Se crea un nuevo navegador de pruebas
$b = new sfTestBrowser();

$b->get('/foobar/show/id/1');                   // Petición GET
$b->post('/foobar/show', array('id' => 1));     // Petición POST

// Los métodos get() y post() son atajos del método call()
$b->call('/foobar/show/id/1', 'get');
$b->call('/foobar/show', 'post', array('id' => 1));

// El método call() puede simular peticiones de cualquier método
$b->call('/foobar/show/id/1', 'head');
$b->call('/foobar/add/id/1', 'put');
$b->call('/foobar/delete/id/1', 'delete');

Una navegación típica no sólo está formada por peticiones a determinadas acciones, sino que también incluye clicks sobre enlaces y botones. Como se muestra en el listado 15-12, el objeto sfTestBrowser también es capaz de simular la acción de pinchar sobre estos elementos.

Listado 15-12 - Simulando una navegación real con el objeto sfTestBrowser

$b->get('/');                  // Petición a la página principal
$b->get('/foobar/show/id/1');
$b->back();                    // Volver a la página anterior del historial
$b->forward();                 // Ir a la página siguiente del historial
$b->reload();                  // Recargar la página actual
$b->click('go');               // Buscar un enlace o botón llamado 'go' y pincharlo

El navegador para pruebas incluye un mecanismo para guardar todas las peticiones realizadas, por lo que los métodos back() y forward() funcionan de la misma manera que en un navegador real.

Truco El navegador de pruebas incluye sus propios mecanismos para gestionar las sesiones (sfTestStorage) y las cookies.

Entre las interacciones que más se deben probar, las de los formularios son probablemente las más necesarias. Symfony dispone de 3 formas de probar la introducción de datos en los formularios y su envío. Se puede crear una petición POST con los parámetros que se quieren enviar, se puede llamar al método click() con los parámetros del formulario en un array o se pueden rellenar los campos del formulario de uno en uno y después pulsar sobre el botón de envío. En cualquiera de los 3 casos, la petición POST resultante es la misma. El listado 15-13 muestra un ejemplo.

Listado 15-13 - Simulando el envío de un formulario con datos mediante el objeto sfTestBrowser

// Plantilla de ejemplo en modules/foobar/templates/editSuccess.php
<?php echo form_tag('foobar/update') ?>
  <?php echo input_hidden_tag('id', $sf_params->get('id')) ?>
  <?php echo input_tag('name', 'foo') ?>
  <?php echo submit_tag('go') ?>
  <?php echo textarea('text1', 'foo') ?>
  <?php echo textarea('text2', 'bar') ?>
</form>

// Prueba funcional de ejemplo para este formulario
$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');

// Opción 1: petición POST
$b->post('/foobar/update', array('id' => 1, 'name' => 'dummy', 'commit' => 'go'));

// Opción 2: Pulsar sobre el botón de envío con parámetros
$b->click('go', array('name' => 'dummy'));

// Opción 3: Introducir los valores del formulario campo a campo y
//  presionar el botón de envío
$b->setField('name', 'dummy')->
    click('go');

Nota En las opciones 2 y 3, los valores por defecto del formulario se incluyen automáticamente en su envío y no es necesario especificar el destino del formulario.

Si una acción finaliza con una redirección (redirect()), el navegador para pruebas no sigue automáticamente la redirección, sino que se debe seguir manualmente mediante followRedirect(), como se muestra en el listado 15-14.

Listado 15-14 - El navegador para pruebas no sigue automáticamente las redirecciones

// Acción de ejemplo en modules/foobar/actions/actions.class.php
public function executeUpdate($peticion)
{
  // ...
  $this->redirect('foobar/show?id='.$peticion->getParameter('id'));
}

// Prueba funcional de ejemplo para esta acción
$b = new sfTestBrowser();
$b->get('/foobar/edit?id=1')->
    click('go', array('name' => 'dummy'))->
    isRedirected()->   // Check that request is redirected
    followRedirect();    // Manually follow the redirection

Existe un último método muy útil para la navegación: restart(), que inicializa el historial de navegación, la sesión y las cookies, es decir, como si se reiniciara el navegador.

Una vez realizada la primera petición, el objeto sfTestBrowser dispone de acceso directo a los objetos de la petición, del contexto y de la respuesta. De esta forma, se pueden probar muchas cosas diferentes, desde el contenido textual de las páginas a las cabeceras de la respuesta, pasando por los parámetros de la petición y la configuración:

$peticion  = $b->getRequest();
$contexto  = $b->getContext();
$respuesta = $b->getResponse();

15.3.3. Utilizando asertos

Como el objeto sfTestBrowser dispone de acceso directo a la respuesta y a otros componentes de la petición, es posible realizar pruebas sobre estos componentes. Se podría crear un nuevo objeto lime_test para estas pruebas, pero por suerte, sfTestBrowser dispone de un método llamado test() que devuelve un objeto lime_test sobre el que se pueden invocar los métodos para asertos descritos anteriormentes. El listado 15-15 muestra cómo realizar asertos mediante sfTestBrowser.

Listado 15-15 - El navegador para pruebas dispone del método test() para realizar pruebas

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');
$request  = $b->getRequest();
$context  = $b->getContext();
$response = $b->getResponse();

// Acceder a los métodos de lime_test mediante el método test()
$b->test()->is($request->getParameter('id'), 1);
$b->test()->is($response->getStatuscode(), 200);
$b->test()->is($response->getHttpHeader('content-type'), 'text/html;charset=utf-8');
$b->test()->like($response->getContent(), '/edit/');

Nota Los métodos getResponse(), getContext(), getRequest() y test() no devuelven un objeto sfTestBrowser, por lo que no se pueden encadenar después de ellos otras llamadas a los métodos de sfTestBrowser.

Las cookies enviadas y recibidas se pueden probar fácilmente mediante los objetos de la petición y de la respuesta, como se muestra en el listado 15-16.

Listado 15-16 - Probando las cookies con sfTestBrowser

$b->test()->is($request->getCookie('foo'), 'bar');     // Cookie enviada
$cookies = $response->getCookies();
$b->test()->is($cookies['foo'], 'foo=bar');            // Cookie recibida

Si se utiliza el método test() para probar los elementos de la petición, se acaban escribiendo unas líneas de código demasiado largas. Afortunadamente, sfTestbrowser contiene una serie de métodos especiales que permiten mantener las pruebas funcionales cortas y fáciles de leer, además de que devuelven objetos sfTestBrowser. El listado 15-15 se podría reescribir por ejemplo de forma más sencilla como se muestra en el listado 15-17.

Listado 15-17 - Realizando pruebas directamente con sfTestBrowser

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1')->
    isRequestParameter('id', 1)->
    isStatusCode()->
    isResponseHeader('content-type', 'text/html; charset=utf-8')->
    responseContains('edit');

El código de estado 200 es el valor por defecto que espera el método isStatusCode(), por lo que, para comprobar si la respuesta es correcta, se puede realizar la llamada sin argumentos.

Otra ventaja del uso de estos métodos especiales es que no es necesario especificar el texto que se muestra en la salida, como sí que era necesario en los métodos del objeto lime_test. Los mensajes se generan automáticamente en los métodos especiales, y la salida producida es clara y muy sencilla de entender.

# get /foobar/edit/id/1
ok 1 - request parameter "id" is "1"
ok 2 - status code is "200"
ok 3 - response header "content-type" is "text/html"
ok 4 - response contains "edit"
1..4

En la práctica, los métodos especiales del listado 15-17 cubren la mayor parte de las pruebas habituales, por lo que raramente es necesario utilizar el método test() del objeto sfTestBrowser.

El listado 15-14 demuestra que sfTestBrowser no sigue directamente las redirecciones. La ventaja de este comportamiento es que se pueden probar las propias redirecciones. El listado 15-18 muestra cómo probar la respuesta del listado 15-14.

Listado 15-18 - Probando las redirecciones con sfTestBrowser

$b = new sfTestBrowser();
$b->
    get('/foobar/edit/id/1')->
    click('go', array('name' => 'dummy'))->
    isStatusCode(200)->
    isRequestParameter('module', 'foobar')->
    isRequestParameter('action', 'update')->

    isRedirected()->      // Comprobar que la respuseta es una redirección
    followRedirect()->    // Obligar manualmente a seguir la redirección

    isStatusCode(200)->
    isRequestParameter('module', 'foobar')->
    isRequestParameter('action', 'show');

15.3.4. Utilizando los selectores CSS

Muchas pruebas funcionales validan que una página sea correcta comprobando que un determinado texto se encuentre en el contenido de la respuesta. Utilizando el método responseContains() y las expresiones regulares, es posible comprobar que existe un determinado texto, los atributos de una etiqueta o sus valores. Pero si lo que se quiere probar se encuentra en lo más profundo del árbol DOM del contenido de la respuesta, la solución de las expresiones regulares es demasiado compleja.

Este es el motivo por el que el objeto sfTestBrowser dispone de un método llamado getResponseDom(). El método devuelve un objeto DOM de libXML2, que es mucho más fácil de procesar que el texto simple. El listado 15-19 muestra un ejemplo de uso de este método.

Listado 15-19 - El navegador para pruebas devuelve el contenido de la respuesta como un objeto DOM

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');
$dom = $b->getResponseDom();
$b->test()->is($dom->getElementsByTagName('input')->item(1)->getAttribute('type'),'text');

Sin embargo, procesar un documento HTML con los métodos DOM de PHP no es lo suficientemente rápido y sencillo. Por su parte, los selectores utilizados en las hojas de estilos CSS son una forma aun más potente de obtener los elementos de un documento HTML. Symfony incluye una herramienta llamada sfDomCssSelector, cuyo constructor espera un documento DOM como argumento. Esta utilidad dispone de un método llamado getTexts() que devuelve un array de las cadenas de texto seleccionadas mediante un selector CSS, y otro método llamado getElements() que devuelve un array de elementos DOM. El listado 15-20 muestra un ejemplo.

Listado 15-20 - El navegador para pruebas permite acceder al contenido de la respuesta mediante el objeto sfDomCssSelector

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1');
$c = new sfDomCssSelector($b->getResponseDom())
$b->test()->is($c->getTexts('form input[type="hidden"][value="1"]'), array('');
$b->test()->is($c->getTexts('form textarea[name="text1"]'), array('foo'));
$b->test()->is($c->getTexts('form input[type="submit"]'), array(''));

Como es habitual, Symfony busca siempre la máxima brevedad y claridad en el código, por lo que se dispone de un método alternativo llamado checkResponseElement(). Utilizando este método, el listado 15-20 se puede transformar en el listado 15-21.

Listado 15-21 - El navegador para pruebas permite acceder a los elementos de la respuesta utilizando selectores de CSS

$b = new sfTestBrowser();
$b->get('/foobar/edit/id/1')->
    checkResponseElement('form input[type="hidden"][value="1"]', true)->
    checkResponseElement('form textarea[name="text1"]', 'foo')->
    checkResponseElement('form input[type="submit"]', 1);

El comportamiento del método checkResponseElement() depende del tipo de dato del segundo argumento que se le pasa:

  • Si es un valor booleano, comprueba si existe un elemento que cumpla con el selector CSS indicado.
  • Si es un número entero, comprueba que el selector CSS indicado devuelva el número de elementos de este parámetro.
  • Si es una expresión regular, comprueba que el primer elemento seleccionado mediante el selector CSS cumpla el patrón de la expresión regular.
  • Si es una expresión regular precedida de !, comprueba que el primer elemento seleccionado mediante el selector CSS no cumpla con el patrón de la expresión regular.
  • En el resto de casos, compara el primer elemento seleccionado mediante el selector CSS y el valor del segundo argumento que se pasa en forma de cadena de texto.

El método acepta además un tercer parámetro opcional en forma de array asociativo. De esta forma es posible no solo realizar la prueba sobre el primer elemento devuelto por el selector CSS (si es que devuelve varios elementos) sino sobre otro elemento que se encuentra en una posición determinada, tal y como muestra el listado 15-22.

Listado 15-22 - Utilizando la opción de posición para comprobar un elemento que se encuentra en una posición determinada

$b = new sfTestBrowser();
$b->get('/foobar/edit?id=1')->
    checkResponseElement('form textarea', 'foo')->
    checkResponseElement('form textarea', 'bar', array('position' => 1));

El array de opciones también se puede utilizar para realizar 2 pruebas a la vez. Se puede comprobar que existe un elemento que cumple un selector y al mismo tiempo comprobar cuantos elementos lo cumplen, como se muestra en el listado 15-23.

Listado 15-23 - Utilizando la opción para contar el número de elementos que cumplen el selector CSS

$b = new sfTestBrowser();
$b->initialize();
$b->get('/foobar/edit?id=1')->
    checkResponseElement('form input', true, array('count' => 3));

La herramienta del selector es bastante potente, ya que acepta la mayor parte de los selectores de CSS 3. De esta forma, se pueden hacer selecciones tan complejas como las que se muestran en el listado 15-24.

Listado 15-24 - Ejemplo de selectores CSS complejos que acepta checkResponseElement()

$b->checkResponseElement('ul#list li a[href]', 'click me');
$b->checkResponseElement('ul > li', 'click me');
$b->checkResponseElement('ul + li', 'click me');
$b->checkResponseElement('h1, h2', 'click me');
$b->checkResponseElement('a[class$="foo"][href*="bar.html"]', 'my link');
$b->checkResponseElement('p:last ul:nth-child(2) li:contains("Some text")');

15.3.5. Probando los errores

En ocasiones, las acciones o la parte del modelo lanzan excepciones a propósito (por ejemplo para mostrar una página de error 404). Aunque se pueden utilizar selectores CSS para comprobar la presencia de un determinado mensaje de error en el código HTML generado, es mejor utilizar el método throwsException para comprobar si se ha lanzado una excepción, tal y como muestra el listado 15-25.

Listado 15-25 - Probando las excepciones

$b = new sfTestBrowser();
$b->
    get('/foobar/edit/id/1')->
    click('go', array('name' => 'dummy'))->
    isStatusCode(200)->
    isRequestParameter('module', 'foobar')->
    isRequestParameter('action', 'update')->

    throwsException()->                   // Comprueba que la última petición lanza una excepción
    throwsException('RuntimeException')-> // Comprueba el tipo de excepción
    throwsException(null, '/error/');     // Comprueba si el mensaje de la excepción cumple con una expresión regular

15.3.6. Trabajando en el entorno de pruebas

El objeto sfTestBrowser utiliza un controlador frontal especial, que trabaja en el entorno test. El listado 15-26 muestra la configuración por defecto de este entorno.

Listado 15-26 - Configuración por defecto del entorno test, en frontend/config/settings.yml

test:
  .settings:
    error_reporting:        <?php echo (E_ALL | E_STRICT & ~E_NOTICE)."\n" ?>
    cache:                  off
    web_debug:              off
    no_script_name:         off
    etag:                   off

En este entorno, la cache y la barra de depuración web están desactivadas. No obstante, la ejecución del código genera logs en un archivo distinto a los logs de los entornos dev y prod, por lo que se pueden observar de forma independiente (en miproyecto/log/frontend_test.log). Además, en este entorno las excepciones no detienen la ejecución de los scripts, de forma que se pueda ejecutar un conjunto completo de pruebas incluso cuando falla alguna prueba. También es posible definir una conexión específica con la base de datos, por ejemplo para utilizar una base de datos que tenga datos de prueba.

Antes de utilizar el objeto sfTestBrowser, es necesario inicializarlo. Si se necesita, es posible especificar el nombre del servidor para la aplicación y una dirección IP para el cliente, por si la aplicación controla estos dos parámetros. El listado 15-27 muestra cómo configurar estos parámetros.

Listado 15-27 - Indicar el hostname y la IP en el navegador para pruebas

$b = new sfTestBrowser('miaplicacion.ejemplo.com', '123.456.789.123');

15.3.7. La tarea test:functional

La tarea test:functional puede ejecutar una o varias pruebas funcionales, dependiendo del número de argumentos indicados. La sintaxis que se utiliza es muy similar a la de la tarea test:unit, salvo que la tarea para pruebas funcionales requiere como primer argumento el nombre de una aplicación, tal y como muestra el listado 15-28.

Listado 15-28 - Sintaxis de la tarea para pruebas funcionales

// Estructura del directorio de pruebas
test/
  functional/
    frontend/
      miModuloActionsTest.php
      miEscenarioTest.php
    backend/
      miOtroEscenarioTest.php
## Ejecutar todas las pruebas funcionales de una aplicacion recursivamente
> php symfony test:functional frontend

## Ejecutar la prueba funcional cuyo nombre se indica como parámetro
> php symfony test:functional frontend myScenario

## Ejecutar todas las pruebas funcionales cuyos nombres cumplan con el patrón indicado
> php symfony test:functional frontend my*