Notitie
Voor toegang tot deze pagina is autorisatie vereist. U kunt proberen u aan te melden of de directory te wijzigen.
Voor toegang tot deze pagina is autorisatie vereist. U kunt proberen de mappen te wijzigen.
Op deze pagina bespreken we technieken voor het schrijven van geautomatiseerde tests die betrekking hebben op het databasesysteem waarmee de toepassing in productie wordt uitgevoerd. Er bestaan alternatieve testmethoden, waarbij het productiedatabasesysteem wordt verwisseld door dubbele tests; zie de overzichtspagina voor testen voor meer informatie. Houd er rekening mee dat het testen op basis van een andere database dan wat in productie wordt gebruikt (bijvoorbeeld Sqlite) hier niet wordt behandeld, omdat de verschillende database wordt gebruikt als een testdubbel; deze benadering wordt behandeld in Testen zonder uw productiedatabasesysteem.
De belangrijkste horde met het testen waarbij een echte database is betrokken, is om de juiste testisolatie te garanderen, zodat tests die parallel worden uitgevoerd (of zelfs in serieel) elkaar niet verstoren. De volledige voorbeeldcode voor het onderstaande kan hier worden bekeken.
Hint
Op deze pagina ziet u xUnit technieken, maar vergelijkbare concepten bestaan in andere testframeworks, waaronder NUnit.
Uw databasesysteem instellen
De meeste databasesystemen kunnen tegenwoordig eenvoudig worden geïnstalleerd, zowel in CI-omgevingen als op ontwikkelcomputers. Hoewel het vaak eenvoudig genoeg is om de database te installeren via het reguliere installatiemechanisme, zijn kant-en-klare Docker-installatiekopieën beschikbaar voor de meeste grote databases en kunnen de installatie bijzonder eenvoudig in CI worden gemaakt. Voor de ontwikkelomgeving kunnen GitHub WorkspacesDev Container alle benodigde services en afhankelijkheden instellen, inclusief de database. Hoewel dit een initiële investering in de installatie vereist, hebt u een werktestomgeving en kunt u zich concentreren op belangrijkere dingen.
In bepaalde gevallen hebben databases een speciale editie of versie die nuttig kan zijn voor het testen. Wanneer u SQL Server gebruikt, kan LocalDB- worden gebruikt om tests lokaal uit te voeren zonder dat er helemaal geen installatie is ingesteld, waarbij het database-exemplaar op aanvraag wordt gemaakt en mogelijk resources worden opgeslagen op minder krachtige ontwikkelmachines. LocalDB is echter niet zonder problemen:
- Het biedt geen ondersteuning voor alles wat SQL Server Developer Edition doet.
- Deze is alleen beschikbaar in Windows.
- Dit kan vertraging veroorzaken tijdens de eerste testuitvoering terwijl de service wordt opgestart.
Over het algemeen wordt u aangeraden sql Server Developer-editie te installeren in plaats van LocalDB, omdat deze de volledige SQL Server-functieset biedt en over het algemeen zeer eenvoudig te doen is.
Wanneer u een clouddatabase gebruikt, is het meestal geschikt om te testen op basis van een lokale versie van de database, zowel om de snelheid te verbeteren als om de kosten te verlagen. Wanneer u bijvoorbeeld SQL Azure in productie gebruikt, kunt u testen op een lokaal geïnstalleerde SQL Server. De twee zijn zeer vergelijkbaar (hoewel het nog steeds verstandig is om tests uit te voeren op SQL Azure zelf voordat u in productie gaat). Wanneer u Azure Cosmos DB gebruikt, de Azure Cosmos DB-emulator een handig hulpprogramma is voor het lokaal ontwikkelen en uitvoeren van tests.
Een testdatabase maken, seeden en beheren
Zodra uw database is geïnstalleerd, kunt u deze gaan gebruiken in uw tests. In de meeste eenvoudige gevallen heeft uw testsuite één database die wordt gedeeld tussen meerdere tests in meerdere testklassen, dus we hebben enige logica nodig om ervoor te zorgen dat de database precies eenmaal wordt gemaakt en geseed gedurende de levensduur van de testuitvoering.
Wanneer u Xunit gebruikt, kunt u dit doen via een class fixture, die de database vertegenwoordigt en wordt gedeeld over meerdere testruns.
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);
}
Wanneer het bovenstaande testvoorbeeld wordt geïnstantieerd, wordt EnsureDeleted() ingezet om de database te verwijderen (voor het geval deze nog bestaat van een vorige uitvoering) en EnsureCreated() om deze te creëren met uw nieuwste modelconfiguratie (bekijk de documentatie voor deze API's). Zodra de database is aangemaakt, wordt deze gevuld met enkele gegevens die onze tests kunnen gebruiken. Het is de moeite waard om na te denken over uw seed-gegevens, omdat het later wijzigen voor een nieuwe test ertoe kan leiden dat bestaande tests mislukken.
Als u de fixture in een testklasse wilt gebruiken, implementeert u gewoon IClassFixture op uw type fixture, en xUnit injecteert deze dan in uw constructor:
public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
public BloggingControllerTest(TestDatabaseFixture fixture)
=> Fixture = fixture;
public TestDatabaseFixture Fixture { get; }
Uw testklasse heeft nu een Fixture eigenschap dat door tests kan worden gebruikt om een volledig functioneel contextexemplaar te maken.
[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);
}
Ten slotte hebt u misschien gemerkt dat er een aantal vergrendelingen zijn in de bovenstaande logica voor het maken van de armaturen. Als een fixture slechts in één testklasse wordt gebruikt, is het gegarandeerd dat het precies één keer geïnstantieerd wordt door xUnit; maar het is gebruikelijk om dezelfde database-fixture in meerdere testklassen te gebruiken. xUnit biedt verzameling armaturen, maar dat mechanisme voorkomt dat uw testklassen parallel worden uitgevoerd, wat belangrijk is voor testprestaties. Om dit veilig te beheren met een xUnit-klassearmatuur, nemen we een eenvoudige vergrendeling rond het maken en seeden van databases en gebruiken we een statische vlag om ervoor te zorgen dat we dit nooit twee keer hoeven te doen.
Testen die gegevens wijzigen
In het bovenstaande voorbeeld is een alleen-lezentest getoond. Dit is het eenvoudige geval vanuit het oogpunt van testisolatie: omdat er niets wordt gewijzigd, is testinterferentie niet mogelijk. Tests die gegevens wijzigen, zijn daarentegen problematischer, omdat ze elkaar kunnen verstoren. Een veelvoorkomende techniek voor het isoleren van schrijftests is het verpakken van de test in een transactie en het terugdraaien van die transactie aan het einde van de test. Omdat er niets daadwerkelijk wordt doorgevoerd in de database, zien andere tests geen wijzigingen en wordt interferentie vermeden.
Hier volgt een controllermethode waarmee een blog aan onze database wordt toegevoegd:
[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
_context.Blogs.Add(new Blog { Name = name, Url = url });
await _context.SaveChangesAsync();
return Ok();
}
We kunnen deze methode testen met het volgende:
[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);
}
Enkele opmerkingen over de bovenstaande testcode:
- We starten een transactie om ervoor te zorgen dat de onderstaande wijzigingen niet worden doorgevoerd in de database en geen invloed hebben op andere tests. Omdat de transactie nooit wordt doorgevoerd, wordt deze impliciet teruggedraaid aan het einde van de test wanneer het context-exemplaar wordt verwijderd.
- Nadat we de gewenste updates hebben aangebracht, wissen we de wijzigingentracker van het contextexemplaar met ChangeTracker.Clear, zodat we de blog daadwerkelijk vanuit de database kunnen laden. We kunnen in plaats daarvan twee contextexemplaren gebruiken, maar we moeten er vervolgens voor zorgen dat dezelfde transactie door beide exemplaren wordt gebruikt.
- Misschien wilt u de transactie zelfs starten in de
CreateContextvan het systeem, zodat tests een contextinstantie ontvangen die al in een transactie is en gereed voor updates. Dit kan helpen om gevallen te voorkomen waarin de transactie per ongeluk wordt vergeten, wat leidt tot interferentie tijdens het testen die moeilijk op te lossen is. U kunt ook overwegen de alleen-lezen- en schrijftests in verschillende testklassen op te splitsen.
Tests waarmee transacties expliciet worden beheerd
Er is één laatste categorie tests die een extra moeilijkheid vormt: tests die gegevens wijzigen en ook transacties expliciet beheren. Omdat databases doorgaans geen ondersteuning bieden voor geneste transacties, is het niet mogelijk om transacties te gebruiken voor isolatie zoals hierboven, omdat ze moeten worden gebruikt door de werkelijke productcode. Hoewel deze tests meestal zeldzamer zijn, is het noodzakelijk om ze op een speciale manier te verwerken: u moet de database na elke test opschonen naar de oorspronkelijke status en parallellisatie moet worden uitgeschakeld, zodat deze tests elkaar niet verstoren.
Laten we de volgende controllermethode als voorbeeld bekijken:
[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();
}
Stel dat voor de methode om een of andere reden een serialiseerbare transactie moet worden gebruikt (dit is meestal niet het geval). Als gevolg hiervan kunnen we geen transactie gebruiken om testisolatie te garanderen. Omdat de test daadwerkelijk wijzigingen doorvoert in de database, definiëren we een andere fixture met een eigen, afzonderlijke database om te voorkomen dat we de andere tests die hierboven al zijn weergegeven verstoren.
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();
}
}
Deze armatuur is vergelijkbaar met de hierboven gebruikte armatuur, maar bevat met name de Cleanup-methode; Deze wordt na elke test aangeroepen om ervoor te zorgen dat de database teruggezet wordt naar de beginstatus.
Als deze armatur alleen door één testklasse wordt gebruikt, kunnen we ernaar verwijzen als een klasse-armatur zoals hierboven: xUnit parallelliseert tests niet binnen dezelfde klasse (lees meer over testverzamelingen en parallellisatie in de xUnit docs). Als we deze armaturen echter tussen meerdere klassen willen delen, moeten we ervoor zorgen dat deze klassen niet parallel worden uitgevoerd om interferentie te voorkomen. Hiervoor gebruiken we dit als een xUnit collectie fixture in plaats van als een class fixture.
Eerst definiëren we een testverzameling, die verwijst naar onze armaturen en zullen worden gebruikt door alle transactionele testklassen waarvoor het nodig is:
[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}
We verwijzen nu naar de testverzameling in onze testklasse en accepteren de armaturen in de constructor als voorheen:
[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
=> Fixture = fixture;
public TransactionalTestDatabaseFixture Fixture { get; }
Ten slotte maken we onze testklasse wegwerpbaar door ervoor te zorgen dat de Cleanup methode van de fixture na elke test wordt aangeroepen.
public void Dispose()
=> Fixture.Cleanup();
Aangezien xUnit maar één keer de verzamelingsarmaturen instantiëren, is het niet nodig om vergrendeling te gebruiken bij het maken en seeden van databases, zoals hierboven is gedaan.
De volledige voorbeeldcode voor het bovenstaande kan hier worden weergegeven .
Hint
Als u meerdere testklassen hebt met tests die de database wijzigen, kunt u ze nog steeds parallel uitvoeren door verschillende armaturen te hebben, die elk verwijzen naar een eigen database. Het maken en gebruiken van veel testdatabases is niet problematisch en moet worden uitgevoerd wanneer dit nuttig is.
Efficiënt database maken
In de bovenstaande voorbeelden hebben we EnsureDeleted() en EnsureCreated() gebruikt voordat we tests uitvoeren, om ervoor te zorgen dat we een up-to-datumtestdatabase hebben. Deze bewerkingen kunnen een beetje traag zijn in bepaalde databases, wat een probleem kan zijn wanneer u codewijzigingen doorloopt en tests telkens opnieuw uitvoert. Als dat het geval is, kunt u tijdelijk EnsureDeleted uitschakelen door commentaar aan toe te voegen in de constructor van uw fixture: hierdoor wordt dezelfde database opnieuw gebruikt tijdens testruns.
Het nadeel van deze aanpak is dat als u uw EF Core-model wijzigt, uw databaseschema niet up-to-date is en tests mogelijk mislukken. Als gevolg hiervan raden we u alleen aan dit tijdelijk te doen tijdens de ontwikkelingscyclus.
Efficiënt opschonen van databases
We hebben hierboven gezien dat wanneer wijzigingen daadwerkelijk worden doorgevoerd in de database, de database tussen elke test moeten worden opgeschoond om interferentie te voorkomen. In het bovenstaande transactionele testvoorbeeld hebben we dit gedaan door EF Core-API's te gebruiken om de inhoud van de tabel te verwijderen:
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();
Dit is doorgaans niet de meest efficiënte manier om een tabel uit te wissen. Als de testsnelheid een probleem is, kunt u onbewerkte SQL gebruiken om de tabel te verwijderen:
DELETE FROM [Blogs];
U kunt ook overwegen om het pakket te gebruiken, waardoor een database efficiënt wordt gewist. Daarnaast hoeft u niet de tabellen op te geven die moeten worden gewist en hoeft de opschooncode dus niet te worden bijgewerkt wanneer tabellen aan uw model worden toegevoegd.
Samenvatting
- Bij het testen op basis van een echte database is het de moeite waard om onderscheid te maken tussen de volgende testcategorieën:
- Alleen-lezentests zijn relatief eenvoudig en kunnen altijd parallel worden uitgevoerd op dezelfde database zonder dat u zich zorgen hoeft te maken over isolatie.
- Schrijftests zijn problematischer, maar transacties kunnen worden gebruikt om ervoor te zorgen dat ze correct zijn geïsoleerd.
- Transactionele tests zijn de meest problematische, waarvoor logica nodig is om de database terug te zetten naar de oorspronkelijke staat, en parallellisatie uit te schakelen.
- Het scheiden van deze testcategorieën in afzonderlijke klassen kan verwarring en onbedoelde interferentie tussen tests voorkomen.
- Denk vooraf na over uw seeded testgegevens en probeer uw tests zo te schrijven dat ze niet te vaak breken als die seed-gegevens veranderen.
- Gebruik meerdere databases om tests te parallelliseren die de database wijzigen en mogelijk ook om verschillende seed-gegevensconfiguraties toe te staan.
- Als de testsnelheid een probleem is, kunt u kijken naar efficiëntere technieken voor het maken van uw testdatabase en voor het opschonen van de gegevens tussen uitvoeringen.
- Houd altijd rekening met testparallelisatie en testisolatie.