Nota
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
En esta página se describen técnicas para escribir pruebas automatizadas que implican el 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 las pruebas en una base de datos distinta a la que se utiliza en producción (por ejemplo, Sqlite) no se cubren aquí, ya que la base de datos diferente se utiliza como un sustituto de prueba; este enfoque se aborda en la sección Pruebas sin el sistema de base de datos de producción.
El principal obstáculo con las pruebas que implica una base de datos real es garantizar un aislamiento de prueba adecuado, 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 actualmente se pueden instalar fácilmente, tanto en entornos de CI como en máquinas para desarrolladores. Aunque con frecuencia es lo suficientemente fácil instalar la base de datos a través del mecanismo de instalación normal, las imágenes de Docker listas para usar están disponibles para la mayoría de las 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 hecho, tiene un entorno de pruebas en funcionamiento y puede concentrarse en cosas más importantes.
En determinados casos, las bases de datos tienen una edición especial o una versión que pueden resultar útiles para las pruebas. Al usar SQL Server, localDB se puede usar para ejecutar pruebas localmente sin prácticamente ninguna configuración, activar la instancia de base de datos a petición y, posiblemente, ahorrar recursos en máquinas para desarrolladores menos eficaces. Sin embargo, LocalDB no está sin sus problemas:
- Este software no admite todo lo que SQL Server Developer Edition ofrece.
- Solo está disponible en Windows.
- Puede provocar retraso en la primera ejecución de prueba a medida que el servicio se activa.
Por lo general, se recomienda instalar SQL Server Developer Edition en lugar de LocalDB, ya que proporciona el conjunto completo de características de SQL Server y, por lo general, es muy fácil de hacer.
Cuando se usa una base de datos en la nube, normalmente es adecuado probar con 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 probar en un servidor SQL Server instalado localmente; los dos son extremadamente similares (aunque sigue siendo aconsejable ejecutar pruebas en SQL Azure antes de entrar en producción). Al usar Azure Cosmos DB, el emulador de Azure Cosmos DB es una herramienta útil tanto para desarrollar localmente como para ejecutar pruebas.
Creación, propagación y administración de una base de datos de prueba
Una vez instalada la base de datos, estará listo para empezar a usarlo 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 asegurarse de que la base de datos se crea y se inicializa exactamente una vez durante la duración de la ejecución de la prueba.
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 nuestras pruebas pueden usar. 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 las pruebas pueden usar para crear una instancia de contexto totalmente funcional:
[Fact]
public async Task GetBlog()
{
using var context = Fixture.CreateContext();
var controller = new BloggingController(context);
var blog = (await 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 sola clase de prueba, se garantiza que se crea una instancia exactamente una vez por xUnit; 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 mostró una prueba de solo lectura, que es el caso sencillo desde el punto de vista del aislamiento de prueba: puesto que no se está modificando nada, no es posible realizar ninguna interferencia de prueba. Por el contrario, las pruebas que modifican los 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 nada se guarda realmente en la base de datos, otras pruebas no ven modificaciones y se evita la interferencia.
Este es un método de controlador que agrega un blog a nuestra base de datos:
[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
_context.Blogs.Add(new Blog { Name = name, Url = url });
await _context.SaveChangesAsync();
return Ok();
}
Podemos probar este método con lo siguiente:
[Fact]
public async Task AddBlog()
{
using var context = Fixture.CreateContext();
context.Database.BeginTransaction();
var controller = new BloggingController(context);
await controller.AddBlog("Blog3", "http://blog3.com");
context.ChangeTracker.Clear();
var blog = await context.Blogs.SingleAsync(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 están confirmados 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. En su lugar, podríamos usar dos instancias de contexto, pero tendríamos que asegurarnos de que ambas instancias usan la misma transacción.
- Es posible que incluso quiera iniciar la transacción en la API
CreateContext
del 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 se olvida accidentalmente, lo que conduce a la interferencia de prueba 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 gestionan 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 más raras, 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 async Task<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.
await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
if (blog is null)
{
return NotFound();
}
blog.Url = url;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return Ok();
}
Supongamos que, por algún motivo, el método requiere que se use una transacción serializable (este no suele ser el caso). Como resultado, no podemos usar una transacción para garantizar el aislamiento de 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 que no interfiramos con las otras pruebas ya mostradas anteriormente:
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 es similar al usado anteriormente, pero en particular contiene un método Cleanup
; Llamaremos a esto después de cada prueba para asegurarse de que la base de datos se restablezca a su estado inicial.
Si este accesorio solo lo usará una sola clase de prueba, podemos hacer referencia a él como un accesorio de clase, como se mencionó anteriormente: xUnit no paraleliza las pruebas dentro de la misma clase (lea más sobre las colecciones de pruebas y la paralelización en los documentos de xUnit). Sin embargo, si queremos compartir este accesorio entre varias clases, debemos asegurarnos de que estas clases no se ejecuten en paralelo para evitar ninguna interferencia. Para ello, usaremos esto como una configuración de colección xUnit en lugar de como una configuración 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 si tiene diferentes accesorios, cada uno de los cuales hace referencia a su propia base de datos. La creación y el uso de muchas bases de datos de prueba no son problemáticas y deben 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 ser un problema a medida que se recorren en iteración los cambios en el código y se vuelven a ejecutar pruebas a lo largo y largo. 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 la base de datos no estará actualizado y es posible que se produzcan errores en las pruebas. Como resultado, solo se recomienda hacerlo 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 cada prueba para evitar interferencias. En el ejemplo de prueba transaccional anterior, lo hicimos 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 vaciar una tabla. Si la velocidad de la prueba es un problema, es posible que quiera usar SQL sin procesar 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 agreguen tablas al modelo.
Resumen
- Al realizar pruebas en una base de datos real, merece la pena distinguir entre las siguientes categorías de prueba:
- 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, lo que requiere lógica para restablecer la base de datos a su estado original, así como deshabilitar la paralelización.
- Separar estas categorías de prueba en clases independientes puede evitar confusiones e interferencias accidentales entre las pruebas.
- Piense detenidamente en sus datos de prueba inicializados e intente escribir sus pruebas de forma que no se rompan con demasiada frecuencia si esos datos cambian.
- Use varias bases de datos para paralelizar las pruebas que modifican la base de datos y, posiblemente, también para permitir configuraciones de datos de inicialización diferentes.
- Si la velocidad de la prueba es un problema, es posible que desee 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.