Test Doubles o Dobles de prueba y como usarlos en Laravel

Spread the love

¿Cómo podemos probar la lógica de forma independiente cuando nuestro código depende de otra clase?

Cuando trabajamos usando TDD las cosas se ponen interesantes cuando comenzamos a probar clases que dependen de otras para hacer su trabajo. a veces es posible instanciar las dependencias y seguir probando, pero algunos casos son mas complicados porque son dependencias que a su vez necesitan de otras clases o se comunican con componentes que no están disponibles en nuestro ambiente de pruebas y en algunas ocasiones es posible que la clase que necesitamos todavía no existe.

Para estos escenarios se usan los Dobles de prueba (Test Double).

Ejemplo de dobles de prueba

¿Qué son los dobles de prueba?

Esta es una buena pregunta que voy a responderte en breve, pero antes veamos que es una dependencia.

Una dependencia se define como una relación entre dos clases, en la que una clase depende de otra (La utiliza), pero la otra clase puede o no depender de la primera, por lo que cualquier cambio en una de las clases puede afectar a la funcionalidad de la otra, que depende de la primera.

Es decir que si tenemos una clase A que necesita a otra clase B a través de su constructor o algún método para hacer su trabajo, podemos decir que la clase A depende de B.

veamos un ejemplo de esto:

class Temperature
{
    private TemperatureSensor $sensor;

    public function __construct(TemperatureSensor $heatSensor)
    {
        $this->sensor = $heatSensor;
    }

    public function average(): float
    {
        $total = 0.00;
        for ($i=0; $i<3; $i++) {
            $total += $this->sensor->readTemp();
        }
        return $total/3;
    }
}

En el ejemplo la clase Temperature usa o depende de una implementación de TemperatureSensor para recibir 3 lecturas y calcular el promedio de esas lecturas.

Sin esa dependencia la clase no puede hacer su trabajo y por lo tanto no podemos probarla. Así que necesitamos buscar la forma de entregar una versión de TemperatureSensor que nos permita probar la clase Temperature.

Un doble de prueba es un objeto que sustituye a una dependencia real en las pruebas automatizadas.

Es decir que usamos un objeto que se comporta de forma muy similar al original pero que podemos controlar para fines de nuestra prueba.

regresemos al ejemplo y antes de continuar necesitamos buscar una implementación de TemperatureSensor para ver si podemos usarla sin tener que sustituirla.

Supongamos que tenemos suerte y encontramos la siguiente clase.

class HeatSensor implements TemperatureSensor
{
    private object $dataReader;

    public function __construct(DataReader $heatReader)
    {

        $this->dataReader = $heatReader;
    }

    public function readTemp() : float
    {
        return $this->dataReader->read();
    }
}

Rayos! esta clase también depende de otra interfaz!, asi que esto nos complica usar la clase original.

¿Qué podemos hacer?

Cómo funcionan los dobles de prueba

Eliminar dependencias
Eliminar dependencias reales

En la imagen, puedes ver del lado izquierdo las dependencias reales que ya vimos que no podemos usar de forma directa, para solucionar el problema lo que debes de hacer es eliminar las dependencias originales que están a la izquierda y cambiarlas por una implementación que funcione para nuestra prueba (DoubleHeatSensor) como se muestra en la imagen que esta hacia la derecha

DoubleHeatSensor solo debe de tener lo necesario para la prueba, esto nos ayuda porque ahora podemos hacer que la clase entregue 3 lecturas que nosotros podemos determinar y de esta forma evaluar que la clase Temperature realiza los cálculos de forma correcta.

A esta estrategia de intercambiar clases reales por otras que funcionan muy parecido se le conoce como Dobles de prueba(Tes Double),

Con esta idea en mente ya podemos escribir una prueba

    public function test_calculates_the_average_temperature()
    {
        //Arrange
        $service = new DoublebHeatSensor(); 

        //Act
        $temperature = new Temperature($service);

        //Assert
        $this->assertEquals(12, $temperature->average());
    }

La clase DoubleHeatSensor tiene la siguiente implementación para la prueba.

class HeatSensor implements TemperatureSensor {

    private array $data;

    public function __construct()
    {
        $this->data = [10.00, 12.00, 14.00];
    }

    public function readTemp(): float
    {
        return array_shift($this->data);
    }
}

Si observas la clase solo regresa las lecturas y ni siquiera usa dependencias. De esta forma podemos controlar las lecturas de temperatura que queremos que use la clase Temperature.

con este cambio ahora podemos probar nuestra clase y no marcara ningún error ya que cree que esta usando la clase HeatSensor original.

λ punit --filter=TemperatureTest
PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 00:01.068, Memory: 10.00 MB

OK (2 tests, 3 assertions)

La prueba pasa sin problema.

Ya que vimos que so los dobles de prueba y como funcionan ahora vemos sus tipos

Clasificación de dobles de prueba

Como en todas las cosas de nuestra profesión también los dobles de prueba se clasifican, dependiendo del propósito que quieres lograr con tus pruebas.

Tipos de dobles de prueba

Dummy

Los objetos dummy se pasan pero nunca se usan realmente. Por lo general, sólo se utilizan para llenar las listas de parámetros.

En nuestro ejemplo si queremos probar que la clase Temperature se puede instanciar, necesita una implementación de TemperatureSensor. Pero para fines de la prueba no se necesita usara ninguno de sus métodos, así que podemos crear una implementación como la siguiente.

class DummyHeatSensor implements TemperatureSensor {
    
    public function readTemp(): float
    {
        return null;
    }
}

$sensor = new DummyHeatSensor();
$temperature = new Temperature($sensor);

//Esta afirmación pasa
$this->assertInstanceOf(Temperature::class, $temperature); //👌

Fakes

Los Objetos Fake realmente tienen implementaciones que funcionan, pero normalmente toman algún atajo que los hace no aptos para producción.

class InMemoryDataReader implements DataReader {
    private array $dataSource;

    public function __construct()
    {
        $this->dataSource = [10.00, 12.00, 14.00];
    }   
 
    public function read(): float
    {
        return array_shift($this->dataSource;
    }
}

$sensor = new HeatSensor(new InMemoryDataReader);
$temperature = new Temperature($sensor);

//Esta afirmación pasa
$this->assertInstanceOf(Temperature::class, $temperature); //👍🏼
$this->assertEquals(12, $temperature->average()); //👍🏼

Si necesitas usar la clase HeatSensor de forma mas “real” entonces en lugar de cambiarla esa clase le proporcionamos mejor una implementación de DataReader y de esta forma puedes probar la clase Temperature de forma mas natural.

En el ejemplo creamos la clase InMemoryDataReader para este propósito.

Stubs

Los Stubs proporcionan respuestas enlatadas a las llamadas realizadas durante la prueba, normalmente no responden a nada fuera de lo programado para la prueba.

Este es el caso de nuestro ejemplo donde usamos DoubleHeatSensor que es una clase que solo regresa las lecturas programadas y no hace nada mas porque ni siquiera usa sus dependencias.

Spyes

Los Objetos Spy(espías) son Stubs que también registran alguna información basada en cómo fueron llamados. En nuestro ejemplo podemos verificar que el calculo se realizo llamando al método readTemp.

class SpyHeatSensor implements TemperatureSensor {

    private array $data;

    public bool $called = false;

    public function __construct()
    {
        $this->data = [10.00, 12.00, 14.00];
    }

    public function readTemp(): float
    {
        $this->called = true;;
        return array_shift($this->data);
    }
}

$sensor = new SpyHeatSensor();
$temperature = new Temperature($sensor);

//Esta afirmación pasa
$this->assertInstanceOf(Temperature::class, $temperature); //👍🏼
$this->assertEquals(12, $temperature->average()); //👍🏼
$this->assertTrue($temperature->called); //👌 verifica que se llamo al método

Mocks

Los Mocks están preprogramados con expectativas que forman una especificación de las llamadas que se espera que reciban. Pueden lanzar una excepción si reciben una llamada que no esperan y se comprueban durante la verificación para asegurarse de que han recibido todas las llamadas que esperaban.

Este es el ultimo doble y también es un Espía pero con esteroides; este tipo despues del Fake es el mas complicado de implementar porque interviene en nuestras pruebas de forma mas activa y genera mas acoplamiento.

En nuestro ejemplo no requerimos verificar parámetros, pero si podemos verificar la llamada de métodos y si se invoco el numero de veces que requería el código de nuestra prueba.

class MockHeatSensor implements TemperatureSensor {

    private array $data;

    public bool $called = false;

    public int $callCount = 0;

    public function __construct()
    {
        $this->data = [10.00, 12.00, 14.00];
    }

    public function readTemp(): float
    {
        ++$this->callCount;
        return array_shift($this->data);
    }
}

$sensor = new MockHeatSensor();
$temperature = new Temperature($sensor);

//Esta afirmación pasa
$this->assertInstanceOf(Temperature::class, $temperature); //👍🏼
$this->assertEquals(12, $temperature->average()); //👍🏼
$this->assertTrue($temperature->called); //👌 verifica que se llamo al método
$this->assertEquals(3, $temperature->callCount); //👌

¿Tengo que programar mis dobles de prueba?

En realidad no, afortunadamente existen frameworks que nos ayudan a crear cualquier tipo de doble de prueba.

Así que en Laravel tenemos algunas opciones.

Mockery

Laravel tiene integrada la librería mockery, con la cual hacer un doble es muy sencillo, para nuestro ejemplo podemos reescribir nuestra prueba y obtener el mismo resultado como te muestro en el siguiente ejemplo:

public function test_calculates_the_average_temperature()
    {
        //Arrange
        $service = \Mockery::mock(\App\Services\HeatSensor::class);
        $service->shouldReceive('readTemp')
                    ->times(3)
                    ->andReturn(10,12,14);

        //Act
        $temperature = new Temperature($service);

        //Assert
        $this->assertEquals(12, $temperature->average());
    }

$service ahora recibe una instancia creada por mockery, la cual verifica que se invoco al método readTemp (Should) mediante tres llamadas (times) y regrese en cada llamada una lista de valores (andReturn) o lecturas de temperatura.

Obviamente usar un doble de esta forma genera mas código y como te debes haber imaginado puede haber ocasiones en las que un doble tenga un setup mas complicado al que tenemos de ejemplo y obviamente será mas difícil de leer.

Mocking Objects

En Laravel es muy común utilizar clases que se resuelven mediante Facades o que se gestionan a través de contenedor de dependencias de Laravel.

Para estos casos en Laravel puede usar $this->mock en las pruebas

use App\Service;
use Mockery\MockInterface;

$mock = $this->mock(Service::class, function (MockInterface $mock) {
    $mock->shouldReceive('process')->once();
});

Si quieres usar un spy solo tienes que usar $this->spy

use App\Service;

$spy = $this->spy(Service::class);

// ...

$spy->shouldHaveReceived('process');

Mocking Facades

También Laravel cuenta con alguno mocks que en realidad por su funcionamiento diría que son Fakes.

Estos mocks nos ayudan a facilitar las pruebas con algunos servicios como el envío de correos, probar notificaciones, etc.

Por ejemplo puedes hacer diferentes pruebas con el uso de eventos

class ExampleTest extends TestCase
{
    /**
     * Test order shipping.
     */
    public function test_orders_can_be_shipped()
    {
        Event::fake();

        // código

        // El evento fue disparado?
        Event::assertDispatched(OrderShipped::class);

        // Se disparo el evento 2 veces ?
        Event::assertDispatched(OrderShipped::class, 2);

        // El eventos no fue disparado?
        Event::assertNotDispatched(OrderFailedToShip::class);

        // Ningún eventos fue disparado
        Event::assertNothingDispatched();
    }
}

Conclusión

El uso de dobles de pruebas nos ayuda a:

  • Aislar el código bajo prueba
  • Acelerar la ejecución de las pruebas
  • Hacer que la ejecución sea determinista
  • Simular condiciones especiales
  • Acceder a información oculta

Y son grandes ventajas, pero debes de ser cuidadoso en su uso, ya que algunas versiones como los mocks acoplan el código de pruebas, ya que los mocks normalmente nos obligan a probar la implementación de las clases, en lugar de su comportamiento.

También otra desventaja desde mi punto de vista es que en escenarios complejos puedes tener dobles difíciles de leer como es el caso cuando usas librerías como Mockery.

Para terminar te dejo la regla básica de uso de los dobles:

Son la segunda opción si no puedes usar las dependencias originales.

¿Te gusto el articulo?

No olvides dejar tu comentario y compartirlo.