Eseguire test senza il sistema di database di produzione
In questa pagina vengono illustrate le tecniche per la scrittura di test automatizzati che non comportano il sistema di database in cui viene eseguita l'applicazione nell'ambiente di produzione, scambiando il database con un test double. Esistono diversi tipi di test double e approcci per eseguire questa operazione ed è consigliabile leggere attentamente Scelta di una strategia di test per comprendere appieno le diverse opzioni. Infine, è anche possibile eseguire test sul sistema di database di produzione; questo argomento è descritto in Test sul sistema di database di produzione.
Consiglio
Questa pagina mostra le tecniche xUnit, ma esistono concetti simili in altri framework di test, tra cui NUnit.
Modello di repository
Se si è deciso di scrivere test senza coinvolgere il sistema di database di produzione, la tecnica consigliata per farlo è il modello di repository; per altre informazioni su questo argomento, vedere questa sezione. Il primo passaggio per implementare il modello di repository consiste nell'estrarre le query LINQ di EF Core in un livello separato, che poi sarà stub o mock. Ecco un esempio di interfaccia del repository per il sistema di blogging:
public interface IBloggingRepository
{
Task<Blog> GetBlogByNameAsync(string name);
IAsyncEnumerable<Blog> GetAllBlogsAsync();
void AddBlog(Blog blog);
Task SaveChangesAsync();
}
... ed ecco un'implementazione di esempio parziale per l'uso in produzione:
public class BloggingRepository : IBloggingRepository
{
private readonly BloggingContext _context;
public BloggingRepository(BloggingContext context)
=> _context = context;
public async Task<Blog> GetBlogByNameAsync(string name)
=> await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
// Other code...
}
Non c'è molto da fare: il repository avvolge semplicemente un contesto di EF Core ed espone metodi che eseguono le interrogazioni e gli aggiornamenti del database. Un punto chiave da notare è che il metodo GetAllBlogs
restituisce IAsyncEnumerable<Blog>
(o IEnumerable<Blog>
) e non IQueryable<Blog>
. Restituire quest'ultimo significherebbe che gli operatori di query possono ancora essere composti sul risultato, richiedendo la partecipazione di EF Core nella traduzione della query; questo vanificherebbe lo scopo di avere un repository sin dall'inizio.
IAsyncEnumerable<Blog>
ci permette di sostituire temporaneamente o simulare facilmente ciò che il repository restituisce.
Per un'applicazione ASP.NET Core, è necessario registrare il repository come servizio nel dependency injection aggiungendo al ConfigureServices
dell'applicazione quanto segue:
services.AddScoped<IBloggingRepository, BloggingRepository>();
Infine, i nostri controller vengono inseriti con il servizio di repository invece del contesto di EF Core ed eseguono metodi su di esso.
private readonly IBloggingRepository _repository;
public BloggingControllerWithRepository(IBloggingRepository repository)
=> _repository = repository;
[HttpGet]
public async Task<Blog> GetBlog(string name)
=> await _repository.GetBlogByNameAsync(name);
A questo punto, l'applicazione viene progettata in base al modello di repository: l'unico punto di contatto con il livello di accesso ai dati , EF Core, è ora tramite il livello del repository, che funge da mediatore tra il codice dell'applicazione e le query di database effettive. Ora è possibile scrivere i test semplicemente simulando il repository o utilizzando una libreria di simulazione a piacere. Di seguito è riportato un esempio di test basato su mock usando la popolare libreria Moq :
[Fact]
public async Task GetBlog()
{
// Arrange
var repositoryMock = new Mock<IBloggingRepository>();
repositoryMock
.Setup(r => r.GetBlogByNameAsync("Blog2"))
.Returns(Task.FromResult(new Blog { Name = "Blog2", Url = "http://blog2.com" }));
var controller = new BloggingControllerWithRepository(repositoryMock.Object);
// Act
var blog = await controller.GetBlog("Blog2");
// Assert
repositoryMock.Verify(r => r.GetBlogByNameAsync("Blog2"));
Assert.Equal("http://blog2.com", blog.Url);
}
Il codice di esempio completo può essere visualizzato qui.
SQLite in memoria
SQLite può essere facilmente configurato come provider EF Core per la suite di test invece del sistema di database di produzione (ad esempio, SQL Server); consultare la documentazione del provider SQLite per dettagli. Tuttavia, in genere è consigliabile usare database in memoria di SQLite funzionalità durante i test, poiché offre un facile isolamento tra i test e non richiede la gestione dei file SQLite effettivi.
Per usare SQLite in memoria, è importante comprendere che viene creato un nuovo database ogni volta che viene aperta una connessione di basso livello e che viene eliminata quando tale connessione viene chiusa. Nell'utilizzo normale, il DbContext
di EF Core apre e chiude le connessioni di database in base alle esigenze, ogni volta che viene eseguita una query, per evitare di mantenere la connessione per tempi inutilmente lunghi. Tuttavia, con SQLite in memoria questo comporta la reimpostazione del database ogni volta; quindi, come soluzione alternativa, si apre la connessione prima di passarla a EF Core e si prevede che venga chiusa solo al termine del test:
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();
I test possono ora chiamare CreateContext
, che restituisce un contesto usando la connessione configurata nel costruttore, assicurando di disporre di un database pulito con i dati predefiniti.
Il codice di esempio completo per i test in memoria di SQLite può essere visualizzato qui.
Provider in memoria
Come discusso nella pagina di panoramica dei test , si sconsiglia fortemente l'uso del provider in-memory per i test; è preferibile considerare l'utilizzo di SQLite, oppure l'implementazione del pattern repository. Se hai deciso di usare in-memory, di seguito è riportato un tipico costruttore della classe di test che configura e inizializza un nuovo database in-memory prima di ogni test.
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();
}
Il codice di esempio completo per i test in memoria può essere visualizzato qui.
Denominazione di un database in memoria
I database in memoria sono identificati da un nome di stringa semplice ed è possibile connettersi più volte allo stesso database specificando lo stesso nome(questo è il motivo per cui l'esempio precedente deve chiamare EnsureDeleted
prima di ogni test). Si noti tuttavia che i database in memoria sono radicati nel provider di servizi interno del contesto; mentre nella maggior parte dei casi i contesti condividono lo stesso provider di servizi, la configurazione di contesti con opzioni diverse può attivare l'uso di un nuovo provider di servizi interno. Quando è il caso, passa esplicitamente la stessa istanza di InMemoryDatabaseRoot a UseInMemoryDatabase
per tutti i contesti che devono condividere i database in memoria (questo viene solitamente fatto tramite un campo statico InMemoryDatabaseRoot
).
Transazioni
Si noti che, per impostazione predefinita, se viene avviata una transazione, il provider in memoria genererà un'eccezione perché le transazioni non sono supportate. È possibile che le transazioni vengano ignorate in modo invisibile all'utente configurando EF Core per ignorare InMemoryEventId.TransactionIgnoredWarning
come nell'esempio precedente. Tuttavia, se il codice si basa effettivamente sulla semantica transazionale - ad esempio dipende dal rollback che effettua effettivamente il ripristino delle modifiche - il test non funzionerà.
Visualizzazioni
Il provider in memoria consente la definizione delle viste tramite query LINQ, usando ToInMemoryQuery:
modelBuilder.Entity<UrlResource>()
.ToInMemoryQuery(() => context.Blogs.Select(b => new UrlResource { Url = b.Url }));