Testování v produkčním databázovém systému
Na této stránce probereme techniky pro psaní automatizovaných testů, které zahrnují databázový systém, proti kterému aplikace běží v produkčním prostředí. Existují alternativní přístupy k testování, kdy je produkční databázový systém prohozen dvojitým testem; Další informace najdete na stránce s přehledem testování. Všimněte si, že testování proti jiné databázi, než je použito v produkčním prostředí (např. Sqlite), se zde nevztahuje, protože jiná databáze se používá jako testovací double; tento přístup se zabývá testováním bez produkčního databázového systému.
Hlavním překážkou při testování, které zahrnuje skutečnou databázi, je zajistit správnou izolaci testů, aby testy spuštěné paralelně (nebo dokonce i v sériovém) navzájem nekompromisovaly. Tady si můžete prohlédnout úplný vzorový kód níže.
Tip
Tato stránka ukazuje techniky xUnit , ale podobné koncepty existují v jiných testovacích architekturách, včetně NUnit.
Nastavení databázového systému
Většina databázových systémů je dnes možné snadno nainstalovat, a to jak v prostředíCH CI, tak na vývojářských počítačích. I když je často dostatečně snadné nainstalovat databázi prostřednictvím běžného instalačního mechanismu, jsou image Dockeru připravené k použití k dispozici pro většinu hlavních databází a můžou usnadnit instalaci zejména v CI. Pro vývojářské prostředí může Dev Container nastavit všechny potřebné služby a závislosti – včetně databáze. I když to vyžaduje počáteční investici do nastavení, jakmile budete mít funkční testovací prostředí a můžete se soustředit na důležitější věci.
V některých případech mají databáze speciální edici nebo verzi, která může být užitečná pro testování. Při použití SQL Serveru lze LocalDB použít ke spouštění testů místně bez prakticky žádné instalace, rozmístit instanci databáze na vyžádání a případně ušetřit prostředky na méně výkonných vývojářských počítačích. LocalDB ale není bez problémů:
- Nepodporuje všechno, co SQL Server Developer Edition dělá.
- Je k dispozici jenom ve Windows.
- Může způsobit prodlevu při prvním testovacím spuštění při spuštění služby.
Obecně doporučujeme nainstalovat edici SQL Server Developer místo LocalDB, protože poskytuje úplnou sadu funkcí SQL Serveru a je obecně velmi snadné.
Při použití cloudové databáze je obvykle vhodné otestovat místní verzi databáze, a to jak ke zlepšení rychlosti, tak ke snížení nákladů. Pokud například používáte SQL Azure v produkčním prostředí, můžete otestovat místně nainstalovaný SQL Server – oba jsou velmi podobné (i když je ještě moudré spouštět testy se samotným SQL Azure před přechodem do produkčního prostředí). Při použití služby Azure Cosmos DB je emulátor služby Azure Cosmos DB užitečným nástrojem pro vývoj místně i pro spouštění testů.
Vytvoření, počáteční a správa testovací databáze
Jakmile je databáze nainstalovaná, můžete ji začít používat v testech. Ve většině jednoduchých případů má vaše sada testů jednu databázi, která je sdílená mezi více testy napříč více třídami testů, takže potřebujeme logiku, abychom zajistili, že se databáze vytvoří a zasadí přesně jednou během životnosti testovacího běhu.
Při použití Xunit to lze provést pomocí zařízení třídy, která představuje databázi a je sdílena napříč několika testovacími běhy:
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);
}
Když dojde k vytvoření instance výše uvedeného zařízení, použije EnsureDeleted() se k vyřazení databáze (v případě, že existuje z předchozího spuštění) a pak EnsureCreated() ji vytvoříte pomocí nejnovější konfigurace modelu (viz dokumentace k těmto rozhraním API). Jakmile se databáze vytvoří, zařízení ho zasadí s některými daty, která naše testy mohou použít. Je vhodné trávit nějaký čas přemýšlet o vašich počátečních datech, protože jejich pozdější změna pro nový test může způsobit selhání stávajících testů.
Chcete-li použít zařízení v testovací třídě, jednoduše implementujte IClassFixture
typ zařízení a xUnit jej vloží do konstruktoru:
public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
public BloggingControllerTest(TestDatabaseFixture fixture)
=> Fixture = fixture;
public TestDatabaseFixture Fixture { get; }
Třída testu má nyní Fixture
vlastnost, kterou můžou testy použít k vytvoření plně funkční instance kontextu:
[Fact]
public void GetBlog()
{
using var context = Fixture.CreateContext();
var controller = new BloggingController(context);
var blog = controller.GetBlog("Blog2").Value;
Assert.Equal("http://blog2.com", blog.Url);
}
Nakonec jste si možná všimli nějakého uzamčení v logice vytváření zařízení výše. Je-li zařízení používáno pouze v jedné testovací třídě, je zaručeno, že se vytvoří instance přesně jednou xUnit; je ale běžné používat stejné zařízení databáze ve více testovacích třídách. xUnit poskytuje sběrná zařízení, ale tento mechanismus zabraňuje paralelnímu spuštění tříd testů, což je důležité pro výkon testů. Abychom to mohli bezpečně spravovat pomocí zařízení třídy xUnit, vezmeme jednoduchý zámek kolem vytváření a seeding databáze a používáme statický příznak, abychom se ujistili, že to nikdy nemusíme udělat dvakrát.
Testy, které upravují data
Výše uvedený příklad ukázal test jen pro čtení, což je snadný případ z hlediska izolace testu: protože se nic nemění, není možné interferenci testu. Naproti tomu testy, které upravují data, jsou problematické, protože mohou vzájemně kolidovat. Jednou z běžných technik izolace psaní testů je zabalení testu do transakce a vrácení této transakce zpět na konec testu. Vzhledem k tomu, že databáze není ve skutečnosti potvrzena nic, ostatní testy nevidí žádné úpravy a interference se vyhne.
Tady je metoda kontroleru, která do naší databáze přidá blog:
[HttpPost]
public ActionResult AddBlog(string name, string url)
{
_context.Blogs.Add(new Blog { Name = name, Url = url });
_context.SaveChanges();
return Ok();
}
Tuto metodu můžeme otestovat následujícím způsobem:
[Fact]
public void AddBlog()
{
using var context = Fixture.CreateContext();
context.Database.BeginTransaction();
var controller = new BloggingController(context);
controller.AddBlog("Blog3", "http://blog3.com");
context.ChangeTracker.Clear();
var blog = context.Blogs.Single(b => b.Name == "Blog3");
Assert.Equal("http://blog3.com", blog.Url);
}
Několik poznámek k výše uvedenému testovacímu kódu:
- Spustíme transakci, abychom se ujistili, že níže uvedené změny nejsou potvrzeny do databáze, a nerušíme jiné testy. Vzhledem k tomu, že transakce není nikdy potvrzena, je implicitně vrácen zpět na konci testu při odstranění kontextové instance.
- Po provedení požadovaných aktualizací vymažeme sledování změn kontextové instance pomocí ChangeTracker.Clear, abychom měli jistotu, že jsme skutečně načetli blog z databáze níže. Místo toho bychom mohli použít dvě kontextové instance, ale pak bychom se museli ujistit, že obě instance používají stejnou transakci.
- Můžete dokonce chtít spustit transakci v zařízení
CreateContext
, aby testy obdržely kontextovou instanci, která je již v transakci, a připravena na aktualizace. To může pomoci zabránit případům, kdy je transakce omylem zapomenuta, což vede k testovací interferenci, která může být obtížné ladit. Můžete také chtít oddělit testy jen pro čtení a zápis v různých třídách testů.
Testy, které explicitně spravují transakce
Existuje jedna konečná kategorie testů, která představuje další potíže: testy, které upravují data a také explicitně spravují transakce. Vzhledem k tomu, že databáze obvykle nepodporují vnořené transakce, není možné použít transakce pro izolaci, jak je uvedeno výše, protože je nutné je používat skutečným kódem produktu. I když tyto testy mají tendenci být vzácnější, je nutné je zpracovat speciálním způsobem: po každém testu musíte databázi vyčistit do původního stavu a paralelizace musí být zakázaná, aby se tyto testy navzájem nenarušovaly.
Pojďme se podívat na následující metodu kontroleru jako příklad:
[HttpPost]
public 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.
using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);
var blog = _context.Blogs.FirstOrDefault(b => b.Name == name);
if (blog is null)
{
return NotFound();
}
blog.Url = url;
_context.SaveChanges();
transaction.Commit();
return Ok();
}
Předpokládejme, že z nějakého důvodu metoda vyžaduje serializovatelnou transakci použít (to obvykle není případ). V důsledku toho nemůžeme k zajištění izolace testu použít transakci. Vzhledem k tomu, že test ve skutečnosti potvrdí změny v databázi, definujeme další zařízení s vlastní, samostatnou databází, abychom zajistili, že nerušíme ostatní testy, které už jsou uvedené výše:
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();
}
}
Toto zařízení se podobá výše použitému zařízení, ale zejména obsahuje metodu. Tuto metodu Cleanup
budeme volat po každém testu, abychom zajistili, že se databáze resetuje do výchozího stavu.
Pokud bude toto zařízení používáno pouze jednou testovací třídou, můžeme na ni odkazovat jako na přípojku třídy, jak je uvedeno výše – xUnit paralelizuje testy ve stejné třídě (přečtěte si další informace o kolekcích testů a paralelizaci v dokumentaci xUnit). Pokud ale chceme toto zařízení sdílet mezi více třídami, musíme zajistit, aby tyto třídy neběžely paralelně, aby nedocházelo k rušení. K tomu použijeme tuto funkci jako příslušenství pro sběr xUnit, nikoli jako zařízení třídy.
Nejprve definujeme kolekci testů, která odkazuje na naše zařízení a bude použita všemi transakčními testovacími třídami, které ji vyžadují:
[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}
Nyní odkazujeme na testovací kolekci v naší třídě testů a přijímáme přípojku v konstruktoru jako předtím:
[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
=> Fixture = fixture;
public TransactionalTestDatabaseFixture Fixture { get; }
Nakonec vytvoříme naši testovací třídu na jedno použití a uspořádáme metodu zařízení Cleanup
, která se má volat po každém testu:
public void Dispose()
=> Fixture.Cleanup();
Všimněte si, že vzhledem k tomu, že xUnit pouze jednou vytvoří instanci kolekce, není nutné používat uzamčení kolem vytváření a počáteční databáze, jak jsme to udělali výše.
Tady si můžete prohlédnout úplný vzorový kód výše.
Tip
Pokud máte více testovacích tříd s testy, které upravují databázi, můžete je přesto spustit paralelně tím, že máte různé příslušenství, přičemž každá odkazuje na vlastní databázi. Vytváření a používání mnoha testovacích databází není problematické a mělo by se provést vždy, když je to užitečné.
Efektivní vytváření databáze
Ve výše uvedených ukázkách jsme použili EnsureDeleted() a EnsureCreated() před spuštěním testů zajistili, že máme aktuální testovací databázi. Tyto operace můžou být v určitých databázích trochu pomalé, což může být problém, když iterujete změny kódu a znovu spustíte testy. V takovém případě můžete chtít dočasně okomentovat EnsureDeleted
konstruktor vašeho zařízení: tím se znovu použije stejná databáze napříč testovacími běhy.
Nevýhodou tohoto přístupu je, že pokud změníte model EF Core, schéma databáze nebude aktuální a testy můžou selhat. V důsledku toho doporučujeme tento postup provést pouze dočasně během vývojového cyklu.
Efektivní vyčištění databáze
Výše jsme viděli, že když jsou změny skutečně potvrzeny do databáze, musíme databázi vyčistit mezi každým testem, aby nedošlo k rušení. V ukázce transakčního testu výše jsme to provedli pomocí rozhraní API EF Core k odstranění obsahu tabulky:
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();
Obvykle to není nejúčinnější způsob, jak tabulku vymazat. Pokud je rychlost testu problém, můžete místo toho tabulku odstranit pomocí nezpracovaného SQL:
DELETE FROM [Blogs];
Můžete také zvážit použití balíčku respawn , který efektivně vymaže databázi. Kromě toho nevyžaduje, abyste zadali tabulky, které mají být vymazány, a proto není nutné aktualizovat kód čištění, protože tabulky se přidají do modelu.
Souhrn
- Při testování skutečné databáze je vhodné rozlišovat mezi následujícími testovacími kategoriemi:
- Testy jen pro čtení jsou poměrně jednoduché a můžou se vždy spouštět paralelně se stejnou databází, aniž byste se museli starat o izolaci.
- Testy zápisu jsou problematické, ale transakce je možné použít k zajištění jejich správné izolace.
- Transakční testy jsou nejproblematičtější a vyžadují logiku resetování databáze zpět do původního stavu a také zakázání paralelizace.
- Oddělení těchto kategorií testů do samostatných tříd může zabránit záměně a náhodné interferenci mezi testy.
- Dejte si představu předem k počátečním testovacím datům a zkuste zapsat testy způsobem, který se příliš často neprolomí, pokud se tato počáteční data změní.
- K paralelizaci testů, které upravují databázi, a případně také k povolení různých konfigurací počátečních dat použijte více databází.
- Pokud se jedná o rychlost testu, můžete se podívat na efektivnější techniky vytváření testovací databáze a čištění dat mezi spuštěními.
- Vždy mějte na paměti paralelizaci a izolaci testů.