Aplikace pro více zařízení (5.) – Testovatelnost

Vítejte v neplánovaném pokračování tohoto miniseriálu. V prvním díle jsme se zabývali potřebným nástrojem, což jsou zejména portable libraries. Ve druhém díle jsme se věnovali základnímu návrhovému vzoru – MVVM, zejména prostřední vrstvě ViewModel, ve třetím díle potom XAMLu používanému pro View a ve čtvrtém abstrakcí platformy.

Měl jsem předem promyšlené čtyři díly, ale při psaní mne napadlo, že by nebylo od věci věnovat se ještě testovatelnosti takto vzniklé aplikace. Nevěděl jsem, do čeho jdu, byl to ryzí průzkum bojem. Na druhou stranu – prodloužený velikonoční víkend u tchýně je ideální příležitosti na tyto hrátky, které by v normální pracovní době byly trochu časovým luxusem.

Architektura

Aby byla aplikace dobře testovatelná, je třeba jasně oddělit vrstvy od sebe. Za tím účelem jsem provedl řadu úprav ve struktuře aplikace. Protože jsem už měl část unit testů hotových, probíhaly úpravy zcela hladce a byl jsem rychle schopen otestovat funkční řešení. Výsledný stav vidíte na obrázku níže. Je jasné, že pro takto malý projekt se jedná o evidentní over-engineering, ale bral jsem to spíše jako cvičení pro nalezení optimální architektury u větších projektů:

image

Pojďme si nyní obrázek popsat podrobněji. Knihovny vytvořené jako Portable Library jsou tmavě modré, platformově specifické knihovny pro Windows Store jsou světle modré. Pro přehlednost jsem v obrázku vynechal knihovny pro Windows Phone – představte si je na stejných místech jako světle modré knihovny.

Samozřejmě není nezbytně nutné fyzicky rozdělovat jednotlivé vrstvy do nezávislých knihoven, ale je to výborná pomůcka pro uvědomění si správné architektury – správné nastavení referencí mezi projekty vám navíc efektivně zabrání vytvářet nepovolené vazby mezi vrstvami.

ShoppingList.ViewModel je platformově nezávislá implementace vrstvy ViewModel. Tuto klíčovou vrstvu je třeba důkladně otestovat, za tím účelem je vhodné oddělit ji od nižší vrstvy (Model) definovanými rozhraními, v mém případě jsou definovány v platormově nezávislé knihovně ShoppingList.Model.Contracts. To umožňuje nejenom dobré testování této vrstvy, ale zároveň ji dělá nezávislou na konkrétní realizaci modelu. Pokud se v budoucnu rozhodnu použít např. jiné úložiště dat, nemusím na vrstvě ViewModel změnit ani písmenko. Zatím to ale nenastalo a tak je vlastní model v případě mé aplikace realizován knihovnou pro uložení dat a autentizaci proti službě Azure Mobile Service – jedná se o jednoduchou knihovnu ShoppingList.Model.AzureMobileService, kde jsou implementována výše uvedená rozhraní.

Protože ViewModel je platformově nezávislý, ale přitom potřebuje volat funkce platformy (navigace, uložení do lokálního úložiště apod.), které jsou samozřejmě platformově závislé, musí i zde být nezbytné oddělení pomocí rozhraní. Ta jsou definována v platformově nezávislé knihovně ShoppingList.Platform.Contracts a implementována v platformové knihovně ShoppingList.Platform.WindowsStore (případně ShoppingList.Platform.WindowsPhone).

Zajímavá je platformově závislá knihovna ShoppingList.View.WindowsStore, kde je definována vrstva View, tedy “obrazovky”. Povšimněte si, že nezávisí vůbec na žádné další části řešení! To je možné díky výbornému data-bindingu ve Windows Store platormě. V případě telefonní aplikace ShoppingList.View.WindowsPhone je zde závislost na vrstvě ViewModel daná tím, že pro tlačítka na dolní liště aplikace nelze bohužel použít data binding. Nicméně nemyslím si, že by to bylo jakkoliv na závadu. Fundamentalisté návrhových vzorů by ještě oddělili vrstvu ViewModel od View pomocí obecného rozhraní, ale to je na mne přece jenom už trochu extrémní ;pojetí. Vrstva View sama o sobě se prakticky testovat nedá a že bych někdy v budoucnu chtěl vyměnit vrstvu ViewModel za jinou implementaci a přitom zachovat nedotčenou vrstvu View (tedy vyměnit logiku uživatelského rozhraní a zároveň zachovat jednotlivé obrazovky) – tak to si nedovedu představit ani při zapojení veškeré svojí obrazotvornosti.

Dále tu máme knihovnu ShoppingList.Common, miniaturní knihovnu pro funkčnost využívanou všemi vrstvami, kterou nelze jednoznačně zařadit do konkrétní vrstvy. V mém případě v ní jsou pomocné třídy pro návrhový vzor Inversion of Control a posílání zpráv mezi komponentami aplikace. Obě jsou pouze tenkými slupkami nad funkcemi frameworku MVVMLight. Instinktivně jsem nechtěl, aby se mi tento framework příliš “rozlézal” do kódu, co kdybych ho chtěl v budoucnu nahradit jiným?

Vlastní projekty aplikací ShoppingList.App.WindowsStore a ShoppingList.App.WindowsPhone jsou pouze jakýmsi hrncem, ve kterém se smíchají jednotlivé inicializované vrstvy (Model, View, ViewModel a abstrakce funkcí platformy). Vlastní kód prakticky neobsahují.

Testování abstrakce platformy

Začněme testováním vrstvy abstrahující platformu. Zde jsou některé funkce, které lze testovat snadno (např. ukládání do lokálního úložiště) a jiné, které testovat prakticky nelze, protože jsou čistě vizuální (např. navigaci mezi stránkami). Spokojme se tedy s konstatováním, že budeme testovat některé platformové funkce.

První věc, na kterou je třeba se připravit, je omezenost mobilních platforem (kam počítám Windows Phone a Windows Store) ve srovnání s plným .NET frameworkem, který je mnohem vyzrálejší. Pro tuto chvíli tedy určitě zapomeňte na měření pokrytí kódu testy (Code Coverage), analýzu dopadu změn na testy (Test Impact) nebo např. podporu mockovacího frameworku Fakes (a jistě i řady dalších).

Protože budeme chtít stejné testy spouštět na více platformách, určitě bychom rádi měli testovací kód jako portable library (PL). Bohužel potřebné třídy, např. atributy testovacích tříd a metod anebo Assert nemají zatím formu PL, tudíž je (jak jsme se dozvěděli již v prvním díle) kód z PL nemůže používat. I když se jedná o stejné třídy se stejným rozhraním, jsou implementovány v různých knihovnách pro různé platformy.

Nakonec jsem zvolil následující jednoduché řešení – skutečný testovací kód je v PL a místo třídy Assert vyhazuje výjimky při nedodržení očekávaných podmínek. Například třída StorageServiceTest v knihovně ShoppingList.Platform.Test má následující metodu:

public void ReadNonExistingTest(IStorageService target)
{
string key = keyBase + Guid.NewGuid().ToString();
if (target.Read<string>(key) != null)
throw new Exception("Storage should return null");
}

Na každé platformě je pak z této třídy odvozena v platformově závislém projektu (ShoppingList.Platform.WindowsPhone.Test a ShoppingList.Platform.WindowsStore.Test) třída s příslušnými testovacími atributy, např.

[TestMethod]
public void ReadNonExistingTest()
{
base.ReadNonExistingTest(new StorageService());
}

Bohužel tento tenký kód je duplikovaný pro každou platformu, ale je tak nepatrný, že v tom nevidím vážný problém (a navíc – lepší řešení jsem nenašel).

Připomeňme ještě, že podpora unit testů pro Windows Store aplikace je obsažena ve Visual Studiu 2012, podpora pro Windows Phone aplikace je ve Visual Studiu 2012 Update 2.

Testování společných funkcí

Společných funkcí je velmi málo a jsou velmi jednoduché. Otestovat je s téměř 100%ním pokrytím kódu testy je hračka. Jedná se o portable knihovnu, takže není problém ji otestovat v testovacím projektu v .NET frameworku 4.5 se všemi jeho vymoženostmi. Kód najdete v knihovně ShoppingList.Common.Test.

Testování Modelu

Protože model se může velmi lišit, může se stejně lišit i přístup k jeho testování.

V některých případech je model “tlustý”, obsahuje značné množství kódu a je žádoucí ho dobře otestovat. Pokud používá nějaké externí funkce (např. databázi nebo síťovou komunikaci), je vhodné je izolovat buď pomocí rozhraní anebo při testování použít nějaký mockovací framework k simulaci externích funkcí. Např. ve Visual Studiu 2012 Ultimate a nově též Premium (od Update 2) je pro oba výše zmíněné účely framework Fakes (více např. zde).

Pokud je model tenký (např. pouhé volání webových služeb s nějakými parametry bez větší logiky), nepřináší testování této vrstvy v izolaci od zbytku aplikace valnou hodnotu. Zde má spíše smysl provádět integrační testy na správnou komunikaci s externí službou – např. zavolat funkci modelu pro vložení nového záznamu a pak se nezávisle přesvědčit, zda byla data skutečně vložena.

Můj model používá klienta služby Azure Mobile Service a je spíše tenčí, přesto v něm nějaký kód je. Po chvíli přemýšlení jsem se rozhodl, že se pokusím o oba dva přístupy. Bohužel jsem narazil s prvním přístupem, neboť knihovna Azure Mobile Service sice může běžet v plném .NET frameworku, ale díky chybné definici z ní nejde generovat mockovací knihovna Fakes, takže testování v izolaci bohužel nepřipadá v úvahu.

Použil jsem tedy pouze druhý přístup – integrační testy. Původně jsem psal testy v jedné testovací knihovně, ale brzy jsem si všiml, že kód se rozpadá na dvě části a podle nich jsem rozdělil projekt na dva kusy – první jsou fragmenty, které jsou nezávislé na konkrétní realizaci modelu (jsou psané proti rozhraním modelu), ty jsem umístil do knihovny ShoppingList.Model.Test. Druhou tvoří kód specifický pro moji realizaci modelu pomocí Azure Mobile Service, tu jsem umístil do knihovny ShoppingList.Model.AzureMobileService.Test. Tato druhá knihovna v podstatě připraví data pro provedení testu, pak zavolá metodu z první knihovny a poté ověří, zda data po provedení testu vypadají tak, jak se očekávalo. Obě knihovny jsou napsané v .NET frameworku, je tedy k dispozici veškerý komfort (testovací atributy, měření pokrytí kódu, třída Assert apod.)

Pro účely testu jsem vypnul autentizaci na službě Mobile Service, neboť vizuální přihlašovací okno v testu dost dobře nejde použít.

 Testování ViewModelu

ViewModel by měl být nejsnáze testovatelnou vrstvou, neboť je zespodu dobře izolován od modelu pomocí rozhraní. Je tedy jednoduché simulovat model, ať už použitím mockovacího frameworku Fakes anebo bez něj – plnou implementací “falešného” modelu. Psaní testů bylo skutečně jednoduché a dosáhnout prakticky 100%ního pokrytí kódu testy snadné. Veškeré testy najdete v projektu ShoppingList.ViewModel.Test.

Jediné úskalí, na které jsem narazil souvisí s asynchronností některých funkcí. Obecně se nedoporučuje používat funkce typu “async void” neboť nelze čekat na výsledek nebo ošetřit výjimku (viz např. článek v MSDN magazínu). Jenomže právě ViewModel je místo, kde je jejich používání oprávněné – např. stisknuté tlačítko nemá na co čekat, typický “fire and forget” mechanismus. Což ovšem bohužel komplikuje testování, neboť např. po zavolání příkazu nevíte, zda už byl asynchronní kód ukončen.

Zkoušel jsem několik cest:

  • Přidání nových metod do API ViewModelu se signaturou “async Task”, které by šlo snadno testovat. Tuto cestu jsem ale záhy opustil. Myšlenka, že by se kvůli testům přidával kód do produktu pro mne byla nestravitelná, navíc bych ve své podstatě testoval jiný kód, než který bude používán v produktu.
  • Hraní si se synchronizačním kontextem – viz https://blogs.msdn.com/b/pfxteam/archive/2012/01/20/10259049.aspx – bohužel nefunguje spolehlivě, pokud asynchronní kód spustí další asynchronní kód
  • Počítání threadů, podobně jako je popsané v tomto článku https://www.codeproject.com/Articles/8398/Wait-for-threads-in-a-ThreadPool-object-to-complete. Vše fungovalo spolehlivě, ale co když někdo spustí více testů naráz (testovací framework to umožňuje, i když ve výchozím nastavení testy paralelně neběží). Opět slepá ulička
  • Prosté čekání po zavolání asynchronní funkce pomocí Thread.Sleep(100). Za sto milisekund se vše spolehlivě dokončí a doba provádění testu zůstává relativně krátká. Úplně spokojený sice nejsem, ale funguje to velmi spolehlivě a kód zůstává dobře čitelný.

(Ne)testování View

Žádný rozumný způsob jak testovat View nezávisle na ViewModelu není. View navíc neobsahuje prakticky žádný kód, a pokud ano, tak zcela triviální (nastavení focusu na ovládací prvek). Další otázkou tedy je, jaký by byl přínos takového testu (kromě naprosto formálního zvýšení pokrytí kódu testy).

Teoreticky je možné testovat uživatelské rozhraní kompletní aplikace pomocí automatizace uživatelského rozhraní, ale Visual Studio 2012 to neumí pro Windows Phone ani Windows Store platformy. Takže testování vrstvy View vzdejme a na závěr si ukažme krásný obrázek všech proběhlých testů:

image

A také statistiku pokrytí jednotlivých knihoven testy, přičemž není problém přiblížit se 100% pokrytí (což samozřejmě samo o sobě nezaručuje správnost kódu):

image

Závěrem a pozvánka

Zcela jistě byste měli rádi k dispozici finální kód řešení včetně všech testů. Najdete ho zde.

Dne 9.4. od 10:00 hodin pořádáme na výše uvedené téma hodinový online seminář, kde se o problematice můžete dozvědět více. Využijte příležitosti položit konkrétní dotazy, které vás zajímají. Zaregistrovat se můžete zde.

Michael