Daniel Kolman

Jak na integrační testy s databází

| 4 comments |

Nejlepší integrační test je žádný integrační test. Jenže na okrajích našeho systému, tam kde naše aplikace komunikuje s ostatními aplikacemi, je integrační test potřeba. A nebo pracujeme s hnusným starým kódem, ze kterého je těžké odstranit závislosti na databázi a nemáme čas to předělávat. A protože nejčastější případ je integrační test s databází, mám pro vás pár zkušeností, které se osvědčily.

Testy s databází mají spoustu nevýhod, které jsou všeobecně známé, takže je zmíním jen ve stručnosti: Jsou pomalé, vyžadují komplexní setup (práva, connection string, databáze se správným schema, testovací data...), jsou závislé na prostředí (databázový server), nejdou paralelizovat a není jednoduché je napsat dobře (zejména se zapomíná na vzájemnou izolaci testů).

Někdy je ale použít musíte. Znám dva dobré důvody, kdy je použít:

  1. Vaše aplikace spoléhá na abstraktní "datovou vrstvu". Pak je dobré každou operaci, kterou má datová vrstva vykonávat, otestovat integračním testem, abychom měli jistotu, že to bude fungovat se skutečnou databází. Příklad: V datové vrstvě je abstraktní třída RepositoryBase, která implementuje rozhraní IRepository a umí provádět základní operace jako List, Add, Remove a Query. Vyplatí se otestovat, že tato base třída je skutečně schopná tyto operace vykonávat nad reálnou databází. Testy doménové vrstvy pak můžeme v klidu psát s mockovanou IRepository.
  2. Přístup k datům je špatně vyřešen a nejde jednoduše zamockovat. Došlo vám to pozdě anebo jste přišli k hotové věci a aplikace už je tak rozsáhlá, že nejste schopni to v rozumném čase opravit. V takovém případě je lepší psát integrační testy než žádné testy. Měli byste ale současně vymyslet, jak se postupným refaktoringem propracovat k lepšímu řešení.

Až budete přecházet na novou verzi databázového serveru nebo ORM knihovny, kvalitní integrační testy vám ušetří mnoho času a nervů.

Izolace testů

Základní pravidlo, které je nutné dodržet, pakliže si chceme udržet mentální zdraví, je, že se testy nesmí navzájem ovlivňovat. U integračních testů to znamená, že výchozí stav databáze musí být na začátku každého testu stejný a že v jeden okamžik může nad jednou databází běžet maximálně jeden test.

Testy proto spouštíme nad práznou databází, která může obsahovat jen statická data. Toho můžeme dosáhnout různými způsoby:

  • Máme k dispozici backup prázdné databáze a před každým testem provedeme restore. Tento přístup by v zásadě mohl fungovat, v praxi to ale bývá dost složité a pomalé.
  • Každý test pouštíme v transakci, kterou na konci rollbackujeme. Toto je častý přístup, ale má několik nevýhod: Nenajde chyby, které nastanou až při commitu transakce, nemůžete testovat kód vyžadující více transakcí a pokud něco selže, nemůžete se podívat do databáze na data, která kód vytvořil. Například ověření hesla uživatele provádíme vždy v samostatné transakci, která je commitnuta i v případě, že operace celkově selže. To proto, že při zadání špatného hesla chceme inkrementovat invalidLoginAttemptCount a rollback by nám tuto změnu zahodil.
  • Na začátku každého testu se spustí SQL skript, který vymaže všechny řádky ze všech tabulek. Je to velmi rychlé, testovaný kód může používat transakce jak se mu zlíbí a po neúspěšném testu lze v databázi analyzovat, co se vlastně stalo. Jediné na co je třeba dávat pozor, je pořadí, ve kterém se tabulky promazávají, aby nedošlo k porušení referenční integrity. Když už se vám to ale stane, velmi rychle na to přijdete – setup testu selže. Tento přístup se nám osvědčil nejvíce.
  • Každý test si vytvoří svoji vlastní databázi. V praxi jsem se s tím nesetkal, ale pokud by to nebylo moc pomalé, byla by to ideální volba – není lepší způsob, jak zajistit izolaci testů.

Tím jsme i odpověděli na otázku, proč mazat data před testem a ne po testu – abychom měli v případě selhání testu možnost podívat se do databáze na výsledná data.

Setup prostředí

Při startu integračního testu je potřeba nastavit prostředí podobně, jako je to v reálném produkčním prostředí. Například je nutné nakonfigurovat datovou vrstvu správným connection stringem a dalšími vlastnostmi, které jsou uloženy v konfiguračním souboru. Abychom dosáhli maximální podobnosti setupu testů a produkčního prostředí, je dobré mít v každé vrstvě bootstrapper, který se stará o její nastartování. Například v datové vrstvě je třída DataBootstrapper, která vytvoří NHibernate session factory podle údajů z konfiguračního souboru. Ideální pak je, když je čtení z konfiguračního souboru odstíněné nějakým rozhraním, aby v testu nebyl žádný konfigurační soubor potřeba. Nám se to zatím nepovedlo, takže máme jeden app.config který linkujeme do všech projektů, kde probíhají integrační testy.

Setup testovacích dat

Každý test začíná s prázdnou databází a musí si nejprve vytvořit data, nad kterými bude operovat. Nejlepší je použít k tomu test data builder objekty, o kterých jsem psal minule. Pokud je testovaná situace složitá a objektů, které je potřeba vytvořit je mnoho, vyplatí se setup dat sdružit do něčeho, čemu říkáme scénáře. Úkolem scénáře je vytvořit nějakou konkrétní situaci, například "uživatel s nákupním košíkem, který obsahuje produkt, jemuž se od přidání do košíku změnila cena".

Databáze

Kde vlastně vzít databázi, nad kterou testy spouštíme? Nejjednodušší je použít nějakou předem existující databázi. Například náš build script nejdříve zkompiluje kód, pak pomocí SQL skriptů vytvoří prázdnou databázi a nakonec spustí testy. Testy proto mohou spoléhat na to, že databáze se správnou strukturou už existuje.

Ideální je, když si build nebo test sám vytvoří vše co potřebuje. Nejlepší by bylo použít embeddovanou databázi, která ukládá data do souboru, například SQL Server Compact. My jsme se k tomu bohužel ještě nedostali, takže vytváříme na normálním SQL Serveru databázi s nějakým jménem, kterou pak použije i test. Má to ale jednu velkou nevýhodu: Když uděláte branch, nesmíte zapomenout změnit název databáze! Jinak poběží testy trunku i branche na jedné databázi, což porušuje izolaci testů a povede k velmi matoucím chybám.

Závěr

Ať už budete v testu pracovat s databází jakkoliv, myslete vždy na to, že testy se nesmějí navzájem ovlivňovat.

(4) Comments

  1. Michal Till said...

    Nevim jak je to v jiných platformách ale v Railsecjh ke paraelizace přes vícero databází je celkem normální. Je to kvůli prolezlosti activerecordovym ORMkem co nejde odpojit, tak se tohle dost rozvinulo. Dokonce existuje cloud SaaS nad EC2, https://www.tddium.com/, co to pouští za prachy.

    19. dubna 2012 v 0:07
  2. Borek Bernard said...

    "Zkuste to bez souborů, milý Marconi". Zdá se mi nejrozumnější použít nějakou in-memory databázi a mít pro každý test novou instanci.

    19. dubna 2012 v 8:34
  3. Daniel Kolman said...

    ad Borek: Proti in-memory databázím mám mírnou averzi, protože:
    1. In-memory databáze se nechová úplně stejně jako reálná produkční databáze, takže je dobré alespoň občas testy spustit nad skutečnou databází. Např. v průběžné integraci po commitu používat SQLite, v nočním buildu MS SQL.
    2. Tím, že se zrychlí testy, není tlak na to, aby se to udělalo dobře (tj. schovat persistenci za nějaké rozumné rozhraní).

    19. dubna 2012 v 9:21
  4. Vojtech Kurka said...

    Pouzivame posledni variantu na MySQL/InnoDB - kazdy test si vytvori vlastni DB.
    DDL statementy se berou z pracovni kopie, jsou ulozene v Gitu, takze verze aplikace a databaze pri testu vzdy sedi. Kazdy test ma svuj vlastni dataset v XML/CSV, taktez v Gitu. Je to dost prace pro vyvojare, ale vyplaci se. Zaklad jsou unit testy nezavisle na DB, to je v clanku zmineno. Diky izolaci testu jich muze bezet libovolne mnozstvi najednou, zalezi jen na vykonu stroje.
    Verze DB je shodna s verzi v produkci, identicke je i nastaveni, testuje se vse vcetne commitu transakci, autoincrementu, specifickych vlastnosti daneho storage enginu atd.
    Dalsi level je simulovat race conditions, to je asi hudba vzdalene budoucnosti.

    21. dubna 2012 v 2:16

Leave a Response