Compartir a través de


Pruebas contra el sistema de base de datos de producción

En esta página, se describen técnicas para escribir pruebas automatizadas que implican al sistema de base de datos en el que se ejecuta la aplicación en producción. Existen enfoques de prueba alternativos, donde el sistema de base de datos de producción se intercambia por dobles de prueba; consulte la página de información general sobre pruebas para obtener más información. Tenga en cuenta que aquí no se explican las pruebas en una base de datos diferente de la que se usa en producción (por ejemplo, Sqlite), ya que la base de datos diferente se usa como un doble de prueba. Este enfoque se aborda en Pruebas sin el sistema de base de datos de producción.

El principal obstáculo para la realización de pruebas con una base de datos real es garantizar un aislamiento adecuado de las pruebas, de modo que las pruebas que se ejecutan en paralelo (o incluso en serie) no interfieran entre sí. El código completo del ejemplo siguiente se puede consultar aquí.

Sugerencia

En esta página se muestran técnicas de xUnit, pero existen conceptos similares en otros marcos de pruebas, como NUnit.

Configuración del sistema de base de datos

La mayoría de los sistemas de base de datos actuales se pueden instalar fácilmente, tanto en entornos de CI como en máquinas para desarrolladores. Aunque suele ser bastante fácil instalar la base de datos mediante el mecanismo de instalación habitual, también hay disponibles imágenes de Docker listas para usar para la mayoría de bases de datos principales y pueden facilitar la instalación en CI. Para el entorno de desarrollo, GitHub Codespaces, Dev Container puede configurar todos los servicios y dependencias necesarios, incluida la base de datos. Aunque esto requiere una inversión inicial en la configuración, una vez completada, tiene un entorno de pruebas operativo y puede centrarse en cosas más importantes.

En determinados casos, las bases de datos tienen una edición o una versión especial que puede resultar útil para las pruebas. Al trabajar con SQL Server, se puede usar LocalDB para ejecutar pruebas de forma local sin prácticamente ninguna configuración, la instancia de base de datos se activa a petición y, posiblemente, se ahorran recursos en las máquinas para desarrolladores menos eficaces. Pero LocalDB también presenta algunos inconvenientes:

  • No admite todo lo que SQL Server Developer Edition.
  • Solo está disponible en Windows.
  • Puede producir un retraso en la primera serie de pruebas cuando se inicia el servicio.

Por lo general, se recomienda instalar la versión SQL Server Developer en lugar de LocalDB, ya que proporciona el conjunto completo de características de SQL Server y es muy fácil de implementar.

Cuando se usa una base de datos en la nube, es aconsejable realizar las pruebas en una versión local de la base de datos, tanto para mejorar la velocidad como para reducir los costos. Por ejemplo, al usar SQL Azure en producción, puede realizar las pruebas en un servidor de SQL Server instalado localmente; los dos son muy similares (aunque sigue siendo aconsejable ejecutar las pruebas también en SQL Azure antes de pasar a producción). Si se usa Azure Cosmos DB, el emulador de Azure Cosmos DB es una herramienta útil tanto para desarrollar de forma local como para ejecutar pruebas.

Creación, inicialización y administración de una base de datos de prueba

Una vez instalada la base de datos, tendrá todo listo para empezar a usarla en las pruebas. En la mayoría de los casos sencillos, el conjunto de pruebas tiene una base de datos única que se comparte entre varias pruebas en varias clases de prueba, por lo que necesitamos cierta lógica para asegurarnos de que la base de datos se crea y se inicializa solamente una vez durante la serie de pruebas.

Al usar xUnit, esto se puede hacer mediante un accesorio de clase, que representa la base de datos y se comparte entre varias series de pruebas:

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    context.AddRange(
                        new Blog { Name = "Blog1", Url = "http://blog1.com" },
                        new Blog { Name = "Blog2", Url = "http://blog2.com" });
                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

Cuando se crea una instancia del accesorio de prueba anterior, esta usa EnsureDeleted() para anular la base de datos (en caso de que exista por una ejecución anterior) y, después, usa EnsureCreated() para crearla con la configuración del modelo más reciente (consulte la documentación de estas API). Una vez creada la base de datos, el accesorio lo inicializa con algunos datos que pueden usar nuestras pruebas. Vale la pena dedicar algún tiempo a pensar en los datos de inicialización, ya que cambiarlos más adelante para una nueva prueba puede provocar un error en las pruebas existentes.

Para usar el accesorio de prueba en una clase de prueba, simplemente implemente IClassFixture sobre el tipo de accesorio y xUnit lo insertará en el constructor:

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

La clase de prueba ahora tiene una propiedad Fixture que pueden usar las pruebas para crear una instancia de contexto totalmente funcional:

[Fact]
public void GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = controller.GetBlog("Blog2").Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

Por último, es posible que haya observado algún bloqueo en la lógica de creación del accesorio anterior. Si el accesorio solo se usa en una única clase de prueba, seguro que xUnit creará una instancia del accesorio una única vez; pero es habitual usar el mismo accesorio de base de datos en varias clases de prueba. xUnit proporciona accesorios de colección, pero ese mecanismo impide que las clases de prueba se ejecuten en paralelo, lo que es importante para el rendimiento de las pruebas. Para administrar esto de forma segura con un accesorio de clase de xUnit, aplicamos un bloqueo simple en la creación e inicialización de la base de datos, y usamos una marca estática para asegurarnos de que nunca tenemos que hacerlo dos veces.

Pruebas que modifican datos

En el ejemplo anterior se muestra una prueba de solo lectura, que es el caso sencillo desde el punto de vista del aislamiento de las pruebas: como no se está modificando nada, no puede haber interferencias en otras pruebas. Por el contrario, las pruebas que modifican datos son más problemáticas, ya que pueden interferir entre sí. Una técnica común para aislar la escritura de pruebas es encapsular la prueba en una transacción y revertir esa transacción al finalizar la prueba. Dado que no se confirma nada realmente en la base de datos, el resto de pruebas no se ven modificadas y se evitan posibles interferencias.

Este es un método de controlador que agrega un blog a nuestra base de datos:

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

Podemos probar este método con el código siguiente:

[Fact]
public void AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = context.Blogs.Single(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

Algunas notas sobre el código de prueba anterior:

  • Iniciamos una transacción para asegurarnos de que los cambios siguientes no se confirman en la base de datos y no interfieren con otras pruebas. Dado que la transacción nunca se confirma, se revierte implícitamente al final de la prueba cuando se elimina la instancia de contexto.
  • Después de realizar las actualizaciones que queremos, borramos el seguimiento de cambios de la instancia de contexto con ChangeTracker.Clear, para asegurarnos de que cargamos el blog desde la base de datos siguiente. Podríamos usar dos instancias de contexto, pero tendríamos que asegurarnos de que ambas usan la misma transacción.
  • Es posible que incluso quiera iniciar la transacción en la API CreateContextdel accesorio, para que las pruebas reciban una instancia de contexto que ya está en una transacción y está lista para recibir actualizaciones. Esto puede ayudar a evitar casos en los que la transacción queda olvidada accidentalmente, lo que provoca una interferencia entre pruebas que puede ser difícil de depurar. También puede que quiera separar las pruebas de solo lectura y escritura en diferentes clases de prueba.

Pruebas que administran explícitamente transacciones

Hay una categoría final de pruebas que presenta una dificultad adicional: pruebas que modifican los datos y también administran explícitamente las transacciones. Dado que las bases de datos normalmente no admiten transacciones anidadas, no es posible usar transacciones para el aislamiento como se ha indicado anteriormente, ya que deben usarse mediante el código real del producto. Aunque estas pruebas tienden a ser menos frecuentes, es necesario controlarlas de forma especial: debe limpiar la base de datos a su estado original después de cada prueba, y la paralelización debe deshabilitarse para que estas pruebas no interfieran entre sí.

Vamos a examinar el siguiente método de controlador como ejemplo:

[HttpPost]
public ActionResult UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

    var blog = _context.Blogs.FirstOrDefault(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

Supongamos que, por algún motivo, el método requiere el uso de una transacción serializable (no suele ser el caso). Como resultado, no podemos usar una transacción para garantizar el aislamiento de la prueba. Dado que la prueba confirmará realmente los cambios en la base de datos, definiremos otro accesorio con su propia base de datos independiente para asegurarnos de no interferir con las otras pruebas mostradas más arriba:

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }
}

Este accesorio de prueba es similar al usado anteriormente, pero se diferencia en que contiene un método Cleanup al que llamaremos después de cada prueba para asegurarnos de que la base de datos se restablece a su estado inicial.

Si este accesorio solo lo usará una única clase de prueba, podemos hacer referencia a él como un accesorio de clase como anteriormente, ya que xUnit no paraleliza las pruebas dentro de la misma clase (obtenga más información sobre las colecciones de pruebas y la paralelización en la documentación de xUnit). Pero si queremos compartir este accesorio entre varias clases, debemos asegurarnos de que estas clases no se ejecuten en paralelo para evitar interferencias. Para ello, usaremos esto como un accesorio de colección de xUnit en lugar de como un accesorio de clase.

En primer lugar, definimos una colección de pruebas, la cual hace referencia a nuestro accesorio y se usará en todas las clases de prueba transaccionales que la requieran:

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

Ahora hacemos referencia a la colección de pruebas en nuestra clase de prueba y aceptamos el accesorio en el constructor como hemos hecho antes:

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

Por último, hacemos que nuestra clase de prueba sea descartable y que se llame al método Cleanup del accesorio después de cada prueba:

public void Dispose()
    => Fixture.Cleanup();

Tenga en cuenta que, dado que xUnit solo crea una instancia del accesorio de colección una vez, no es necesario bloquear la creación y la inicialización de la base de datos como hemos hecho anteriormente.

El código completo del ejemplo anterior se puede consultar aquí.

Sugerencia

Si tiene varias clases de prueba con pruebas que modifican la base de datos, puede ejecutarlas en paralelo usando diferentes accesorios, cada uno de los cuales debe hacer referencia a su propia base de datos. La creación y el uso de múltiples bases de datos de prueba no es problemático y debe realizarse siempre que sea útil.

Creación eficaz de bases de datos

En los ejemplos anteriores, hemos usado EnsureDeleted() y EnsureCreated() antes de ejecutar las pruebas para asegurarnos de tener una base de datos de prueba actualizada. Estas operaciones pueden ser un poco lentas en determinadas bases de datos, lo que puede suponer un problema al iterar los cambios en el código y volver a ejecutar las pruebas una y otra vez. En estos casos, es posible que quiera convertir EnsureDeleted en comentario temporalmente en el constructor del accesorio. Esto reutilizará la misma base de datos en todas las series de pruebas.

La desventaja de este enfoque es que, si cambia el modelo de EF Core, el esquema de su base de datos no estará actualizado y es posible que se produzcan errores en las pruebas. Como resultado, solo se recomienda hacer esto temporalmente durante el ciclo de desarrollo.

Limpieza eficaz de bases de datos

Hemos visto anteriormente que, cuando los cambios se confirman realmente en la base de datos, debemos limpiar la base de datos entre prueba y prueba para evitar interferencias. En el ejemplo de prueba transaccional anterior, hicimos esto mediante las API de EF Core para eliminar el contenido de la tabla:

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

context.AddRange(
    new Blog { Name = "Blog1", Url = "http://blog1.com" },
    new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();

Normalmente, esta no es la manera más eficaz de borrar una tabla. Si la velocidad de la prueba es un problema, es posible que quiera usar consultas SQL sin formato para eliminar la tabla en su lugar:

DELETE FROM [Blogs];

También puede considerar la posibilidad de usar el paquete respawn, que borra eficazmente una base de datos. Además, no requiere que especifique las tablas que se van a borrar y, por tanto, no es necesario actualizar el código de limpieza a medida que se agregan tablas al modelo.

Resumen

  • Al realizar pruebas en una base de datos real, merece la pena distinguir entre las siguientes categorías de pruebas:
    • Las pruebas de solo lectura son relativamente sencillas y siempre se pueden ejecutar en paralelo en la misma base de datos sin tener que preocuparse por el aislamiento.
    • Las pruebas de escritura son más problemáticas, pero se pueden usar transacciones para asegurarse de que están correctamente aisladas.
    • Las pruebas transaccionales son las más problemáticas de todas y requieren lógica para restablecer la base de datos a su estado original, así como que la paralelización esté deshabilitada.
  • Separar estas categorías de pruebas en clases independientes puede evitar confusiones e interferencias accidentales entre las pruebas.
  • Dedique un tiempo a pensar en sus datos de prueba de inicialización e intente escribir las pruebas de forma que no se interrumpan con demasiada frecuencia si cambian los datos de inicialización.
  • Use varias bases de datos para paralelizar las pruebas que modifican la base de datos y, posiblemente, también para permitir diferentes configuraciones de datos de inicialización.
  • Si la velocidad de las pruebas es un problema, es posible que quiera examinar técnicas más eficaces para crear la base de datos de prueba y para limpiar sus datos entre ejecuciones.
  • Tenga siempre en cuenta la paralelización y el aislamiento de las pruebas.