Link Search Menu Expand Document

2.6. TDD: test-driven development

El testing o fase de pruebas es una de las grandes olvidadas en los cursos de programación y este ciclo formativo no es una excepción. Realmente, parece que nunca hubiera tiempo para dedicarse a probar el software como es debido.

Vamos a dedicar este apartado al testing y vamos a ver algunas técnicas que nos ayudarán a interiorizarlo y a integrarlo como parte de nuestra práctica profesional. En particular, nos vamos a centrar en el TDD o test-driven development, una metodología de desarrollo que toma las pruebas del software como la piedra angular del proceso de desarrollo y que, por lo tanto, permite construir aplicaciones en cualquier lenguaje que resultan mucho más confiables que las que se prueban a salto de mata.

2.6.1. Ventajas de hacer testing automático

Imagina que estás desarrollando una aplicación web que tiene multitud de funcionalidades. A ti te han encargado dos: hacer la pantalla de login y el mantenimiento de los usuarios registrados (altas, bajas, modificaciones, esas cosas).

Puede que decidas que empezar por la pantalla de login está bien, así que comienzas a desarrollarla y te centras en todas sus funcionalidades: comprobar que el usuario y la contraseña son correctas, prevenir los posibles ataques, informar al usuario de posibles errores, habilitar un mecanismo para resetear la contraseña en caso de olvido, etc.

Cuando terminas, ¿que haces? Lo pruebas todo manualmente una y otra vez. De hecho, lo habrás estado probando mientras lo desarrollabas. Las pruebas manuales están bien, y es conveniente hacerlas, pero no son suficiente. Siempre hay posibilidades que se nos pasan por alto y olvidamos probar. ¿Cuántas veces no has enseñado un programa a un compañero o a tu profesor después de haberlo usado mil veces y te ha fallado a los dos segundos porque han hecho algo que a ti no se te había ocurrido comprobar?

Por lo tanto, aquí tenemos la primera ventaja de hacer un testing: el testing bien diseñado siempre detectará más fallos que las pruebas manuales, además de consumir mucho menos tiempo por nuestra parte.

Sigamos imaginando. Cuando terminas la pantalla de login, comienzas a desarrollar tu segunda tarea, es decir, el mantenimiento de usuarios. Te centras durante varios días en este nuevo subprograma y todas sus funciones: insertar, modificar y borrar usuarios, validar campos, impedir repeticiones, prevenir ataques, etc.

Cuando terminas, no puedes estar seguro de si algo de lo que has hecho ha podido afectar a la pantalla de login. Por ejemplo, has podido añadir un nuevo campo que te hacía falta a la tabla de usuarios, y eso puede afectar a cómo se comporta el login. Conclusión: ¡tienes que volver a probar el login de nuevo!

Con un testing automatizado y bien diseñado, esto no es necesario, y aquí viene la segunda gran ventaja: siempre estaremos seguros de que toda la aplicación funciona, aunque vayamos añadiendo módulos y funcionalidades.

En resumen, hacer un buen testing nos asegura que estamos produciendo código de calidad y libre de errores. No libre de errores al 100%, porque eso no existe, pero sí de buena calidad. Y eso no solo dice mucho de nuestra profesionalidad, sino que te dará una tranquilidad a la hora de presentar tus proyectos a tus clientes o a tus jefes (¡o a tus profesores!) que te aseguro que no tiene precio.

La metodología TDD es útil cuando trabajas con proyectos grandes. Para proyectos pequeños, suele dar buenos resultados hacer un testing más o menos manual. Pero si trabajas así en un proyecto grande, notarás que al principio desarrollas muy deprisa pero, al cabo de unas semanas o unos meses, pasas más tiempo reparando cosas que no funcionan de tu código antiguo que desarrollando código y funcionalidades nuevas. Esta es la prueba más clara de que ese proyecto habria necesitado de un proceso de testing mucho más estricto. Estos proyectos suelen acabar abandonados porque se vuelven imposibles de mantener.

En las primeras fases de desarrollo de un proyecto grande, puede parecerte que usar TDD es una pérdida de tiempo. Pero ese tiempo se recupera con creces más adelante, cuando podemos seguir avanzando al mismo ritmo que al principio porque tenemos la seguridad de que todo sigue funcionando.

Y así llegamos a la última gran ventaja de seguir una metodología que automatice el testing: los programadores que trabajan de este modo y están acostumbrados a producir código de calidad ganan más dinero que los programadores chapuceros que prueban sus aplicaciones manualmente. Y lo hacen porque, a la larga, resultan mucho más productivos para su empresa.

Así que la decisión es tuya: ¿quieres ser un programador/a que genere código de calidad o te conformas con ser un programador/a chapucero?

2.6.2. Tipos de pruebas

La fase de pruebas de cualquier programa incluye diferentes tipos de prueba. Este no es un manual teórico sobre ingeniería del software (¡Dios nos libre!), pero necesitas conocer al menos estos tres tipos de prueba para entender cómo actúa la metodología TDD sobre el flujo de trabajo en un proyecto:

  • Test unitario: estas pruebas se dedican a comprobar que una funcionalidad muy concreta responde correctamente.

    Por ejemplo, imagina que en tu pantalla de login quieres exigir al usuario que el campo email tenga formato de una dirección de correo bien escrita, así que escribes el código necesario para hacer esa comprobación. Pues bien, cuando compruebas si esa comprobación está funcionando o no, estás haciendo un test unitario.

  • Test de integración: estas pruebas comprueban el funcionamiento de varias funcionalidades o varios componentes trabajando en conjunto.

    Por ejemplo, cuando has terminado de escribir toda tu pantalla de login y quieres comprobar que todo funciona, estás haciendo un test de integración. Ahí están en juego no solo las distintas funciones que has programado (comprobación del email, comprobación de la contraseña, consulta a la base de datos, etc), sino varios componentes de la aplicación: javascript en el lado del cliente, una conexión con el servidor, tal vez un enrutador, etc.

  • Test de aceptación: estas pruebas son parecidas a las de integración, pero aún más amplias, implicando a componentes dispersos que previamente habrás probado mediante test de integración y, en última instancia, a toda la aplicación en su conjunto. Es el test que se hace antes de poner la aplicación en producción o de hacer una demo ante un cliente.

2.6.3. ¿Qué es TDD?

La metodología TDD o test-driven development es diferente del testing o fase de pruebas. TDD es una metodología de desarrollo, es decir, una manera de trabajar en el desarrollo de un proyecto software, con la peculiaridad de que la metodología TDD implica que el testing va a estar presente a lo largo de todo el desarrollo.

Es decir, que puedes hacer testing sin metodología TDD, pero si trabajas con la metodología TDD, es seguro que vas a estar haciendo testing todo el tiempo.

De hecho, cuando usas TDD, no escribes ni una sola línea de código sin haber diseñado antes las pruebas para ese código. El testing así, en general, puede hacerse en cualquier momento del desarollo, pero si usas TDD estarás haciendo testing de forma continua.

Flujo de trabajo con TDD

Desarrollar cualquier parte de tu aplicación con TDD implica seguir siempre este orden:

  1. FASE ROJA: Consiste en diseñar las pruebas unitarias o de integración de lo que sea que vamos a desarrollar a continuación. Se llama fase roja porque estas pruebas siempre fallarán (y los errores, por aquello de que es un color muy llamativo, siempre se muestran en color rojo), ya que el código aún no está escrito.
  2. FASE VERDE: Consiste en escribir el código necesario para que las pruebas dejen de fallar, sin preocuparnos de si está muy bien escrito o no. Lo importante aquí es convertir esos errores en rojo en mensajes en verde.
  3. REFACTORIZACIÓN: Consiste en arreglar el código anterior para dejarlo bien legible y siguiendo las directrices de nuestra empresa o de nuestro grupo de trabajo en cuanto a calidad. Las pruebas, por supuesto, deben seguir dando un resultado verde, es decir, el código quedará vestido de domingo y seguirá sin fallar.

Unit Tests are FIRST

Los test unitarios pueden hacerse en cualquier momento del desarrollo, pero la metodología TDD aboga por hacerlos ANTES de escribir el código de las clases.

“Unit Tests are FIRST” no es solo una frase para recordarnos la conveniencia de diseñar los tests antes de escribir el código, sino que la palabra FIRST es un acrónimo de las características que deben tener esos tests según la metodología TDD:

  • Fast (rápido): el número de tests puede llegar a ser considerable. Si no se ejecutan con rapidez, cada vez que pasemos la batería de tests podemos tener que esperar un buen rato.

  • Isolated (aislado): debemos diseñar cada test para que sea independiente de factores externos y también de los otros tests. Un cambio en uno de los tests no debería afectar al resto.

  • Repeateable (repetible): los tests siempre deben ofrecer los mismos resultados ante los mismos datos de prueba.

  • Selfverifying (autoverificable): los tests bien hechos solo pueden ofrecer dos resultados: o son un éxito o fallan. No deben quedar a la interpretación del desarrollador.

  • Timely (oportuno): los tests deben escribirse antes que el código que se pretende verificar. Eso, que puede resultado antiintuitivo al principio, hace que tengamos mucho más claro qué comportamiento debe tener el código que vamos a escribir.

2.6.4. Implantar TDD en un proyecto PHP con PHP Unit

Vamos a ver cómo usar PHP Unit para automatizar las pruebas unitarias de nuestras aplicaciones. Y lo vamos a hacer mediante un ejemplo.

Supongamos que una determinada funcionalidad de nuestra aplicación tiene el cometido de comprobar que el campo email de un formulario no está vacío y tiene formato de email (es decir, una @ y al menos un punto). En caso contrario, debe mostrar un mensaje de error. Podríamos escribir ese código más o menos así:

$email = $_REQUEST["email"];
if ($email == "") {
   echo "Error: email incorrecto";
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
   echo "Error: email incorrecto";
}

Este ejemplo tan simple será el código con el que trabajaremos para comprender cómo se usa PHPUnit.

Instalar y preparar PHPUnit

PHPunit, como cualquier paquete adicional para PHP, puede instalarse manualmente o, mejor, con composer. Ya hablamos de cómo funciona este gestor de dependencias en el apartado 2.2.5, así que vuelve a leerlo si no lo recuerdas.

Configuraremos composer.json de este modo:

{
   "autoload": {
      "psr-4": {"Application\\": "src/"}
   },
   "require-dev": {
      "phpunit/phpunit": "^9.5",
      "phpunit/php-code-coverage": "^9.2"
   }
}

De este modo, le estamos indicando a composer que cargue automáticamente las clases que encuentre en la carpeta /src, que es donde deben estar los fuentes de tu aplicación (si no están ahí, cambia el archivo de configuración) y que las asigne al espacio de nombres (namespace) “Application”. También estamos declarando las dependencias de phpunit para que los paquetes necesarios se instalen en la carpeta /vendor, de manera que tengamos PHPUnit disponible durante el desarrollo.

Para lanzar la instalación de esos paquetes y la autocarga de las clases de la aplicación, ahora tienes que teclear estos comandos:

$ composer install
$ composer dump-autoload

Casos de prueba de PHPUnit

Los casos de prueba son clases que contienen el código necesario para probar otras clases.

Estos casos de prueba se suelen organizar del mismo modo que las clases reales. La costumbre es tener una carpeta llamada tests y, dentro de ella, reproducir la jerarquía de clases de todo el programa.

Además, existen una serie de convenciones de nombres que debemos respetar:

  • Si una clase de la aplicación se llama MiClase, el caso de prueba de esa clase debe llamarse MiClaseTest.
  • Los casos de prueba deben heredar de PHPUnit/Framework/TestCase.
  • Los tests que haya dentro de los casos de prueba deben ser métodos públicos, sin parámetros y contener el texto test en su nombre.
  • Todos los tests deben comportarse como afirmaciones. Pueden ser como la función assertTrue() del ejemplo anterior. En ese caso, estaríamos afirmando que cierta condición es true y, en otro caso, el test fallará. O pueden ser afirmaciones de tipo assertEquals() (el valor de algo es igual a cierto valor esperado o, si no, el test fallará), assertContains() (cierta estructura de datos contiene determinado valor; si no, el test fallará), assertCount() (una estructura de datos contiene un cierto número de elementos; si no es así, el test fallará), etc. PHPunit tiene predefinidas un enorme número de afirmaciones como estas.
  • Los tests se lanzan desde la línea de comandos. Lo más cómodo es usar la consola integrada dentro de Visual Studio Code (o del editor que estés usando, si dispone de esta funcionalidad). Si no, siempre puedes abrir un terminal de texto en tu sistema operativo y moverte hasta la carpeta de tu proyecto.

Escribiendo mi primer caso de prueba

Aunque es posible llamar a una sola clase de prueba, lo habitual es lanzar la batería completa de tests de un directorio concreto. En nuestro ejemplo no habrá diferencias porque, como es un ejemplo pequeñito, solo tendremos una clase (que llamaremos CheckFormLogin) y su correspondiente caso de prueba (CheckFormLoginTest). Esta clase contendrá las comprobaciones necesarias para saber si un formulario de login de una aplicación web ha sido cumplimentado correctamente.

El código de CheckFormLogin debe estar, como el resto de la aplicación, en la carpeta /src (u la carpeta que hayamos preferido, siempre que se lo indiquemos a PHPUnit en composer.json):

namespace Application;

class CheckFormLogin {

   private $status;     // Estado de la comprobación de errores. Si vale 0, es que no hay ningún error.
   private $email;      // Campos del formulario. Solo trabajaremos con el email, pero se pueden añadir más.

   /* Constructor. Pone el status a 0, que significa que no se ha detectado ningún error en el formulario. */
   function __construct($email) {
      $this->status = 0;
   }

   /* Comprueba si el campo email está correctamente escrito. Pone el status a 1 si el email está vacío y
      a 2 si el email no está correctamente formado. Devuelve el valor del status */
   function checkEmail() {      
      if ($this->email == "") {
         $this->status = 1;
      }
      if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
         $this->status = 2;
      }
      return $this->status;
   }

   /* Getter y setter para el atributo email */
   function getEmail() { 
      return $this->email; 
   }
   function setEmail($email) {
      $this->email = $email;
      return $this->email;
   } 
}

Por supuesto, esta clase puede contener otros atributos y otros métodos para comprobar que otros campos de un formulario de login se han rellenado correctamente, pero para nuestro ejemplo solo usaremos $email y checkEmail().

Necesitamos ahora escribir, en la carpeta /test, el caso de prueba correspondiente a la clase anterior:

namespace Test;
use PHPUnit\Framework\TestCase;
use Application\CheckFormLogin;

class ChecCheckFormLoginTest extends TestCase {
   public function testEmailIsEmpty() {
      $checkLogin = new CheckFromLogin();
      $checkLogin->setEmail("");           // Probamos un email vacío
      $status = $checkLogin->checkEmail();
      $this->assertEquals($status, 1);
   }
   public function testEmailBadFormat() {
      $checkLogin = new CheckFromLogin();  
      $checkLogin->setEmail("blablabla");  // Probamos un email mal formateado
      $status = $checkLogin->checkEmail();
      $this->assertEquals($status, 2);
   }
}

Lo que hemos hecho en el caso de prueba es testear que la clase que estamos probando, CheckFormLogin, se comporta como debe en cada situación. Por ejemplo, el primer método, testEmailIsEmpty(), comprueba qué pasa cuando la clase CheckFormLogin recibe como email una cadena vacía (el atributo status debería pasar a valer 1, y eso es lo que comprobamos). El otro método, testEmailBadFormat(), comprueba qué sucede si la clase CheckFormLogin recibe un email mal formateado, sin arrobas ni puntos (el status debería valer 2).

Por supuesto, se pueden añadir otras comprobaciones, como el caso de un email correctamente escrito (el status debería valer 0).

Se hace fácil ver que construir los casos de prueba es un trabajo bastante costoso y que, en más de una ocasión, requerirá más líneas de código que la propia aplicación.

Ejecutando los tests

Ya tenemos nuestro primer caso de prueba escrito para una de las clases de nuestra aplicación. Ha llegado el momento de lanzar el test.

Para eso, nos salimos a la consola de texto (recuerda: si lo haces desde dentro del propio Visual Studio Code, todo resultará más fácil) y escribimos:

$ ./vendor/bin/phpunit --colors test

Así ejecutaremos phpunit (que está dentro de la carpeta /vendor, como todos los paquetes de terceros) y lanzamos el test de colores (lo mencionamos al principio: fase roja, fase verde y refactorización. Por eso se llama “test de colores”).

PHPUnit lanzará todos los tests que encuentre y nos mostrará una pantalla de resultados, marcando en rojo los tests que hayan fallado, es decir, las afirmaciones o assets que no se hayan cumplido. Si pasamos los tests regularmente a toda la aplicación, podemos ir haciendo crecer nuestro código sin miedo a que los nuevos fragmentos de código rompan algo de lo que ya habíamos probado antes.

Fixtures

Casi todos los métodos de los casos de prueba siguen el mismo patrón:

  1. Preparamos la clase que queremos probar (en nuestro ejemplo anterior, CheckFormLogin), creando un objeto de esa clase con los valores que correspondan.
  2. Se lanza la acción que queremos probar (en nuestro ejemplo, checkEmail())
  3. Se realiza una afirmación sobre el resultado de la acción anterior (en nuestro ejemplo, que el status valga 1 o 2).

Hay ciertas partes de ese patrón que se repetirán una y otra vez en todos los métodos del caso de prueba. Para evitar ese copy-paste, que siempre es una pésima práctica de programación, existen las fixtures o características fijas que se repiten en todos los tests.

Incluso en el ejemplo anterior, que es muy simple, podemos observar una característica fija: todos los tests empiezan crando un objeto de tipo CheckFormLogin. Por supuesto, en tests más realistas y complejos podría haber bastante más código repetido.

Pues bien, PHPUnit permite evitar la duplicación de ese código mediante dos métodos que se pueden añadir al caso de prueba:

  • setUp() - El código que pongamos en este método se ejecutará ANTES que los tests. Suele usarse para crear objetos y configurarlos.
  • tearDown() - El código que escribamos aquí se ejecutar DESPUÉS de los tests, cuando todo haya terminado. Suele usarse para liberar la memoria o los recursos que hayan requerido los tests.

En nuestro ejemplo anterior, podríamos añadir esos dos métodos así:

namespace Test;
use PHPUnit\Framework\TestCase;
use Application\CheckFormLogin;

class ChecCheckFormLoginTest extends TestCase {

   private $checkLogin;   // Este será el objeto con el que haremos los tests   

   protected function setUp() {
      $this->checkLogin = new CheckLoginForm();
   }

   public function testEmailIsEmpty() {
      $this->checkLogin->setEmail("");           // Probamos un email vacío
      $status = $this->checkLogin->checkEmail();
      $this->assertEquals($status, 1);
   }

   public function testEmailBadFormat() {
      $this->checkLogin = new CheckFromLogin();  
      $this->checkLogin->setEmail("blablabla");  // Probamos un email mal formateado
      $status = $this->checkLogin->checkEmail();
      $this->assertEquals($status, 2);
   }

   protected function tearDown() {
      unset($this->checkLogin);
   }
}

Como puedes ver, ya no repetimos la línea checkLogin = new CheckFormLogin() al principio de cada método, sino que ese código repetido se escribe una sola vez, en el método setUp(). Y usamos el método tearDown() para eliminar el objeto que hemos creado. Esto último no es obligatorio (si no lo hubiéramos hecho, el recolector de basura de PHP se hubiera encargado de ello), pero mantener la simetría entre los métodos de creación y destrucción se considera una buena práctica de programación.

Como es lógico, en un caso más complejo que el de este ejemplo, tanto setUp() como tearDown() podrían tener mucho más trabajo que hacer.

Dobles de prueba

A veces, no se puede probar bien una clase porque esta necesita otros componentes (por ejemplo, otras clases) que no se pueden usar, por ejemplo porque aún no están desarrollados o porque tienen efectos secundarios que afectarían a la propia prueba. ¡Los diferentes componentes de las aplicaciones no suelen ser completamente independientes, y eso es un engorro a la hora de plantear las pruebas!

Esto puede hacer que nuestros tests sean muy dependientes unos de otros y, como vimos más arriba, los tests deberían ser completamente independientes entre sí.

Para solventar este problema existen los dobles de prueba, que se denominan así por los dobles de las películas de acción que sustituyen a los actores y actrices en las escenas peligrosas.

Si el objeto que estamos probando necesita usar otro objeto diferente, podemos usar un doble de prueba en su lugar. Se comportará como un objeto real, pero no lo será.

Por ejemplo, supón que estamos programando una aplicación para jugar al solitario con la baraja española, y que tenemos dos clases, una llamada Baraja y otra llamada Jugador. Son dos clases relacionadas entre sí:

class Baraja {
   private $cartas;

   public function __construct() {
      $this->cartas = array("1-oros", "2-oros", "3-oros", ..., "12-oros",
                            "1-copas", "2-copas", "3-copas", ... "12-copas",
                            "1-espadas", "2-espadas", ..., "12-espadas",
                            "1-bastos", "2-bastos", ..., "12-bastos");   
   }

   public function getCarta() {
      // Este método devuelve la siguiente carta de la baraja
   }
   public function getNumero($carta) {
      // Este método recibe una carta y devuelve solo su número
   }
   public function getPalo($carta) {
      // Este método recibe una carta y devuelve solo su palo
   }
   public function getAll() {
      // Este método nos devuelve todas las cartas que aún faltan por salir
   }
   public function getMonton() {
      // Este método nos devuelve todas las cartas que ya han salido
   }
   public function barajar() {
      // Este método baraja las cartas (las desordena al azar)
   }
   public function cartasDisponibles() {
      // Este método nos dice cuántas cartas quedan disponibles (sin salir)
   }
   public function reset() {
      // Este método vuelve a dejar la baraja en su estado inicial (ordenado)
   }
}
class Jugador {
   private $nombre;  // Nombre del jugador
   private $baraja;  // Baraja con la que va a jugar este jugador
   private $puntos;  // Puntos del jugador

   public function __construct($n) {
      $this->nombre = $n;
      $this->baraja = new Baraja();
      $this->baraja->barajar();
   }

   // Getter y setter del atributo $nombre
   public function getNombre(): string {
      return $this->nombre;
   }
   public function setNombre(string $n) {
      $this->nombre = $n;
   }

   // Getter y setter del atributo $baraja
   public function getBaraja(): Baraja {
      return $this->baraja;
   }
   public function setBaraja(Baraja $b) {
      $this->baraja = $b;
   }

   // Este método extrae las dos siguientes cartas de la baraja y las devuelve en un array.
   // Si no hay cartas disponibles, resetea la baraja antes de sacar las dos cartas.
   // Si el número de las dos cartas es igual, incrementa en 1 los puntos del jugador.
   public function sacarDosCartas() {
      if ($this->baraja->cartasDisponibles() < 2) {
         $this->baraja->reset();
         $this->baraja->barajar();
      }
      $carta1 = $this->baraja->getCarta();
      $carta2 = $this->baraja->getCarta();
      if ($baraja->getNumero($carta1) == $baraja->getNumero($carta2)) {
         $this->puntos++;
      }
      return array($carta1, $carta2);
   }

   // Etc. (aquí irían los demás métodos de la clase)
}

En este ejemplo, cuando creamos un objeto de tipo Jugador, también creamos, indirectamente, un objeto de tipo Baraja, puesto que uno de los atributos de Jugador es una Baraja y el objeto $this->baraja se crea en el constructor.

Observa también cómo el método getBaraja() devuelve un objeto de tipo Baraja y setBaraja() recibe un objeto de ese mismo tipo para asignarlo al atributo $this->baraja.

Más abajo, el método sacarDosCartas() hace un uso exhaustivo del objeto $this->baraja, fundamental para que la clase Jugador funcione bien.

Pues bien, ¿qué pasa cuando queremos escribir los tests de la clase Jugador? Porque si los tests deben ser independientes unos de otros, el test de Jugador no debería verse afectado por lo que ocurra en la clase Baraja. Por ejemplo, podría suceder que la clase Baraja aún no esté implementada completamente, o que contenga errores porque no haya sido depurada. En esos casos, los tests de la clase Jugador fallarían, pero no por errores en Jugador, sino por errores aún no resueltos en Baraja, que no es el componente que estamos probando ahora.

Dobles de prueba: stubs

Para solventar situación como la que acabamos de describir (que es muy habitual en sistemas complejos) se utilizan los dobles de prueba, que a veces se llaman stubs y a veces se llaman mocks. En concreto, necesitamos un doble de la clase Baraja que simule un comportamiento aceptable de esta clase para poder probar la clase Jugador, que es lo que ahora nos interesa.

Puedes ver cómo se crean esos dobles en este caso de prueba de la clase Jugador, que llamaremos JugadorTest:

namespace Test;
use PHPUnit\Framework\TestCase;
use Application\Jugador;

class JugadorTest extends TestCase {
   private $jugador;
   private $baraja;

   protected function setUp() {
      $this->persona = new Persona("John Doe");
      $this->baraja = $this->createMock(\Application\Baraja::class);
      $this->persona->setBaraja($this->baraja);
   }

   // Aquí irían los métodos de los tests de la clase Jugador
}

Observa con detenimiento el método setUp(). Verás que hemos construido un objeto Jugador (al que queremos hacer los tests) y también un objeto Baraja. Pero esa no es la clase que queremos probar, y por eso no creamos un objeto real, sino un doble de prueba.

Eso se consigue con createMock(), un método de PHPUNit heredado de TestCase. Este método solo necesita que le pasemos el nombre de una clase (que se obtiene con la expresión \Application\Baraja::class) para crear el doble de prueba. Para Jugador, este será como un objeto Baraja real. De hecho, inmediatamente después se lo pasamos a setBaraja() para que el objeto Jugador lo asuma como su baraja de cartas.

Pero el doble de prueba no es una Baraja de verdad, solo debe parecerlo y se debe comportar como si lo fuera. De modo que, en el test de Jugador, debemos especificar cuál debería ser un comportamiento apropiado con el único fin de testear la clase Jugador.

La forma más simple de hacer esto sería forzando a que los métodos de Baraja devuelvan a Jugador un valor cualquiera, siempre que tenga sentido. Esto se hace cuando solo necesitamos que los métodos del doble nos devuelvan algo sin importar el valor concreto que, en realidad, no afecta al funcionamiento de la clase que estamos testeando.

Cuando un doble de prueba se comporta así, devolviendo valores a lo loco (pero con sentido), se le suele llamar stub.

Por ejemplo, si te fijas en el método sacarDosCartas() de la clase Jugador, verás que fallará porque los métodos de Baraja que se usan allí aún no están implementados (cartasDisponibles(), reset(), barajar() o getCarta()). Esto provocará un fallo en el test, pero no es un error de Jugador.

Por lo tanto, para probar el funcionamiento del método sacarDosCartas(), usaremos un stub añadiendo en sacarDosCartasTest(), de este modo:

   // Test para el método sacarDosCartas()
   public function sacarDosCartasTest() {
      $this->baraja->method('cartasDisponibles')->willReturn(40);
      $this->baraja->method('reset');
      $this->baraja->method('barajar');
      $this->baraja->method('getCarta')->willReturn('3-bastos');
      // Aquí iría el resto del código del test.
   }

¡Y listo! Aunque los métodos cartasDisponibles(), reset() y compañía aún no existan en Baraja, podemos llamarlos en nuestro test y se comportarán como si existieran. De hecho, cartasDisponibles() nos devolverá el valor 40 y getCarta() nos devolverá “3-bastos”, que son valores correctos y perfectamente válidos para hacer nuestras pruebas. El valor que nos devolverá el stub se indica, como puedes ver en el ejemplo, con willReturn(). Por su parte, los métodos reset() y barajar() no devolverán nada, porque realmente no tienen que hacerlo.

Dobles de prueba: mocks

A veces, el doble de prueba no puede comportarse de forma tan simplona (devolver siempre el mismo valor para el mismo método), sino que necesitamos “simular” un comportamiento un poco más complejo. Por ejemplo, hacer varias llamadas consecutivas al método y que este nos devuelva valores diferentes cada vez.

Cuando un doble de prueba tiene un comportamiento como este último, se denomina mock en lugar de stub. Y el objeto Baraja (que, recuerda, no es un objeto de la clase Baraja real, sino un doble) dispone de muchos métodos para ello, como:

  • method(): para indicar qué método vamos a simular.
  • expects(): para especificar el número de veces que ese método debería ser llamado durante las pruebas.
  • with(): para especificar los parámetros que se le van a pasar al método en cada llamada.
  • will(): para especificar los valores que el método devolverá en cada llamada.

Estos métodos, a su vez, tienen multitud de variantes. Por ejemplo, will() tiene una variante llamada willReturnOnConsecutive(), que permite especificar una lista de valores que el mock devolverá en las sucesivas invocaciones. La lista de posibilidades es realmente grande y aquí no tenemos espacio para verla entera, pero puedes encontrarla en la documentación oficial de PHPUnit.

En el siguiente ejemplo, vamos a extender el método sacarDosCartasTest() para que compruebe una de las cosas que tiene que hacer esa función: incrementar los puntos del jugador en caso de que el número de dos cartas consecutivas sea el mismo. Para ello, forzaremos al mock para que nos devuelva unos valores concretos en las sucesivas llamadas. Observa cómo se hace eso usando willReturnOnConsecutive() en el método getCarta() del mock, que ahora devolverá “3-bastos” en la primera llamada y “3-oros” en la segunda.

   // Test para el método sacarDosCartas()
   public function sacarDosCartasTest() {
      $this->baraja->method('cartasDisponibles')->willReturn(40);
      $this->baraja->method('reset');
      $this->baraja->method('barajar');
      $this->baraja->method('getCarta')->willReturnOnConsecutive('3-bastos', '3-oros');
      // Aquí iría el resto del código del test. Por ejemplo, vamos a comprobar si
      // el método sacarDosCartas() incrementa los puntos del jugador cuando salen dos
      // cartas del mismo número
      $puntos_ini = $this->jugador->getPuntos();
      $this->baraja->sacarDosCartas();                  // Este método llamará dos veces a getCarta()
      $puntos_fin = $this->jugador->getPuntos();
      $this->assertEquals($puntos_fin - $puntos_ini, 1);  // Los puntos deberían haberse incrementado en 1
   }

El resto de cosas que hubiera que comprobar de sacarDosCartas() se pueden programar también con ayuda de mocks y stubs de la clase Baraja, igual que hemos comprobado que los puntos se incrementan al sacar dos cartas consecutivas del mismo número. Y eso mismo habría que hacerlo con todos los métodos de la clase Jugador. Recuerda que, en la metodología TDD, las pruebas se diseñan ANTES de escribir el código de las clases. Puede parecer engorroso (y, de hecho, lo es) pero el código resultante es muchísimo más robusto.

Tal vez el sistema de dobles de prueba de PHPUnit te haya parecido un poco difícil de usar. Es cierto: los dobles de prueba no son la mayor fortaleza de PHPUnit. Existen, por suerte o por desgracia, otras bibliotecas para hacer pruebas basadas completamente en la creación de dobles que tal vez te apetezca explorar, como Mockery o Phake (https://github.com/mlively/phake).

Cobertura

La cobertura de los tests es una medida de la cantidad de código que está siendo comprobada por los casos de prueba que hayamos escrito.

Una cobertura del 100% no indica necesariamente que hayamos escrito tests para todos los posibles comportamientos de nuestro código o todos los flujos de ejecución, pero sí es un indicador de que nos estamos aproximando a ello. Por el contrario, una cobertura baja siempre nos dice que nuestros casos de prueba son aún muy mejorables.

PHPUnit proporciona una herramienta para generar informes muy intuitivos acerca de la cobertura de nuestros tests. Para obtener ese informe en formato HTML, simplemente teclea esto en la consola de Visual Studio Code (o de tu IDE preferido):

$ XDEBUG_MODE = coverage ./vendor/bin/phpunit --coverage-filter test --coverage-html coverage test

Este comando generará una página HTML que se ubicará en el directorio coverage y que puede abrirse con cualquier navegador web.