Saltar a contenido

Pruebas con PHPUnit

El curso pasado, dentro del módulo de Entornos de Desarrollo, se estudió la importancia de la realización de pruebas, así como las pruebas unitarias mediante JUnit.

A día de hoy es de gran importancia seguir una buena metodología de pruebas, siendo el desarrollo dirigido por las pruebas (Test Driven Development / TDD) uno de los enfoques más empleados, el cual consiste en:

  1. Escribir el test, y como no hay código implementado, la prueba falle (rojo).
  2. Escribir el código de aplicación para que la prueba funcione (verde).
  3. Refactorizar el código de la aplicación con la ayuda de la prueba para comprobar que no rompemos nada (refactor).

En el caso de PHP, la herramienta que se utiliza es PHPUnit (https://phpunit.de/), que como su nombre indica, está basada en JUnit. La versión actual es la 11.5.

Se recomienda consultar su documentación oficial.

Puesta en marcha

Las pruebas se almacenan en una carpeta tests en el raíz del proyecto. Modificamos el archivo composer.json:

"require-dev": {
    "phpunit/phpunit": "^11.5"
},
"scripts": {
    "test": "phpunit --testdox --colors tests"
}

También puedes instalar PHPUnit desde la terminal:

composer require --dev phpunit/phpunit ^11.5

Librerías de desarrollo

Las librerías que se colocan en require-dev son las de desarrollo y testing, de manera que no se instalarán en un entorno de producción.

Como hemos creado un script, podemos lanzar las pruebas mediante:

composer test

Ejemplo de prueba:

<?php
use PHPUnit\Framework\TestCase;

class PilaTest extends TestCase
{
    public function testPushAndPop()
    {
        $pila = [];
        $this->assertSame(0, count($pila));

        array_push($pila, 'batman');
        $this->assertSame('batman', $pila[count($pila)-1]);
        $this->assertSame(1, count($pila));

        $this->assertSame('batman', array_pop($pila));
        $this->assertSame(0, count($pila));
    }
}

Métodos para ejecutar pruebas:

./vendor/bin/phpunit tests/PilaTest.php
./vendor/bin/phpunit tests
./vendor/bin/phpunit --testdox tests
./vendor/bin/phpunit --testdox --colors tests

Diseñando pruebas

Tal como hemos visto en el ejemplo, la clase de prueba debe heredar de TestCase, y el nombre de la clase debe acabar en Test, de ahí que hayamos llamado la clase de prueba como PilaTest.

Una prueba implica un método de prueba (público) por cada funcionalidad a probar. Cada un de los métodos se les asocia un caso de prueba.

Los métodos deben nombrarse con el prefijo test, por ejemplo, testPushAndPop. Es muy importante que el nombre sea muy claro y descriptivo del propósito de la prueba. (camelCase).

En los casos de prueba prepararemos varias aserciones para toda la casuística: rangos de valores, tipos de datos, excepciones, etc...

Aserciones

Las aserciones permiten comprobar el resultado de los métodos que queremos probar. Las aserciones esperan que el predicado siempre sea verdadero.

PHPUnit ofrece las siguiente aserciones:

  • assertTrue / assertFalse: Comprueba que la condición dada sea evaluada como true / false
  • assertEquals / assertSame: Comprueba que dos variables sean iguales
  • assertNotEquals / assertNotSame: Comprueba que dos variables NO sean iguales
    • Same → comprueba los tipos. Si no coinciden los tipos y los valores, la aserción fallará
    • Equals → sin comprobación estricta
  • assertArrayHasKey / assertArrayNotHasKey: Comprueba que un array posea un key determinado / o NO lo posea
  • assertArraySubset: Comprueba que un array posea otro array como subset del mismo
  • assertAttributeContains / assertAttributeNotContains: Comprueba que un atributo de una clase contenga una variable determinada / o NO contenga una variable determinada
  • assertAttributeEquals: Comprueba que un atributo de una clase sea igual a una variable determinada.

Comparando la salida

Si los métodos a probar generan contenido mediante echo o una instrucción similar, disponemos de las siguiente expectativas:

  • expectOutputString(salidaEsperada)
  • expectOutputRegex(expresionRegularEsperada)

Las expectativas difieren de las aserciones que informan del resultado que se espera antes de invocar al método. Tras definir la expectativa, se invoca al método que realiza el echo/print.

<?php
namespace Dwes\Videoclub\Model;

use PHPUnit\Framework\TestCase;
use Dwes\Videoclub\Model\CintaVideo;

class CintaVideoTest extends TestCase {
    public function testConstructor()
    {
        $cinta = new CintaVideo("Los cazafantasmas", 23, 3.5, 107); 
        $this->assertSame( $cinta->getNumero(), 23);
    }

    public function testMuestraResumen()
    {
        $cinta = new CintaVideo("Los cazafantasmas", 23, 3.5, 107);
        $resultado = "<br>Película en VHS:";
        $resultado .= "<br>Los cazafantasmas<br>3.5 (IVA no incluido)";
        $resultado .= "<br>Duración: 107 minutos";
        // definimos la expectativa
        $this->expectOutputString($resultado);
        // invocamos al método que hará echo
        $cinta->muestraResumen();
    }
}

Proveedores de datos

Cuando tenemos pruebas que solo cambian respecto a los datos de entrada y de salida, es útil utilizar proveedores de datos.

Se declaran en el docblock mediante @dataProvider nombreMetodo, donde se indica el nombre de un método público que devuelve un array de arrays, donde cada elemento es un caso de prueba.

La clase de prueba recibe como parámetros los datos a probar y el resultado de la prueba como último parámetro.

El siguiente ejemplo comprueba con diferentes datos el funcionamiento de muestraResumen:

<?php
/**
 * @dataProvider cintasProvider
 */
public function testMuestraResumenConProvider($titulo, $id, $precio, $duracion, $esperado)
{
    $cinta = new CintaVideo($titulo, $id, $precio, $duracion);
    $this->expectOutputString($esperado);
    $cinta->muestraResumen();
}

public function cintasProvider() {
    return [
        "cazafantasmas" => ["Los cazafantasmas", 23, 3.5, 107, "<br>Película en VHS:<br>Los cazafantasmas<br>3.5 €(IVA no incluido)<br>Duración: 107 minutos"],
        "superman" => ["Superman", 24, 3, 188, "<br>Película en VHS:<br>Superman<br>3 € (IVA no incluido)<br>Duración: 188 minutos"],
    ];
}

Probando excepciones

Las pruebas además de comprobar que las clases funcionan como se espera, han de cubrir todos los casos posibles. Así pues, debemos poder hacer pruebas que esperen que se lance una excepción (y que el mensaje contenga cierta información):

Para ello, se utilizan las siguiente expectativas:

  • expectException(Excepcion::class)
  • expectExceptionCode(codigoExcepcion)
  • expectExceptionMessage(mensaje)

Del mismo modo que antes, primero se pone la expectativa, y luego se provoca que se lance la excepción:

<?php
public function testAlquilarCupoLleno() {
    $soporte1 = new CintaVideo("Los cazafantasmas", 23, 3.5, 107); 
    $soporte2 = new Juego("The Last of Us Part II", 26, 49.99, "PS4", 1, 1);
    $soporte3 = new Dvd("Origen", 24, 15, "es,en,fr", "16:9"); 
    $soporte4 = new Dvd("El Imperio Contraataca", 4, 3, "es,en","16:9"); 

    $cliente1 = new Cliente("Bruce Wayne", 23); 
    $cliente1->alquilar($soporte1); 
    $cliente1->alquilar($soporte2); 
    $cliente1->alquilar($soporte3); 

    $this->expectException(CupoSuperadoException::class);
    $cliente1->alquilar($soporte4); 
}

Cobertura de código

La cobertura de pruebas indica la cantidad de código que las pruebas cubren, siendo recomendable que cubran entre el 95 y el 100%.

Una de las métricas asociadas a los informes de cobertura es el CRAP (Análisis y Predicciones sobre el Riesgo en Cambios), el cual mide la cantidad de esfuerzo, dolor y tiempo requerido para mantener una porción de código. Esta métrica debe mantenerse con un valor inferior a 5.

Requisito xdebug

Aunque ya viene instalado dentro de PHPUnit, para que funcione la cobertura del código, es necesario que el código PHP se ejecute con XDEBUG, y e indicarle a Apache que así es (colocando en el archivo de configuración php.inila directiva xdebug.mode=coverage).

Añadimos en composer.json un nuevo script:

"coverage": "phpunit --coverage-html coverage --coverage-filter app tests"

Y posteriormente ejecutamos

composer coverage

Por ejemplo, si accedemos a la clase CintaVideo con la prueba que habíamos realizado anteriormente, podemos observar la cobertura que tiene al 100% y que su CRAP es 2.

Informe de cobertura de la clase CintaVideo

Temas pendientes

  • Dependencia entre casos de prueba con el atributo @depends
  • Completamente configurable mediante el archivo phpxml.xml: https://phpunit.readthedocs.io/es/latest/configuration.html
  • Preparando las pruebas con setUpBeforeClass() y tearDownAfterClass()
  • Objetos y pruebas Mock (dobles) con createMock()

Actividades

  • 📝 AC 507. (RA4 RA5 / CE4f CE5h / IC1 / 3p) - A partir de la clase HolaMonolog, modifica los métodos para que además de escribir en en log, devuelvan el saludo como una cadena. Crea la clase HolaMonologTest y añade diferentes casos de prueba para comprobar que los saludos y despedidas son acordes a la hora con la que se crea la clase.