Tests de integración con Entity Framework (II): Escribiendo tests cómodamente

En el anterior post de esta serie vimos cómo preparar el entorno para crear tests de integración con entity framework y llegamos a una situación en la que teníamos creado un modelo simple para nuestro ejemplo y las herramientas necesarias para generar y limpiar una base de datos. Partiendo de eso, en este post vamos a ver cómo podemos escribir los tests de integración de forma cómoda y efectiva.

OJO: Aunque llevo mucho tiempo trabajando con ORMs, no soy un experto en Entity Framework y hay partes de su filosofía que no me acaban de gustar, por lo que puede que el código resulte un poco extraño o que, directamente, esté mal. Estaré encantado de recibir correcciones en los comentarios.

Gestionando la vida de la base de datos

Para escribir tests sobre una base de datos es fundamental poder dejarla en un estado conocido antes de cada test. Para ello, vamos a usar la técnica que vimos al señalar los conceptos básicos para testear bases de datos, aprovechando los mecanismos que ofrece NUnit para controlar el ciclo de vida de un test:

  1. Crearemos la base de datos una única vez antes de ejecutar todos los tests usando un SetupFixture.
  2. Borraremos todas las tablas de la base de datos antes de ejecutar cada clase de tests usando un TestFixtureSetup.
  3. Borraremos las tablas concretas afectadas por los tests de cada fixture antes de ejecutar cada test usando un Setup.

El último paso es concreto para cada clase de test, pero los dos primeros los podemos reutilizar creando un par de clases.

[SetUpFixture]
public class DBTestSetup
{
  [SetUp]
  public void Setup()
  {
    using (var context = new TestDbContext())
      DbTools.CreateNewDatabase(context);
  }
}

Esta clase será la encargada de crear la base de datos una sola vez antes de ejecutar todos los tests. El resto de las clases de tests deberán estar en el mismo espacio de nombres o en espacios de nombres hijos. La clase TestDbContext no es más que una clase derivada de nuestro MovieDbContext que veíamos en el post anterior y que se encarga de fijar el nombre de la base de datos que usaremos para los tests.

Con esto tenemos resuelto el primero punto de nuestra lista. Vamos a por el segundo:

public abstract class DBTest
{
  [TestFixtureSetUp]
  public void TestFixtureSetup()
  {
    Execute(DbTools.CleanDatabase);
    BeforeAll();
  }

  protected virtual void BeforeAll() {}

  [SetUp]
  public virtual void BeforeEach() {}

  protected void Execute(Action<MovieDbContext> action)
  {
    using (var context = new TestDbContext())
      action(context);
  }

  protected T Execute<T>(Func<MovieDbContext, T> query)
  {
    using (var context = new TestDbContext())
      return query(context);
  }
}

Ésta será la clase base para nuestros tests de integración contra la base de datos. Se encarga de borrar todas las tablas de la base de datos antes de empezar la ejecución de los tests y ofrece puntos de enganche en los métodos BeforeAll y BeforeEach para ejecutar inicialización personalizada en las clases hijas.

Además, incluye un par de métodos de utilidad, los Execute, para simplicar el uso del DbContext. En el código real este tipo de métodos no suelen ser necesarios ya que la gestión del ciclo de vida del DbContext se realizará en la capa superior de la aplicación (controladores MVC/WebAPI, handlers en un Bus, etc.), y muchas veces de forma declarativa (mediante atributos, filtros o decoradores), pero para los tests resulta cómodo hacerlo así.

Escribiendo un test

Ya tenemos todo listo para escribir nuestro primer test. Nuestro modelo de ejemplo era algo así:

Modelo de objetos

Teníamos películas producidas por uno o más países, dirigidas por una persona, que se habían estrenado en un año y habían conseguido una recaudación determminada. Lo que vamos a testear en el ejemplo en una consulta que nos permita generar un informe de recaudación anual de las películas de un determinado país.

Nuestro objetivo es escribir tests pequeños y focalizados en distintos aspectos de la consulta, de forma que resulten sencillos de entender. Además, necesitamos que sea fácil añadir nuevos tests para verificar cada aspecto de la consulta.

Teniendo eso claro, podemos imaginarnos cómo nos gustaría escribir un test. ¿Qué tal algo así?

[Test]
public void Returns_Only_Movies_From_Selected_Country()
{
  AddMovie("Titanic", 1997, 1000, usa);
  AddMovie("Airbag", 1997, 25, spain);

  var result = GetResults(spain);

  Assert.That(result.Length, Is.EqualTo(1));
  Assert.That(result[0].Year, Is.EqualTo(1997));
  Assert.That(result[0].Gross, Is.EqualTo(25));
}

Este test es bastante fácil de leer, es cómodo de escribir y se centra en un aspecto concreto de la consulta: comprobar que sólo se incluyen en los resultados las películas producidas por el país indicado. Eso se consigue con los métodos AddMovie y GetResults que nos permiten olvidarnos de aspectos necesarios para el código de producción (como el director de la película) pero irrelevantes para el test.

Si os fijáis, eso tiene otra características muy importante: no hace referencia para nada al código de producción. Tenemos aislado el código del test no sólo de la implementación del código de producción, sino también de su API, por lo que podríamos refactorizar bastante el código de producción y este test nos seguiría sirviendo.

Parece que el primero objetivo, tener un test focalizado en un aspecto de la consulta, lo podemos conseguir. ¿Y el siguiente? ¿Es fácil escribir más tests?

[Test]
public void Returns_The_Total_Gross_By_Year()
{
  AddMovie("Airbag", 1997, 25, spain);
  AddMovie("Abre los ojos", 1997, 100, spain);

  var result = GetResults(spain);

  Assert.That(result.Length, Is.EqualTo(1));
  Assert.That(result[0].Gross, Is.EqualTo(125));
}

Este otro test comprueba que se suman las recaudaciones de las películas de un mismo año, y partiendo del API que hemos diseñado antes (AddMovie, GetResults), es muy sencillo escribirlo.

Sólo nos queda entonces ver qué tenemos que hacer para convertir estos tests en realidad, y no es más que añadir un poco de infraestructura en nuestra clase de test, que quedaría más o menos así:

[TestFixture]
public class GrossByCountryAndYearTests : DBTest
{
  private Country spain;
  private Country usa;
  private Person lucas;

  protected override void BeforeAll()
  {
    Execute(context =>
    {
      spain = new Country("España");
      usa = new Country("USA");
      lucas = new Person("George Lucas");

      context.Countries.Add(spain);
      context.Countries.Add(usa);
      context.People.Add(lucas);

      context.SaveChanges();
    });
  }

  public override void BeforeEach()
  {
    Execute(context =>
    {
      context.Movies.RemoveRange(context.Movies.ToList());
      context.SaveChanges();
    });
  }

  private GrossByCountryAndYearQuery.Result[] GetResults(Country country)
  {
    var query = new GrossByCountryAndYearQuery(country.Name);
    return Execute(context => query.Execute(context)).ToArray();
  }

  private void AddMovie(string title, int year, decimal gross, params Country[] countries)
  {
    Execute(context =>
    {
      context.Movies.Add(new Movie(title, year, lucas, countries) {Gross = gross});
      context.SaveChanges();
    });
  }
  
  // Casos de test
}

Antes de que empecéis a chillarme, aquí hay unas cuantas cosas que merecen una explicación.

En el método BeforeAll estamos guardando datos de referencia para el resto de entidades que construiremos durante nuestros tests. Estos datos no deberían modificarse nunca durante la ejecución de los tests (si lo hicieran, cada test no partiría de un estado conocido).

Los datos maestros los guardamos como atributos de la clase para poder usarlos luego; esto va un poco contra la forma en que debería usarse un ORM, ya que son entidades que van a estar desconectadas de los DbContext y aun así formarán parte de relaciones con otras entidades, pero puesto que no se modifican, no debería ser problemático.

En el método BeforeEach limpiamos las cosas que se modifican en cada test. La forma más sencilla es hacerlo (y que suele ser suficiente) es a través de Entity Framework para que se encargue de borrar las tablas necesarias, aunque esto obliga a cargar las entidades en memoria. Si te preocupa mucho, puedes borrar las tablas a mano con sentencias SQL, pero eso hará que los tests queden más acoplados al esquema de la base de datos.

Al limpiar las tablas antes de ejecutar el test en lugar de hacerlo después (en el Teardown) nos permite ejecutar por separado cualquier test que esté fallando e inspeccionar el estado de la base de datos, cosa bastante más incómoda de hacer si borramos las tablas en el Teardown.

Con el método AddMovie conseguimos añadir una película a la base de datos abstrayéndonos de la forma concreta de construirla y añadirla, lo que nos permite centrar los tests en las propiedades de las películas que nos importan para el test concreto.

Aquí estamos usando mal (o al menos de forma discutible) el DbContext, ya que creamos uno por cada película que guardamos en lugar de encapsular todas las operaciones relacionadas en un único UnitOfWork, pero en mi experiencia merece la pena para ganar legibilidad en los tests individuales.

Por último el método GetResults se encarga de lanzar la consulta. Al igual que el método AddMovie, este método nos permite aislarnos del API del código de producción, y si este cambia nos ahorra tener que retocar todos los tests que tengamos en la clase. Ambos son un buen ejemplo de cómo evitar dependencias sobre cosntructores y APIs no testeadas en los tests.

En github podéis encontrar la implementación final de la clase de test, la consulta y el código completo.

Resumen

En estos últimos tres posts hemos visto como podemos testear una base de datos, empezando por los conceptos básicos que debemos tener en cuenta y viendo un ejemplo concreto de cómo hacerlo.

Testear una base de datos no es algo sumamente complejo, pero requiere un poco de planificación y organización a la hora de escribir los tests; con un montaje adecuado podemos conseguir tests muy sencillos de escribir, de leer y de mantener.

No olvidéis que esto es un ejemplo con un modelo de juguete. Cuando veáis la consulta es posible que penséis que no merece la pena montar tanta historia para testear una consulta tan simple, pero tened en cuenta que el montaje que hemos hecho es aplicable a escenarios muchos más complejos. Además, la infraestructura de tests que hemos construido se puede reutilizar a lo largo de toda la aplicación, con lo que el coste de definirla se amortiza bastante rápido.