Anteckning
Åtkomst till den här sidan kräver auktorisering. Du kan prova att logga in eller ändra kataloger.
Åtkomst till den här sidan kräver auktorisering. Du kan prova att ändra kataloger.
På den här sidan diskuterar vi tekniker för att skriva automatiserade tester som omfattar databassystemet som programmet körs i produktion mot. Det finns alternativa testmetoder, där produktionsdatabassystemet växlas ut av testdubblar. Mer information finns på testöversiktssidan . Observera att testning mot en annan databas än vad som används i produktion (t.ex. Sqlite) inte omfattas här, eftersom den olika databasen används som en testdubblett. Den här metoden beskrivs i Testa utan ditt produktionsdatabassystem.
Det största hindret med testning som omfattar en verklig databas är att säkerställa korrekt testisolering, så att tester som körs parallellt (eller till och med i serie) inte stör varandra. Den fullständiga exempelkoden för nedanstående kan visas här.
Tips/Råd
Den här sidan visar xUnit- tekniker, men liknande begrepp finns i andra testramverk, inklusive NUnit.
Konfigurera databassystemet
De flesta databassystem kan numera enkelt installeras, både i CI-miljöer och på utvecklardatorer. Även om det ofta är enkelt att installera databasen via den vanliga installationsmekanismen är docker-avbildningar som är redo att användas tillgängliga för de flesta större databaser och kan göra installationen särskilt enkel i CI. För utvecklarmiljön, GitHub-arbetsytor, kan Dev Container konfigurera alla nödvändiga tjänster och beroenden – inklusive databasen. Även om detta kräver en initial investering i installationen, när det är gjort har du en fungerande testmiljö och kan koncentrera dig på viktigare saker.
I vissa fall har databaser en specialutgåva eller version som kan vara till hjälp vid testning. När du använder SQL Server kan LocalDB användas för att köra tester lokalt med praktiskt taget ingen installation alls, snurra upp databasinstansen på begäran och eventuellt spara resurser på mindre kraftfulla utvecklardatorer. LocalDB är dock inte utan problem:
- Den stöder inte allt som SQL Server Developer Edition gör.
- Den är bara tillgänglig i Windows.
- Det kan orsaka fördröjning vid första testkörningen när tjänsten startas.
Vi rekommenderar vanligtvis att du installerar SQL Server Developer Edition i stället för LocalDB, eftersom det ger den fullständiga SQL Server-funktionsuppsättningen och i allmänhet är mycket lätt att göra.
När du använder en molndatabas är det vanligtvis lämpligt att testa mot en lokal version av databasen, både för att förbättra hastigheten och minska kostnaderna. När du till exempel använder SQL Azure i produktion kan du testa mot en lokalt installerad SQL Server – de två är mycket lika (även om det fortfarande är klokt att köra tester mot själva SQL Azure innan du går in i produktion). När du använder Azure Cosmos DB är Azure Cosmos DB-emulatorn ett användbart verktyg både för att utveckla lokalt och för att köra tester.
Skapa, seeda och hantera en testdatabas
När databasen har installerats är du redo att börja använda den i dina tester. I de flesta enkla fall har testpaketet en enkel databas som delas mellan flera tester i flera testklasser, så vi behöver viss logik för att se till att databasen skapas och seedas exakt en gång under testkörningens livslängd.
När du använder Xunit kan detta göras via en klassfixtur, som representerar databasen och delas mellan flera testkörningar:
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);
}
När ovanstående fixtur instansieras används EnsureDeleted() för att ta bort databasen (om den finns från en tidigare körning) och sedan EnsureCreated() för att skapa den med din senaste modellkonfiguration (se dokumentationen för dessa API:er). När databasen har skapats, förser fixturen den med viss data som våra tester kan använda. Det är värt att ägna lite tid åt att tänka på dina startdata, eftersom det kan leda till att befintliga tester misslyckas om du ändrar dem senare för ett nytt test.
Om du vill använda fixturen i en testklass implementerar IClassFixture
du helt enkelt över din fixturtyp, och xUnit matar in den i konstruktorn:
public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
public BloggingControllerTest(TestDatabaseFixture fixture)
=> Fixture = fixture;
public TestDatabaseFixture Fixture { get; }
Testklassen har nu en Fixture
egenskap som kan användas av tester för att skapa en fullständigt funktionell kontextinstans:
[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);
}
Slutligen kanske du har lagt märke till vissa låsningar i armaturens skapandelogik ovan. Om fixturen endast används i en enda testklass är den garanterad att instansieras exakt en gång av xUnit; men det är vanligt att använda samma databasfixtur i flera testklasser. xUnit tillhandahåller samlingsarmaturer, men den mekanismen förhindrar att testklasserna körs parallellt, vilket är viktigt för testprestanda. För att hantera detta på ett säkert sätt med en xUnit-klassfixtur tar vi ett enkelt lås kring databasskapande och seeding och använder en statisk flagga för att se till att vi aldrig behöver göra det två gånger.
Tester som ändrar data
Exemplet ovan visade ett skrivskyddad test, vilket är det enkla fallet med avseende på testisolering: eftersom ingenting ändras är testinterferens inte möjligt. Däremot är tester som ändrar data mer problematiska eftersom de kan störa varandra. En vanlig teknik för att isolera skrivtester är att omsluta testet i en transaktion och att få transaktionen återställd i slutet av testet. Eftersom inget faktiskt har checkats in i databasen kan andra tester inte se några ändringar och interferens undviks.
Här är en kontrollantmetod som lägger till en blogg i vår databas:
[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
_context.Blogs.Add(new Blog { Name = name, Url = url });
await _context.SaveChangesAsync();
return Ok();
}
Vi kan testa den här metoden med följande:
[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);
}
Några anteckningar om testkoden ovan:
- Vi startar en transaktion för att se till att ändringarna nedan inte har checkats in i databasen och inte stör andra tester. Eftersom transaktionen aldrig slutförs rullas den implicit tillbaka i slutet av testet när kontextinstansen avslutas.
- När vi har gjort de uppdateringar vi vill ha rensar vi kontextinstansens ändringsspårare med ChangeTracker.Clear, för att se till att vi faktiskt läser in bloggen från databasen nedan. Vi skulle kunna använda två kontextinstanser i stället, men då måste vi se till att samma transaktion används av båda instanserna.
- Du kanske till och med vill starta transaktionen i fixturens
CreateContext
, så att testerna får en kontextinstans som redan är i en transaktion och redo för uppdateringar. Detta kan hjälpa till att förhindra fall där transaktionen av misstag glöms bort, vilket leder till testinterferens som kan vara svår att felsöka. Du kanske också vill separera skrivskyddade och skrivskyddade tester i olika testklasser.
Tester som uttryckligen hanterar transaktioner
Det finns en sista kategori av tester som ger ytterligare svårigheter: tester som ändrar data och även uttryckligen hanterar transaktioner. Eftersom databaser vanligtvis inte stöder kapslade transaktioner är det inte möjligt att använda transaktioner för isolering som ovan, eftersom de måste användas av den faktiska produktkoden. Även om dessa tester tenderar att vara mer sällsynta, är det nödvändigt att hantera dem på ett speciellt sätt: du måste rensa databasen till dess ursprungliga tillstånd efter varje test, och parallellisering måste inaktiveras så att dessa tester inte stör varandra.
Nu ska vi undersöka följande kontrollantmetod som ett exempel:
[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();
}
Anta att metoden av någon anledning kräver att en serialiserbar transaktion används (detta är vanligtvis inte fallet). Därför kan vi inte använda en transaktion för att garantera testisolering. Eftersom testet faktiskt kommer att spara ändringar i databasen, definierar vi en annan fixtur med en egen, separat databas, för att säkerställa att vi inte stör de andra testerna som redan visas ovan.
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();
}
}
Den här armaturen liknar den som används ovan, men innehåller särskilt en Cleanup
metod. Vi anropar detta efter varje test för att säkerställa att databasen återställs till starttillståndet.
Om den här armaturen endast används av en enskild testklass kan vi referera till den som en klassfixtur som ovan – xUnit parallelliserar inte tester inom samma klass (läs mer om testsamlingar och parallellisering i xUnit-dokumenten). Men om vi vill dela den här armaturen mellan flera klasser måste vi se till att dessa klasser inte körs parallellt för att undvika störningar. För att göra det använder vi detta som en xUnit-samlingsfixtur i stället för som en klassfixtur.
Först definierar vi en testsamling som refererar till vår fixtur och kommer att användas av alla transaktionstestklasser som kräver det:
[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}
Nu refererar vi till testsamlingen i vår testklass och accepterar fixturen i konstruktorn som tidigare:
[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
=> Fixture = fixture;
public TransactionalTestDatabaseFixture Fixture { get; }
Slutligen gör vi vår testklass disponibel och ordnar så att fixturens metod anropas Cleanup
efter varje test:
public void Dispose()
=> Fixture.Cleanup();
Observera att eftersom xUnit bara instansierar samlingsfixturen en gång, behöver vi inte använda låsning runt databasskapande och seeding som vi gjorde ovan.
Den fullständiga exempelkoden för ovanstående kan visas här.
Tips/Råd
Om du har flera testklasser med tester som ändrar databasen kan du fortfarande köra dem parallellt genom att använda olika testmiljöer, där var och en refererar till sin egen databas. Att skapa och använda många testdatabaser är inte problematiskt och bör göras när det är till hjälp.
Effektivt skapande av databas
I exemplen ovan använde EnsureDeleted() och EnsureCreated() innan vi körde tester för att se till att vi har en up-to-datertestdatabas. Dessa åtgärder kan vara lite långsamma i vissa databaser, vilket kan vara ett problem när du itererar över kodändringar och kör tester om och om igen. Om så är fallet kanske du tillfälligt vill kommentera ut EnsureDeleted
i konstruktorn för din fixtur: detta återanvänder samma databas över testkörningar.
Nackdelen med den här metoden är att om du ändrar EF Core-modellen är databasschemat inte uppdaterat och testerna kan misslyckas. Därför rekommenderar vi endast att du gör detta tillfälligt under utvecklingscykeln.
Effektiv databasrensning
Vi såg ovan att när ändringar faktiskt görs i databasen måste vi rensa databasen mellan varje test för att undvika störningar. I transaktionstestexemplet ovan gjorde vi detta med hjälp av EF Core-API:er för att ta bort tabellens innehåll:
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();
Detta är vanligtvis inte det mest effektiva sättet att rensa en tabell. Om testhastigheten är ett problem kanske du vill använda rå SQL för att ta bort tabellen i stället:
DELETE FROM [Blogs];
Du kanske också vill överväga att använda respawn-paketet , vilket effektivt rensar ut en databas. Dessutom kräver det inte att du anger vilka tabeller som ska rensas, så din rensningskod behöver inte uppdateras när tabeller läggs till i din modell.
Sammanfattning
- När du testar mot en riktig databas är det värt att skilja mellan följande testkategorier:
- Skrivskyddade tester är relativt enkla och kan alltid köras parallellt mot samma databas utan att behöva oroa sig för isolering.
- Skrivtester är mer problematiska, men transaktioner kan användas för att se till att de är korrekt isolerade.
- Transaktionstester är de mest problematiska, vilket kräver logik för att återställa databasen till sitt ursprungliga tillstånd, samt inaktivera parallellisering.
- Om du separerar dessa testkategorier i separata klasser kan det undvika förvirring och oavsiktlig interferens mellan testerna.
- Tänk på dina seedade testdata i förväg och försök att skriva dina tester på ett sätt som inte går sönder för ofta om dessa data ändras.
- Använd flera databaser för att parallellisera tester som ändrar databasen och eventuellt även för att tillåta olika konfigurationer av startdata.
- Om testhastigheten är ett problem kanske du vill titta på mer effektiva tekniker för att skapa testdatabasen och för att rensa dess data mellan körningar.
- Ha alltid testparallellisering och isolering i åtanke.