Symfony 1.2, la guía definitiva

15.2. Pruebas unitarias

Las pruebas unitarias de Symfony son archivos PHP normales cuyo nombre termina en Test.php y que se encuentran en el directorio test/unit/ de la aplicación. Su sintaxis es sencilla y fácil de leer.

15.2.1. ¿Qué aspecto tienen las pruebas unitarias?

El listado 15-1 muestra un conjunto típico de pruebas unitarias para la función strtolower(). En primer lugar, se instancia el objeto lime_test (todavía no hace falta que te preocupes de sus parámetros). Cada prueba unitaria consiste en una llamada a un método de la instancia de lime_test. El último parámetro de estos métodos siempre es una cadena de texto opcional que se utiliza como resultado del método.

Listado 15-1 - Archivo de ejemplo de prueba unitaria, en test/unit/strtolowerTest.php

<?php

include(dirname(__FILE__).'/../bootstrap/unit.php');
require_once(dirname(__FILE__).'/../../lib/strtolower.php');

$t = new lime_test(7, new lime_output_color());

// strtolower()
$t->diag('strtolower()');
$t->isa_ok(strtolower('Foo'), 'string',
    'strtolower() returns a string');
$t->is(strtolower('FOO'), 'foo',
    'strtolower() transforms the input to lowercase');
$t->is(strtolower('foo'), 'foo',
    'strtolower() leaves lowercase characters unchanged');
$t->is(strtolower('12#?@~'), '12#?@~',
    'strtolower() leaves non alphabetical characters unchanged');
$t->is(strtolower('FOO BAR'), 'foo bar',
    'strtolower() leaves blanks alone');
$t->is(strtolower('FoO bAr'), 'foo bar',
    'strtolower() deals with mixed case input');
$t->is(strtolower(''), 'foo',
    'strtolower() transforms empty strings into foo');

Para ejecutar el conjunto de pruebas, se utiliza la tarea test:unit desde la línea de comandos. El resultado de esta tarea en la línea de comandos es muy explícito, lo que permite localizar fácilmente las pruebas que han fallado y las que se han ejecutado correctamente. El listado 15-2 muestra el resultado del ejemplo anterior.

Listado 15-2 - Ejecutando una prueba unitaria desde la línea de comandos

> php symfony test:unit strtolower

1..7
# strtolower()
ok 1 - strtolower() returns a string
ok 2 - strtolower() transforms the input to lowercase
ok 3 - strtolower() leaves lowercase characters unchanged
ok 4 - strtolower() leaves non alphabetical characters unchanged
ok 5 - strtolower() leaves blanks alone
ok 6 - strtolower() deals with mixed case input
not ok 7 - strtolower() transforms empty strings into foo
#     Failed test (.\batch\test.php at line 21)
#            got: ''
#       expected: 'foo'
# Looks like you failed 1 tests of 7.

Truco La instrucción include al principio del listado 15-1 es opcional, pero hace que el archivo de la prueba sea un script de PHP independiente, es decir, que se puede ejecutar sin utilizar la línea de comandos de Symfony, mediante php test/unit/strtolowerTest.php.

15.2.2. Métodos para las pruebas unitarias

El objeto lime_test dispone de un gran número de métodos para las pruebas, como se muestra en la figura 15-2.

Tabla 15-2 - Métodos del objeto lime_test para las pruebas unitarias

| Método | Descripción | | ----------------------------------------------- | ---------------------------------------------------------------------- | | diag($mensaje) | Muestra un comentario, pero no ejecuta ninguna prueba | | ok($prueba, $mensaje) | Si la condición que se indica es true, la prueba tiene éxito | | is($valor1, $valor2, $mensaje) | Compara 2 valores y la prueba pasa si los 2 son iguales (==) | | isnt($valor1, $valor2, $mensaje) | Compara 2 valores y la prueba pasa si no son iguales | | like($cadena, $expresionRegular, $mensaje) | Prueba que una cadena cumpla con el patrón de una expresión regular | | unlike($cadena, $expresionRegular, $mensaje) | Prueba que una cadena no cumpla con el patrón de una expresión regular | | cmp_ok($valor1, $operador, $valor2, $mensaje) | Compara 2 valores mediante el operador que se indica | | isa_ok($variable, $tipo, $mensaje) | Comprueba si la variable que se le pasa es del tipo que se indica | | isa_ok($objeto, $clase, $mensaje) | Comprueba si el objeto que se le pasa es de la clase que se indica | | can_ok($objeto, $metodo, $mensaje) | Comprueba si el objeto que se le pasa dispone del método que se indica | | is_deeply($array1, $array2, $mensaje) | Comprueba que 2 arrays tengan los mismos valores | | include_ok($archivo, $mensaje) | Valida que un archivo existe y que ha sido incluido correctamente | | fail() | Provoca que la prueba siempre falle (es útil para las excepciones) | | pass() | Provoca que la prueba siempre se pase (es útil para las excepciones) | | skip($mensaje, $numeroPruebas) | Cuenta como si fueran $numeroPruebas pruebas (es útil para las pruebas condicionales) | | todo() | Cuenta como si fuera 1 prueba (es útil para las pruebas que todavía no se han escrito) |

La sintaxis es tan clara que prácticamente se explica por sí sola. Casi todos los métodos permiten indicar un mensaje como último parámetro. Este mensaje es el que se muestra como resultado de la prueba cuando esta tiene éxito. La mejor manera de aprender a utilizar estos métodos es utilizarlos, así que es importante el código del listado 15-3, que utiliza todos los métodos.

Listado 15-3 - Probando los métodos del objeto lime_test, en test/unit/ejemploTest.php

<?php

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

// Funciones y objetos vacíos para las pruenas
class miObjeto
{
  public function miMetodo()
  {
  }
}

function lanza_una_excepcion()
{
  throw new Exception('excepción lanzada');
}

// Inicializar el objeto de pruebas
$t = new lime_test(16, new lime_output_color());

$t->diag('hola mundo');
$t->ok(1 == '1', 'el operador de igualdad ignora el tipo de la variable');
$t->is(1, '1', 'las cadenas se convierten en números para realizar la comparación');
$t->isnt(0, 1, '0 y 1 no son lo mismo');
$t->like('prueba01', '/prueba\d+/', 'prueba01 sigue el patrón para numerar las pruebas');
$t->unlike('pruebas01', '/prueba\d+/', 'pruebas01 no sigue el patrón');
$t->cmp_ok(1, '<', 2, '1 es inferior a 2');
$t->cmp_ok(1, '!==', true, '1 y true no son exactamente lo mismo');
$t->isa_ok('valor', 'string', '\'valor\' es una cadena de texto');
$t->isa_ok(new miObjeto(), 'miObjeto', 'new crea un objeto de la clase correcta');
$t->can_ok(new miObjeto(), 'miMetodo', 'los objetos de la clase miObjeto tienen un método llamado miMetodo');
$array1 = array(1, 2, array(1 => 'valor', 'a' => '4'));
$t->is_deeply($array1, array(1, 2, array(1 => 'valor', 'a' => '4')),
    'el primer array y el segundo array son iguales');
$t->include_ok('./nombreArchivo.php', 'el archivo nombreArchivo.php ha sido incluido correctamente');

try
{
  lanza_una_excepcion();
  $t->fail('no debería ejecutarse ningún código después de lanzarse la excepción');
}
catch (Exception $e)
{
  $t->pass('la excepción ha sido capturada correctamente');
}

if (!isset($variable))
{
  $t->skip('saltamos una prueba para mantener el contador de pruebas correcto para la condición', 1);
}
else
{
  $t->ok($variable, 'valor');
}

$t->todo('la última prueba que falta');

Las pruebas unitarias de Symfony incluyen muchos más ejemplos de uso de todos estos métodos.

Truco Puede que sea confuso el uso de is() en vez de ok() en el ejemplo anterior. La razón es que el mensaje de error que muestra is() es mucho más explícito, ya que muestra los 2 argumentos de la prueba, mientras que ok() simplemente dice que la prueba ha fallado.

15.2.3. Parámetros para las pruebas

En la inicialización del objeto lime_test se indica como primer parámetro el número de pruebas que se van a ejecutar. Si el número de pruebas realmente realizadas no coincide con este valor, la salida producida por Lime muestra un aviso. El conjunto de pruebas del listado 15-3 producen la salida del listado 15-4. Como en la inicialización se indica que se deben ejecutar 16 pruebas y realmente solo se han realizado 15, en la salida se muestra un mensaje de aviso.

Listado 15-4 - El contador de pruebas realizadas permite planificar las pruebas

> php symfony test:unit ejemplo

1..16
# hola mundo
ok 1 - el operador de igualdad ignora el tipo de la variable
ok 2 - las cadenas se convierten en números para realizar la comparación
ok 3 - 0 y 1 no son lo mismo
ok 4 - prueba01 sigue el patrón para numerar las pruebas
ok 5 - pruebas01 no sigue el patrón
ok 6 - 1 es inferior a 2
ok 7 - 1 y true no son exactamente lo mismo
ok 8 - 'valor' es una cadena de texto
ok 9 - new crea un objeto de la clase correcta
ok 10 - los objetos de la clase miObjeto tienen un método llamado miMetodo
ok 11 - el primer array y el segundo array son iguales
not ok 12 - el archivo nombreArchivo.php ha sido incluido correctamente
#     Failed test (.\test\unit\ejemploTest.php at line 35)
#       Tried to include './nombreArchivo.php'
ok 13 - la excepción ha sido capturada correctamente
ok 14 # SKIP saltamos una prueba para mantener el contador de pruebas correcto para la condición
ok 15 # TODO la última prueba que falta
# Looks like you planned 16 tests but only ran 15.
# Looks like you failed 1 tests of 16.

El método diag() no cuenta como una prueba. Se utiliza para mostrar mensajes, de forma que la salida por pantalla esté más organizada y sea más fácil de leer. Por otra parte, los métodos todo() y skip() cuentan como si fueran pruebas reales. La combinación pass()/fail() dentro de un bloque try/catch cuenta como una sola prueba.

Una estrategia de pruebas bien planificada requiere que se indique el número esperado de pruebas. Indicar este número es una buena forma de validar los propios archivos de pruebas, sobre todo en los casos más complicados en los que algunas pruebas se ejecutan dentro de condiciones y/o excepciones. Además, si la prueba falla en cualquier punto, es muy fácil de verlo porque el número de pruebas realizadas no coincide con el número de pruebas esperadas.

El segundo parámetro del constructor del objeto lime_test indica el objeto que se utiliza para mostrar los resultado. Se trata de un objeto que extiende la clase lime_output. La mayoría de las veces, como las pruebas se realizan en una interfaz de comandos, la salida se construye mediante el objeto lime_output_color, que muestra la salida coloreada en los sistemas que lo permiten.

15.2.4. La tarea test:unit

La tarea test:unit, que se utiliza para ejecutar las pruebas unitarias desde la línea de comandos, admite como argumento una serie de nombres de pruebas o un patrón de nombre de archivos. El listado 15-5 muestra los detalles.

Listado 15-5 - Ejecutando las pruebas unitarias

// Estructura del directorio de pruebas
    test/
      unit/
        miFuncionalTest.php
        miSegundoFuncionalTest.php
        otro/
          nombreTest.php
> php symfony test:unit miFuncional                    ## Ejecutar miFuncionalTest.php
> php symfony test:unit miFuncional miSegundoFuncional ## Ejecuta las 2 pruebas
> php symfony test:unit 'otro/*'                       ## Ejecuta nombreTest.php
> php symfony test:unit '*'                            ## Ejecuta todas las pruebas (de forma recursiva)

15.2.5. Stubs, Fixtures y carga automática de clases

La carga automática de clases no funciona por defecto en las pruebas unitarias. Por tanto, todas las clases que se utilizan en una prueba se deben definir en el propio archivo de la prueba o se deben incluir como una dependencia externa. Este es el motivo por el que muchos archivos de pruebas empiezan con un grupo de instrucciones include, como se muestra en el listado 15-6.

Listado 15-6 - Incluyendo las clases de forma explícita en las pruebas unitarias

<?php

include(dirname(__FILE__).'/../bootstrap/unit.php');
require_once($sf_symfony_lib_dir.'/util/sfToolkit.class.php');

$t = new lime_test(7, new lime_output_color());

// isPathAbsolute()
$t->diag('isPathAbsolute()');
$t->is(sfToolkit::isPathAbsolute('/test'), true,
    'isPathAbsolute() returns true if path is absolute');
$t->is(sfToolkit::isPathAbsolute('\\test'), true,
    'isPathAbsolute() returns true if path is absolute');
$t->is(sfToolkit::isPathAbsolute('C:\\test'), true,
    'isPathAbsolute() returns true if path is absolute');
$t->is(sfToolkit::isPathAbsolute('d:/test'), true,
    'isPathAbsolute() returns true if path is absolute');
$t->is(sfToolkit::isPathAbsolute('test'), false,
    'isPathAbsolute() returns false if path is relative');
$t->is(sfToolkit::isPathAbsolute('../test'), false,
    'isPathAbsolute() returns false if path is relative');
$t->is(sfToolkit::isPathAbsolute('..\\test'), false,
    'isPathAbsolute() returns false if path is relative');

En las pruebas unitarias, no solo se debe instanciar el objeto que se está probando, sino también el objeto del que depende. Como las pruebas unitarias deben ser autosuficientes, depender de otras clases puede provocar que más de una prueba falle si alguna clase no funciona correctamente. Además, crear objetos reales es una tarea costosa, tanto en número de líneas de código necesarias como en tiempo de ejecución. Debe tenerse en cuenta que la velocidad de ejecución es esencial para las pruebas unitarias, ya que los programadores en seguida se cansan de los procesos que son muy lentos.

Si se incluyen muchos scripts en una prueba unitaria, lo más útil es utilizar un sistema sencillo de carga automática de clases. Para ello, la clase sfSimpleAutoload (que se debe incluir manualmente) dispone del método addDirectory(), que admite como argumento la ruta absoluta hasta un directorio y que se puede llamar tantas veces como sean necesarias para incluir todos los directorios deseados. Todas las clases que se encuentren bajo esa ruta, se cargarán automáticamente. Si por ejemplo se quieren cargar automáticamente todas las clases del directorio $sf_symfony_lib_dir/util/, se utilizan las siguientes instrucciones al principio del script de la prueba unitaria:

require_once($sf_symfony_lib_dir.'/autoload/sfSimpleAutoload.class.php');
$autoload = new sfSimpleAutoload();
$autoload->addDirectory($sf_symfony_lib_dir.'/util');
$autoload->register();

Otra técnica muy utilizada para evitar los problemas de la carga automática de clases es el uso de stubs o clases falsas. Un stub es una implementación alternativa de una clase en la que los métodos reales se sustituyen por datos simples especialmente preparados. De esta forma, se emula el comportamiento de la clase real y se reduce su tiempo de ejecución. Los casos típicos para utilizar stubs son las conexiones con bases de datos y las interfaces de los servicios web. En el listado 15-7, las pruebas unitarias para una API de un servicio de mapas utilizan la clase WebService. En vez de ejecutar el método fetch() real de la clase del servicio web, la prueba utiliza un stub que devuelve datos de prueba.

Listado 15-7 - Utilizando stubs en las pruebas unitarias

require_once(dirname(__FILE__).'/../../lib/WebService.class.php');
require_once(dirname(__FILE__).'/../../lib/MapAPI.class.php');

class testWebService extends WebService
{
  public static function fetch()
  {
    return file_get_contents(dirname(__FILE__).'/fixtures/data/servicio_web_falso.xml');
  }
}

$miMapa = new MapAPI();

$t = new lime_test(1, new lime_output_color());

$t->is($miMapa->getMapSize(testWebService::fetch(), 100));

Los datos de prueba pueden ser más complejos que una cadena de texto o la llamada a un método. Los datos de prueba complejos se suelen denominar "fixtures". Para mejorar el código de las pruebas unitarias, es recomendable mantener los fixtures en archivos independientes, sobre todo si se utilizan en más de una prueba. Además, Symfony es capaz de transformar un archivo YAML en un array mediante el método sfYAML::load(). De esta forma, en vez de escribir arrays PHP muy grandes, los datos para las pruebas se pueden guardar en archivos YAML, como en el listado 15-8.

Listado 15-8 - Usando archivos para los "fixtures" de las pruebas unitarias

// En fixtures.yml:
-
  input:   '/test'
  output:  true
  comment: isPathAbsolute() returns true if path is absolute
-
  input:   '\\test'
  output:  true
  comment: isPathAbsolute() returns true if path is absolute
-
  input:   'C:\\test'
  output:  true
  comment: isPathAbsolute() returns true if path is absolute
-
  input:   'd:/test'
  output:  true
  comment: isPathAbsolute() returns true if path is absolute
-
  input:   'test'
  output:  false
  comment: isPathAbsolute() returns false if path is relative
-
  input:   '../test'
  output:  false
  comment: isPathAbsolute() returns false if path is relative
-
  input:   '..\\test'
  output:  false
  comment: isPathAbsolute() returns false if path is relative
// En testTest.php
<?php

include(dirname(__FILE__).'/../bootstrap/unit.php');
require_once($sf_symfony_lib_dir.'/util/sfToolkit.class.php');
require_once($sf_symfony_lib_dir.'/yaml/sfYaml.class.php');

$testCases = sfYaml::load(dirname(__FILE__).'/fixtures.yml');

$t = new lime_test(count($testCases), new lime_output_color());

// isPathAbsolute()
$t->diag('isPathAbsolute()');
foreach ($testCases as $case)
{
  $t->is(sfToolkit::isPathAbsolute($case['input']), $case['output'],$case['comment']);
}

15.2.6. Pruebas unitarias de las clases de Propel

Crear pruebas unitarias para las clases de Propel es un poco más complicado, ya que los objetos Propel generados dependen de muchísimas clases, por lo que es necesario utilizar la carga automática de clases. Además, para probar Propel debes proporcionar una conexión válida con la base de datos y debes incluir algunos datos de prueba en la base de datos.

Afortunadamente, el proceso no es tan complicado como parece porque Symfony ya incluye todo lo que necesitas:

  • Para obtener la carga automática de clases, inicializa un objeto de la configuración
  • Para obtener la conexión con la base de datos, inicializa la clase sfDatabaseManager
  • Para cargar algunos datos de prueba, utiliza la clase sfPropelData

El listado 15-9 muestra un típico archivo de pruebas de Propel.

Listado 15-9 - Probando las clases de Propel

<?php

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

new sfDatabaseManager(ProjectConfiguration::getApplicationConfiguration('frontend', 'test', true));
$loader = new sfPropelData();
$loader->loadData(sfConfig::get('sf_data_dir').'/fixtures');

$t = new lime_test(1, new lime_output_color());

// begin testing your model class
$t->diag('->retrieveByUsername()');
$user = UserPeer::retrieveByUsername('fabien');
$t->is($user->getLastName(), 'Potencier', '->retrieveByUsername() returns the User for the given username');