Creando tests de integración con base de datos idempotentes

Las baterías de tests juegan un rol importante en la detección de defectos y regresiones de todo proyecto de software, en este artículo abordaremos uno de los problemas comunes en tests de integración. Contar con tests efectivos hará posible que el equipo avance con confianza, especialmente en proyectos con diseño evolutivo.

Construir, probar y entregar, el ciclo de todo desarrollo, se complica a medida que el número de funcionalidades aumenta. Una vez que el producto supere la decena de funcionalidades, probar manualmente cada una de ellas después de cada cambio terminará por consumir nuestro tiempo y paciencia.

Y puede ser mucho peor: ¿te ha tocado añadir funcionalidades a un proyecto legacy complejo?, probablemente tu suerte esté en manos una documentación parcialmente desactualizada, tu memoria (suponiendo que tuvieras la suerte de formar parte del equipo de desarrollo original) y lo legible que pueda ser el código fuente. ¿Puedes modificar esta aplicación con garantías de que no se rompa por el camino?. Probablemente no.

El antiguo debate «testing manual por perfiles QA» VS «tests automatizados» se ha zanjado en favor del segundo. Entre otras razones porque:

  • Los tests están asociados a una revisión concreta de la aplicación, van en el mismo repositorio. Si fallan, o bien la funcionalidad se ha roto o el test no ha sido actualizado con los últimos cambios implementados.
  • Sirven de documentación viva y ejemplos de uso.
  • Pueden ser requisito para el merge de una pull request. Que no entre nada «roto».
  • Prácticas comunes como «Continuous delivery» poco menos que lo requieren.
  • Son infinitamente más rápidos que los test manuales.
  • Una vez creados, solo requieren que le des al «play» y esperes los resultados.

Siendo este el escenario, automaticemos nuestros tests. Al fin y al cabo, ¿no es eso a lo que nos dedicamos?. Automatización de procesos repetitivos.

Test de integración

Los tests automatizados tienen tres metas fundamentales: ser deterministas, rápidos y que cubran la funcionalidad de manera efectiva. Es además recomendable que cumplan con los siguientes puntos:

  • Poder ser ejecutados de manera atómica.
  • Poder ser ejecutados en cualquier orden.
  • El resultado de un test no debería verse afectado por los tests ejecutados previamente.

A diferencia de los test unitarios, donde cada componente se prueba de manera aislada de sus colaboradores, los tests de integración pueden incluir componentes que conservan estado; la base de datos en el caso aquí tratado. Esto resulta ser un obstáculo a la hora de cumplir con algunas de las recomendaciones descritas arriba.

Seguir «las reglas» evitará que en un futuro tengamos que dedicar tiempo a evaluar si el fallo de un test se debe a una regresión o es un error «normal» dado que el test del que dependíamos ha fallado. Por poner un ejemplo, el test A crea una empresa y el test B borra la empresa creada por el test A. El test B es por tanto completamente dependiente del éxito del test A. La discriminación de fallos «fantasma» es peligrosa: podríamos terminar ignorando errores reales (este test suele fallar, será lo de siempre…).

Existen al menos dos vías más o menos obvias para afrontar los problemas derivados del estado de la base de datos:

  1. Crear un dataset enorme que se carga al inicio de la batería de tests, haciendo que el test A cree una nueva empresa y el test B borre una empresa distinta pre-existente en base de datos.
  2. Cargar el dataset antes de la ejecución de cada uno de los tests.

La primera opción es claramente más rápida en ejecución pero más laboriosa y propensa al fallo; tests que terminan haciendo uso de los mismos registros.

Combinando lo mejor de ambas opciones

Dado que en Irontec solemos hacer uso de algún ORM, podemos cambiar de software de bases de datos modificando un fichero de configuración y hacer uso de soluciones distintas en escenarios distintos.

Sqlite es una base de datos, plenamente soportada por doctrine, que utiliza un único fichero que podemos alojar en cualquier directorio de nuestro file system . Hacer un backup del fichero de base de datos y restaurarlo (copy & paste automatizado) antes de la ejecución de cada test es sencillo y rápido (al menos más rápido que reimportar el dataset inicial en una mysql).

Nota: En realidad, hacer que nuestro esquema de base de datos fuese compatible con mysql y sqlite requirió de algo más que cambiar una setting: renombrar algunos índices, etcétera, nada especialmente complicado.

Definiendo la nueva base de datos para el entorno test

El primer paso es sencillo, hacer que nuestro framework hable con sqlite cuando es ejecutado en el entorno test. El siguiente snippet corresponde a nuestro config_test.yml de un proyecto symfony:

imports:
  - { resource: config.yml }
...
doctrine:
  dbal:
    driver: 'pdo_sqlite'
    path: '%kernel.cache_dir%/db.sqlite'
    charset: 'UTF8'

Cargando el dataset

Antes de lanzar los tests tendremos que crear las tablas y cargar sus registros. El proyecto doctrine cuenta con herramientas para ambas tareas.

  doctrine:database:create                Creates the configured database
  doctrine:database:drop                  Drops the configured database
  doctrine:fixtures:load                  Load data fixtures to your database.
  doctrine:schema:create                  Executes (or dumps) the SQL needed to generate the database schema

Por último creamos el backup de la base de datos que será recuperado antes de cada test. El siguiente script de shell agrupa los distintos pasos:

#!/bin/bash

set -e

pushd /opt/irontec/ivozprovider/web/rest
    # Prepare dataset
    bin/console api:prepare:database -e test
    # Load fixtures
    bin/console doctrine:fixtures:load -e test --no-interaction -v

    # Create initial dataset file
    mv var/cache/test/db.sqlite var/cache/test/db.sqlite.back

    ....
    # Run tests
    ....
popd

Ya solo quedaría copiar y pegar la base de datos antes de la ejecución de cada uno de los test.

Implementación para phpunit

Si utilizamos phpunit, simplemente nos aseguraremos de ejecutar el copy & paste desde el método setUp, que está definido en la clase abstracta PHPUnit_Framework_TestCase. Nosotros hemos implementado resetDatabase en un pequeño trait para incluirlo de manera sencilla en cada uno de los TestCase que lo requieran.

trait DbIntegrationTestHelperTrait
{
...
    /**
     * {@inheritDoc}
     */
    protected function setUp()
    {
        ...
        $this->resetDatabase();
        ...
    }
...
    protected function resetDatabase()
    {
        $cacheDir = self::$kernel->getCacheDir();
        $fs = new Filesystem();

        $dbFile = $cacheDir . DIRECTORY_SEPARATOR . 'db.sqlite';

        if ($fs->exists($dbFile)) {
            $fs->remove($dbFile);
        }

        $fs->copy(
            $dbFile . '.back',
            $dbFile
        );
    }
}

 

class BrandLifeCycleTest extends KernelTestCase
{
    use DbIntegrationTestHelperTrait;

    /**
     * @test
     */
    public function it_persists_brands()
    {
        $brandRepository = $this->em
            ->getRepository(Brand::class);

        ...
        $brands = $brandRepository->findAll();
        $this->assertCount(3, $brands);
    }
...
}

 

Implementación para behat

La integración con behat también resulta sencilla. En este caso jugamos con anotaciones creando los tags @createSchema y @dropSchema.

use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;

class FeatureContext implements Context, SnippetAcceptingContext
{

    /**
     * @var Filesystem
     */
    protected $fs;

    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can also pass arbitrary arguments to the
     * context constructor through behat.yml.
     */
    public function __construct(\AppKernel $kernel, Request $request)
    {
        ...
        $this->fs = new Filesystem();
        ...
    }

    /**
     * @BeforeScenario @createSchema
     */
    public function resetDatabase()
    {
        $this->dropDatabase();
        $this->fs->copy(
            $this->cacheDir . DIRECTORY_SEPARATOR . 'db.sqlite.back',
            $this->cacheDir . DIRECTORY_SEPARATOR . '/db.sqlite'
        );
    }

    /**
     * @AfterScenario @dropSchema
     */
    public function dropDatabase()
    {
        $this->fs->remove(
            $this->cacheDir . DIRECTORY_SEPARATOR . 'db.sqlite'
        );
    }
}

Y nos aseguramos de invocarlo antes de cada escenario:

Feature: Manage brands
  In order to manage brands
  As an super admin
  I need to be able to retrieve them through the API.

  @createSchema
  Scenario: Retrieve the brand json list
    Given I add Authorization header
     When I add "Accept" header equal to "application/json"
      And I send a "GET" request to "brands"
     Then the response status code should be 200
      And the response should be in JSON
      And the header "Content-Type" should be equal to "application/json; charset=utf-8"
      And the JSON should be equal to:
    """
      [
        {
            "name": "DemoBrand",
            "id": 1
        },
        {
            "name": "Irontec_e2e",
            "id": 2
        }
      ]
    """

Cierre

Eso es todo amigos, espero que hayáis encontrado algo útil o interesante en este artículo. Si te has enfrentado a esto mismo y has optado por otro tipo de solución, nos encantaría leerlo en los comentarios.

 



¿Te gusta este post? Es solo un ejemplo de cómo podemos ayudar a tu empresa...
Sobre Mikel Madariaga

Desarrollador de aplicaciones, principalmente PHP y javascript orientado a web, desde el lanzamiento del pan Bimbo de corteza tierna blanca.

Queremos tu opinión :)