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

En esta página, se describen técnicas para escribir pruebas automatizadas que no implican al sistema de base de datos en el que se ejecuta la aplicación en producción, intercambiando la base de datos por un doble de prueba. Hay varios tipos de dobles de prueba y enfoques para hacerlo, y se recomienda leer exhaustivamente el artículo Elección de una estrategia de prueba para comprender completamente las distintas opciones. Por último, también es posible realizar las pruebas con el sistema de base de datos de producción; esto se aborda en el artículo Pruebas con el sistema de base de datos de producción.

Sugerencia

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

Modelo de repositorio

Si ha decidido escribir pruebas sin involucrar al sistema de base de datos de producción, la técnica recomendada para hacerlo es el patrón de repositorio; para obtener más información al respecto, consulte esta sección. El primer paso para implementar el patrón de repositorio es extraer las consultas LINQ de EF Core en una capa independiente, que más adelante se usará como código auxiliar o ficticio. Este es un ejemplo de una interfaz de repositorio para nuestro sistema de blog:

public interface IBloggingRepository
{
    Blog GetBlogByName(string name);

    IEnumerable<Blog> GetAllBlogs();

    void AddBlog(Blog blog);

    void SaveChanges();
}

Lo siguiente es una implementación de muestra parcial para su uso en producción:

public class BloggingRepository : IBloggingRepository
{
    private readonly BloggingContext _context;

    public BloggingRepository(BloggingContext context)
        => _context = context;

    public Blog GetBlogByName(string name)
        => _context.Blogs.FirstOrDefault(b => b.Name == name);

    // Other code...
}

No tiene mucho misterio: el repositorio simplemente encapsula un contexto de EF Core y expone métodos que ejecutan las consultas y actualizaciones de la base de datos en él. Un punto clave que hay que tener en cuenta es que nuestro método GetAllBlogs devuelve IEnumerable<Blog> y no IQueryable<Blog>. La devolución de este último elemento significaría que los operadores de consulta todavía se pueden componer sobre el resultado, lo que requiere que EF Core siga participando en la traducción de la consulta; esto sería contrario al propósito de tener un repositorio en primer lugar. IEnumerable<Blog> nos permite crear código auxiliar o ficticio fácilmente de lo que devuelve el repositorio.

Para una aplicación ASP.NET Core, es necesario registrar el repositorio como servicio en la inserción de dependencias agregando lo siguiente a ConfigureServices en la aplicación:

services.AddScoped<IBloggingRepository, BloggingRepository>();

Por último, nuestros controladores se insertan con el servicio de repositorio en lugar del contexto de EF Core y ejecutan métodos en él:

private readonly IBloggingRepository _repository;

public BloggingControllerWithRepository(IBloggingRepository repository)
    => _repository = repository;

[HttpGet]
public Blog GetBlog(string name)
    => _repository.GetBlogByName(name);

En este punto, su aplicación está diseñada según el patrón de repositorio: el único punto de contacto con la capa de acceso a datos (EF Core) ahora es por medio de la capa de repositorio, que actúa como mediador entre el código de la aplicación y las consultas de base de datos reales. Las pruebas ahora se pueden escribir simplemente mediante el código auxiliar del repositorio o simulándolo con su biblioteca de simulación favorita. Este es un ejemplo de una prueba basada en simulación usando la popular biblioteca Moq:

[Fact]
public void GetBlog()
{
    // Arrange
    var repositoryMock = new Mock<IBloggingRepository>();
    repositoryMock
        .Setup(r => r.GetBlogByName("Blog2"))
        .Returns(new Blog { Name = "Blog2", Url = "http://blog2.com" });

    var controller = new BloggingControllerWithRepository(repositoryMock.Object);

    // Act
    var blog = controller.GetBlog("Blog2");

    // Assert
    repositoryMock.Verify(r => r.GetBlogByName("Blog2"));
    Assert.Equal("http://blog2.com", blog.Url);
}

El código de ejemplo completo se puede ver aquí.

SQLite en memoria

SQLite se puede configurar fácilmente como el proveedor de EF Core para el conjunto de pruebas en lugar del sistema de base de datos de producción (por ejemplo, SQL Server). Consulte la documentación del proveedor de SQLite para obtener más información. Pero normalmente es una buena idea usar la característica de base de datos en memoria de SQLite a la hora de realizar pruebas, ya que proporciona un aislamiento sencillo entre las pruebas y no requiere trabajar con archivos reales de SQLite.

Para usar SQLite en memoria, es importante comprender que se crea una nueva base de datos cada vez que se abre una conexión de bajo nivel, y se elimina cuando se cierra esa conexión. Por lo general, DbContext de EF Core abre y cierra las conexiones de base de datos según sea necesario (cada vez que se ejecuta una consulta) para evitar mantener la conexión durante períodos innecesariamente largos. Pero con SQLite en memoria, esto conllevaría restablecer la base de datos cada vez; por lo que, como solución alternativa, se abre la conexión antes de pasarla a EF Core y se programa para que solo se cierre cuando se complete la prueba:

    public SqliteInMemoryBloggingControllerTest()
    {
        // Create and open a connection. This creates the SQLite in-memory database, which will persist until the connection is closed
        // at the end of the test (see Dispose below).
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();

        // These options will be used by the context instances in this test suite, including the connection opened above.
        _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
            .UseSqlite(_connection)
            .Options;

        // Create the schema and seed some data
        using var context = new BloggingContext(_contextOptions);

        if (context.Database.EnsureCreated())
        {
            using var viewCommand = context.Database.GetDbConnection().CreateCommand();
            viewCommand.CommandText = @"
CREATE VIEW AllResources AS
SELECT Url
FROM Blogs;";
            viewCommand.ExecuteNonQuery();
        }

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

    BloggingContext CreateContext() => new BloggingContext(_contextOptions);

    public void Dispose() => _connection.Dispose();

Las pruebas ahora pueden llamar a CreateContext, que devuelve un contexto usando la conexión que hemos configurado en el constructor, lo que garantiza que tenemos una base de datos limpia con los datos inicializados.

El código de ejemplo completo para las pruebas de SQLite en memoria se puede consultar aquí.

Proveedor en memoria

Tal y como se describe en la página de información general sobre pruebas, no se recomienda usar el proveedor en memoria para las pruebas; considere la posibilidad de usar SQLite en su lugar o implementar el patrón de repositorio. Si ha decidido usar las pruebas en memoria, este es un constructor de clase de prueba típico que configura e inicializa una nueva base de datos en memoria antes de cada prueba:

public InMemoryBloggingControllerTest()
{
    _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
        .UseInMemoryDatabase("BloggingControllerTest")
        .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning))
        .Options;

    using var context = new BloggingContext(_contextOptions);

    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();
}

El código de ejemplo completo de las pruebas en memoria se puede consultar aquí.

Nomenclatura de las bases de datos en memoria

Las bases de datos en memoria se identifican mediante un nombre de cadena simple, y es posible conectarse a la misma base de datos varias veces proporcionando el mismo nombre (por eso el ejemplo anterior debe llamar a EnsureDeleted antes de cada prueba). Pero tenga en cuenta que las bases de datos en memoria se basan en el proveedor de servicios interno del contexto. Aunque en la mayoría de los casos los contextos comparten el mismo proveedor de servicios, la configuración de contextos con diferentes opciones puede desencadenar el uso de un nuevo proveedor de servicios interno. En este caso, pase explícitamente la misma instancia de InMemoryDatabaseRoot a UseInMemoryDatabase para todos los contextos que deben compartir bases de datos en memoria (normalmente se hace con un campo InMemoryDatabaseRoot estático).

Transacciones

De forma predeterminada, si se inicia una transacción, el proveedor en memoria producirá una excepción, ya que no se admiten transacciones. Es posible que quiera que las transacciones se omitan silenciosamente en su lugar; para ello, configure EF Core para omitir InMemoryEventId.TransactionIgnoredWarning como en el ejemplo anterior. No obstante, si el código realmente se basa en la semántica transaccional, por ejemplo, si depende de la reversión real de los cambios, la prueba no funcionará.

Vistas

El proveedor en memoria permite la definición de vistas por medio de consultas LINQ mediante el uso de ToInMemoryQuery:

modelBuilder.Entity<UrlResource>()
    .ToInMemoryQuery(() => context.Blogs.Select(b => new UrlResource { Url = b.Url }));