Testeando bases de datos

5.1 Formas de testear dependencias con la base de datos

Para realizar test con dependencia de base de datos hay que afrontarlos de tres formas posibles, en función del tipo de test que consideremos mejor se adapta a nuestras necesidades.

  • Test unitario, utilizando test dobles. Test tipo de tests lo explicaremos en el capítulo 6, dedicado a tests dobles, aunque veremos otras situaciones como en el capítulo 10, dedicado a testear API. Resumiendo: consiste en falsear objetos que simulan el comportamiento de la base de datos, por lo que testearíamos la lógica de más alto nivel de las clases que afecten a dicha base de datos. No podríamos considerar un tests de base de datos realmente, aunque en muchas situaciones este tipo de tests son suficientes.

  • Integración, con fixture de datos concretos. Este tipo de tests acceden realmente a base de datos, pero el estado de la base de datos es conocido, dado que antes de la ejecución del conjunto de tests se realizan tareas para crear y rellenar la base de datos.

  • Test de aceptación. Estos tests simulan la aplicación en su conjunto. Para estos tests en entornos web la aplicación será ejecutada por completo, ejecutando un navegador y simulando el comportamiento de un usuario interactuando con algún mecanismo de simulación de navegadores web. Aunque el objetivo de estos tests no sea específicamente verificar la interacción entre la aplicación y la base de datos, como efecto de realizar pruebas sobre todo el sistema, la base de datos también quedará ejercitada y testeada.

5.2 Dificultades de testear base de datos

Testear fragmentos de código con dependencia a bases de datos suele presentar varios complicaciones:

  • La funcionalidad que se quiere testear ejecuta una compleja consulta con entre tablas (join).
  • La lógica de negocio ejecuta SELECT, INSERT o DELETE.
  • La inicialización de los datos no es sencilla, requiere mecanismos de inserción previos a la ejecución de los tests y de limpieza de los datos una vez ejercitados los tests.
  • La gestión del esquemas de base de datos y tablas, desde el punto de vista de mantenibilidad. Con esto queremos decir que para mantener funcionales los tests de bases de datos necesitamos actualizar la base de datos de nuestro SUT con relación a la base de datos de producción.

Los tests que ejecutan sentencias en base de datos deben ser cortos por las siguientes razones:

  • Mantenibilidad: Realizar pequeños cambios en el código que se ejecuta a producción puede romper un gran conjunto de tests.
  • Legibilidad: Pequeños y concisos tests son más legibles y pueden mantenerse mejor en posteriores desarrollos de funcionalidades o refactorizaciones.

5.3 Estados cuando se testea con dependencia de código de base de datos

En el libro xUnit Test Patterns [48] se define estos cuatro pasos:

  • Configurar el fixture.
  • Ejercitar el sistema bajo test.
  • Verificar los resultados.
  • Limpiar el fixture.

Estos pasos no son exactamente los mismos que debemos ejecutar cuando testeamos bajo dependencia de la base de datos. Los pasos que debemos considerar son:

  • Limpiar la base de datos: Para tener un entorno bajo un sistema de control la base de datos debe de estar en un estado conocido. La forma de comenzar de forma segura es limpiar la base de datos.
  • Configurar el fixture: En un test sin dependencia de base de datos, configurar el fixture, como vimos en el capítulo 4, consiste en la inicialización de los objetos que interactuarán con nuestro SUT. En un test con bases de datos necesitaremos crear el contenido necesario realizando las operaciones de inserción sean apropiadas para conocer el estado inicial de la base de datos.
  • Ejecutar y verificar los resultados, como en cualquier tests.
  • Ejecutar el proceso de tearDown, para dejar la base de datos en un estado conocido para la ejecución de los siguientes tests.

5.4 Replicación del entorno de producción

Para ejecutar test en un entorno con bases de datos es imprescindible que el sistema a testear sea lo más parecido posible al de producción. Para ello, es necesario mantener actualizado el esquema de la base de datos, así como otro tipo de procedimientos. Crear una copia del esquema de la base de datos en el entorno donde se ejecutarán los tests es la única forma de realizar esta tarea.

Dependiendo de si lo que deseamos realizar son test de integración o funcionales, mantener la base de datos de test es más complejo. Para los tests de integración es necesario mantener la estructura de la base de datos, procedimientos, triggers y otras configuraciones. Para realizar test de aceptación es necesario tener, al menos, un subconjunto de los datos. Dado que los tests funcionales son, al fin y al cabo, simulaciones de usuarios reales.

En algunos proyectos esto se resuelve manteniendo una copia parcial de los datos reales. Esta práctica puede tener algún incumplimiento con leyes sobre privacidad de datos, por lo que una mejor solución sería crear una simulación de la base de datos, lo más parecida posible a producción. En el capítulo 4 vimos herramientas, como Faker [13], que nos pueden ayudar a generar el conjunto de datos necesarios para simular un entorno funcional completo.

5.5 Testeando base de datos con PHPUnit

PHPUnit [1] ofrece algunas herramientas integradas para ayudar a testear bases de datos. En este apartado haremos un resumen sobre los puntos más interesantes, aunque en el ejemplo para este capítulo no usaremos dichas herramientas.

En primer lugar, PHPUnit nos proporciona una clase distinta a la genérica PHPUnit_Framework_TestCase para testear con base de datos: PHPUnit_Extensions_Database_TestCase. Dicha clase es una clase abstracta con los métodos getConnection y getDataSet. getConnection, como su nombre indica, nos obliga a implementar la conexión a base de datos sobre la que realizaremos los tests.

Por otra parte, getDataSet es un método para crear el fixture inicial en base de datos sobre el que realizaremos los tests. En la propia documentación de PHPUnit recomiendan como buena práctica crear una implementación de PHPUnit_Extensions_Database_TestCase que sea compartida para todas las clases de testeo de base de datos, con el objetivo tener en un único lugar la conexión y datos. Esta última recomendación mantiene el principio DRY (del inglés, don't repeat yourself) cosa que en software es una buena práctica.

Para preparar el fixture, PHPUnit nos da la posibilidad de importar distintos tipos de ficheros:

  • XML: En formato simple (sin poder tener valores nulos) o extendido.
  • Volcado de datos en formato XML de MySQL.
  • CSV.
  • YAML.
  • Array PHP.

PHPUnit ofrece distintas funciones para procesar el fixture, como sustituciones (para reemplazar cambios de valores de campos), filtros (para seleccionar campos o filas en caso de fixtures grandes), composiciones (para agregar varios fixture en uno) y aserciones propias de bases de datos (assertTablesEqual y assertDataSetsEqual).

5.6 Ejemplo de tests con base de datos

Para ver como se testea con dependencia de bases de datos lo mejor es ver un caso práctico. El siguiente ejemplo es un sistema simplificado de Flickr.com, un sitio web en los que los usuarios pueden guardar sus fotografías.

Los tests que realizaremos son de integración y no utilizaremos las herramientas propias de PHPUnit. Por el contrario, utilizaremos una simulación simplificada de DbUnit, incluida en PHPUnit [1], aunque el concepto es el mismo.

El esquema de la base de datos para nuestro ejemplo sería el siguiente:


CREATE TABLE `User` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) DEFAULT NULL,
  `email` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`),
  UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `Photo` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `url_photo` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `user_idx` (`user_id`),
  CONSTRAINT `user_idx` FOREIGN KEY (`user_id`) REFERENCES `User` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Dado que utilizaremos PDO con la forma FETCH_CLASS, es decir, asociando a una clase el resultado de una consulta necesitaremos las clases User y Photo:


class User {
    public $id;
    public $username;
    public $email;
    public $photos = Array();
    public function getPhotos() {
        return $this->photos;
    }
    public function addPhoto(Photo $photo) {
        $this->photos[] = $photo;
    }
    public function addPhotos(array $photos) {
        $this->photos = array_merge($this->photos, $photos);
    }
}

class Photo {
    public $id;
    public $user_id;
    public $url_photo;
}

Para gestionar el acceso a base de datos hemos creado las clases UserRepository y PhotoRepository, de forma que sean estas clases las responsables de ejecutar las consultas.

El código de la clase UserRepository sería:


class UserRepository
{
    protected $db;

    public function __construct(PDO $db)
    {
        $this->db = $db;
    }

    public function storeUser(User $user)
    {
        $sql = 'INSERT INTO User(username, email) VALUES (:username, :email)';
        $stm = $this->db->prepare($sql);
        $stm->execute(array(':username' => $user->username, ':email' => $user->email));
        $userId = $this->db->lastInsertId();
        if (!$userId) {
            throw new Exception('User not saved');
        }
        $user->id = $userId;

        if (count($user->getPhotos()>0)) {
            $photoRepository = new PhotoRepository($this->db);
            foreach($user->getPhotos() as $photo) {
                $photoRepository->storePhoto($photo, $user);
            }

        }

        return $user;
    }

    public function removeUser(User $user) {
        $sql = 'DELETE FROM User where id=:id'; 
        $stm = $this->db->prepare($sql);
        $stm->execute(Array(':id' => $user->id));

    }

    public function getById($id)
    {
        $sql = 'SELECT * FROM User where id = :user_id';
        $stm = $this->db->prepare($sql);
        $stm->execute(array(':user_id' => $id));
        $stm->setFetchMode(PDO::FETCH_CLASS, 'User');
        $user = $stm->fetch();

        $sql = 'SELECT * FROM Photo where user_id = :user_id';
        $stm = $this->db->prepare($sql);
        $stm->execute(array(':user_id' => $id));
        $stm->setFetchMode(PDO::FETCH_CLASS, 'Photo');
        $photos = $stm->fetchAll();
        if(is_array($photos) && count($photos)>0) {
            $user->addPhotos($photos);
        }

        return $user;
    }
}

Y el código de la clase PhotoRepository sería:


class PhotoRepository {

    protected $db;

    public function __construct($db) {
        $this->db = $db;
    }

    public function storePhoto(Photo $photo, User $user)
    {
        $sql = 'INSERT INTO Photo(user_id, url_photo) VALUES (:user_id, :url_photo)';
        $stm = $this->db->prepare($sql);
        $stm->execute(array(':user_id' => $user->id, ':url_photo' => $photo->url_photo));
        $photoId = $this->db->lastInsertId();
        if (!$photoId) {
            throw new Exception('Photo not saved');
        }
        $photo->id = $photoId;

        return $photo;
    }
}

Para la ejecución de los tests unitarios hemos decidido utilizar un conjunto de datos generado de forma dinámica. Para ello, y como vimos en el capítulo 4, utilizado Faker [13] y Alice [14], con el siguiente fichero YAML:


User:
    user{1..10}:
        username: <username()>
        email: <email()>

Photo:
    photo{1..3}:
        url_photo: <imageUrl()>

Y el código de los tests unitarios:


class UserRepositoryTest extends PHPUnit_Framework_TestCase {

    protected static $pdo;

    public static function setUpBeforeClass() {
       try {
            $host = 'mysql:host=localhost;dbname=photostock';
            self::$pdo = new PDO($host, 'root', 'PASS');
        } catch (\Exception $e) {
            $this->markTestSkipped('MySQL conection is not working.');
        }
    }

    public function setUp() {
        $loader = new Nelmio\Alice\Loader\Yaml();
        $this->data = $loader->load(__DIR__.'/fixtures/users.yml');

        $this->userRepository = new UserRepository(self::$pdo);
    }

    public function tearDown(){
        self::$pdo->query("set foreign_key_checks=0");
        self::$pdo->query("TRUNCATE User");
        self::$pdo->query("TRUNCATE Photo");
        self::$pdo->query("set foreign_key_checks=1");
    }

    public function testStoreUser() {
        $user = $this->data['user1'];
        $this->assertNull($user->id);
        $user = $this->userRepository->storeUser($user);
        $this->assertObjectHasAttribute('id', $user);
    }

    public function testStoreUserWithPhotos() {

        $user = $this->data["user1"]; 
        $this->assertNull($this->data["photo1"]->id);
        $this->assertNull($this->data["photo2"]->id);
        $this->assertNull($this->data["photo3"]->id);
        $user->addPhoto($this->data["photo1"]);
        $user->addPhoto($this->data["photo2"]);
        $user->addPhoto($this->data["photo3"]);

        $user = $this->userRepository->storeUser($this->data['user1']);
        $photos = $user->getPhotos();
        foreach($photos as $photo) {
            $this->assertGreaterThan(0, $photo->id);
        }
    }

    public function testGetUserById(){
        $user = $this->userRepository->storeUser($this->data['user1']);
        unset($user);
        $user = $this->userRepository->getById(1);
        $this->assertEquals($this->data['user1']->username, $user->username);
    }

    public function testGetUserByIdWithPhotos() {
        $user = $this->data["user1"]; 
        $user->addPhoto($this->data["photo1"]);
        $user->addPhoto($this->data["photo2"]);
        $user->addPhoto($this->data["photo3"]);
        $user = $this->userRepository->storeUser($this->data['user1']);
        unset($user);
        $user = $this->userRepository->getById(1);
        $this->assertEquals($this->data['user1']->username, $user->username);
        $this->assertEquals(3, count($user->getPhotos()));
    }
}

Como comparativa con DbUnit, nos hemos tomado varias libertades:

  • La conexión de la base de datos la hemos creado en el método setUp.
  • La carga del fixture lo hemos puesto en memoria, con instancias de objetos User y Photo en lugar de la configuración de la base de datos.
  • El proceso propio de tearDown lo hemos ejecutado manualmente, al ejecutar las correspondientes sentencias SQL TRUCATE.

El motivo por el que hemos decidido no seguir la forma normal de testar con dependencias con bases de datos es porque la documentación extendida y algunas funcionalidades fueron introducidas en PHPUnit 3.5. Antes de dicha versión se podían testear código dependiente de base de datos, pero requería algunos procesos. Por ejemplo, para la carga del fixture inicial se puede ejecutar un el comando "mysql load data" en el método setUp de cada test, y el truncate tal y como hacemos en nuestro ejemplo.

Las versiones posteriores PHPUnit 3.4 facilitan estas tareas, pero los conceptos de mantener la base de datos en un estado bajo control y los mecanismos para ello, siguen siendo análogos.

En este ejemplo hemos decidido realizar un test de integración y ejercitar el código que accede directamente a la base de datos. Testear una pieza de código con dependencias de otros sistemas como base de datos se puede realizar de otra forma, utilizando tests dobles, como mencionábamos al principio del capítulo. Dado que este capítulo se centraba especialmente en tests de base de datos hemos decido no hacer test con tests dobles y ejercitar las consultas directamente.

results matching ""

    No results matching ""