Článek „Posuvná odpovědnost vzoru úložiště“ vyvolal několik otázek, na které je velmi obtížné odpovědět. Potřebujeme úložiště, když není možné úplné ignorování technických detailů? Jak složité musí být úložiště, aby jeho přidání mohlo být považováno za užitečné? Odpověď na tyto otázky se liší v závislosti na důrazu kladeném na vývoj systémů. Pravděpodobně nejtěžší otázka je následující:potřebujete vůbec úložiště? Problém „plynulé abstrakce“ a rostoucí složitost kódování s rostoucí úrovní abstrakce neumožňují najít řešení, které by uspokojilo obě strany plotu. Například při vytváření sestav vede návrh záměru k vytvoření velkého počtu metod pro každý filtr a řazení a generické řešení vytváří velkou režii na kódování.
Abych měl úplný obrázek, podíval jsem se na problém abstrakcí z hlediska jejich aplikace v původním kódu. Repozitář nás v tomto případě zajímá pouze jako nástroj pro získání kvalitního a bezchybného kódu. Samozřejmě, že tento vzorec není jediná věc nezbytná pro aplikaci praktik TDD. Poté, co jsem během vývoje několika velkých projektů snědl bušl soli a sledoval, co funguje a co ne, vytvořil jsem si pro sebe několik pravidel, která mi pomáhají dodržovat praktiky TDD. Jsem otevřený konstruktivní kritice a dalším metodám implementace TDD.
Předmluva
Někteří si mohou všimnout, že není možné použít TDD ve starém projektu. Existuje názor, že různé typy integračních testů (UI-testy, end-to-end) jsou pro ně vhodnější, protože je příliš obtížné porozumět starému kódu. Také můžete slyšet, že psaní testů před skutečným kódováním vede pouze ke ztrátě času, protože nemusíme vědět, jak bude kód fungovat. Musel jsem pracovat na několika projektech, kde jsem byl omezen pouze na integrační testy, protože jsem věřil, že unit testy nejsou orientační. Zároveň bylo napsáno mnoho testů, spouštěli spoustu služeb atd. Ve výsledku jim rozuměl pouze jeden člověk, který je ve skutečnosti psal.
Během své praxe se mi podařilo pracovat na několika velmi velkých projektech, kde bylo hodně legacy kódu. Některé z nich obsahovaly testy, jiné nikoli (existoval pouze záměr je implementovat). Podílel jsem se na dvou velkých projektech, ve kterých jsem se nějak snažil uplatnit přístup TDD. V počáteční fázi byl TDD vnímán jako první testovací vývoj. Nakonec se rozdíly mezi tímto zjednodušeným chápáním a současným vnímáním, krátce nazývaným BDD, staly jasnějšími. Ať už se použije jakýkoli jazyk, hlavní body, já jim říkám pravidla, zůstávají podobné. Někdo může najít paralely mezi pravidly a jinými principy psaní dobrého kódu.
Pravidlo 1:Použití zdola nahoru (zevnitř)
Toto pravidlo odkazuje spíše na metodu analýzy a návrhu softwaru při vkládání nových částí kódu do pracovního projektu.
Když navrhujete nový projekt, je naprosto přirozené představit si celý systém. V této fázi ovládáte jak sadu komponent, tak budoucí flexibilitu architektury. Proto můžete psát moduly, které lze snadno a intuitivně vzájemně integrovat. Takový přístup shora dolů vám umožňuje provést dobrý úvodní návrh budoucí architektury, popsat potřebné vodící linie a získat úplný obrázek o tom, co nakonec chcete. Po chvíli se projekt promění v to, čemu se říká legacy code. A pak začíná zábava.
Ve fázi, kdy je nutné zabudovat novou funkcionalitu do existujícího projektu s hromadou modulů a závislostí mezi nimi, může být velmi obtížné je všechny vložit do hlavy, abyste vytvořili správný design. Druhou stránkou tohoto problému je množství práce potřebné ke splnění tohoto úkolu. Proto bude v tomto případě efektivnější přístup zdola nahoru. Jinými slovy, nejprve vytvoříte kompletní modul, který vyřeší potřebnou úlohu, a poté jej zabudujete do stávajícího systému a provedete pouze nezbytné změny. V tomto případě můžete garantovat kvalitu tohoto modulu, protože se jedná o kompletní funkční jednotku.
Je třeba poznamenat, že s přístupy to není tak jednoduché. Například při navrhování nové funkce ve starém systému, ať se vám to líbí nebo ne, budete používat oba přístupy. Při prvotní analýze je ještě potřeba systém vyhodnotit, poté jej snížit na úroveň modulu, implementovat a poté se vrátit zpět na úroveň celého systému. Podle mého názoru je zde hlavní nezapomenout, že nový modul by měl být kompletní funkcionalitou a být nezávislý, jako samostatný nástroj. Čím přísněji budete tento přístup dodržovat, tím méně změn bude proveden ve starém kódu.
Pravidlo 2:Testujte pouze upravený kód
Při práci se starým projektem není absolutně potřeba psát testy pro všechny možné scénáře metody/třídy. Navíc o některých scénářích nemusíte vůbec vědět, protože jich může být spousta. Projekt je již ve výrobě, zákazník je spokojen, takže si můžete odpočinout. Obecně platí, že problémy v tomto systému způsobují pouze vaše změny. Proto by měly být testovány pouze ony.
Příklad
Existuje modul internetového obchodu, který vytvoří košík s vybraným zbožím a uloží jej do databáze. Nezáleží nám na konkrétní implementaci. Hotovo jako hotovo – toto je starší kód. Nyní zde musíme zavést nové chování:odeslat upozornění účetnímu oddělení v případě, že cena košíku přesáhne 1000 USD. Zde je kód, který vidíme. Jak zavést změnu?
public class EuropeShop : Shop { public override void CreateSale() { var items = LoadSelectedItemsFromDb(); var taxes = new EuropeTaxes(); var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList(); var cart = new Cart(); cart.Add(saleItems); taxes.ApplyTaxes(cart); SaveToDb(cart); } }
Podle prvního pravidla musí být změny minimální a atomické. Nemáme zájem o načítání dat, nestaráme se o výpočet daně a ukládání do databáze. Nás ale zajímá vypočítaný košík. Pokud by existoval modul, který dělá to, co je požadováno, pak by provedl nezbytný úkol. Proto to děláme.
public class EuropeShop : Shop { public override void CreateSale() { var items = LoadSelectedItemsFromDb(); var taxes = new EuropeTaxes(); var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList(); var cart = new Cart(); cart.Add(saleItems); taxes.ApplyTaxes(cart); // NEW FEATURE new EuropeShopNotifier().Send(cart); SaveToDb(cart); } }
Takový oznamovatel funguje samostatně, lze jej testovat a změny provedené ve starém kódu jsou minimální. Přesně to říká druhé pravidlo.
Pravidlo 3:Testujeme pouze požadavky
Abyste si ulevili od množství scénářů, které vyžadují testování pomocí jednotkových testů, přemýšlejte o tom, co vlastně od modulu potřebujete. Nejprve napište minimální sadu podmínek, které si můžete představit jako požadavky na modul. Minimální sada je sada, která po doplnění novou se chování modulu příliš nezmění a po vyjmutí modul nefunguje. BDD přístup v tomto případě hodně pomáhá.
Představte si také, jak s ním budou komunikovat ostatní třídy, které jsou klienty vašeho modulu. Potřebujete ke konfiguraci modulu napsat 10 řádků kódu? Čím jednodušší je komunikace mezi částmi systému, tím lépe. Proto je lepší vybrat moduly odpovědné za něco konkrétního ze starého kódu. SOLID v tomto případě přijde na pomoc.
Příklad
Nyní se podívejme, jak nám vše popsané výše pomůže s kódem. Nejprve vyberte všechny moduly, které jsou s vytvořením košíku spojeny pouze nepřímo. Takto je rozdělena odpovědnost za moduly.
public class EuropeShop : Shop { public override void CreateSale() { // 1) load from DB var items = LoadSelectedItemsFromDb(); // 2) Tax-object creates SaleItem and // 4) goes through items and apply taxes var taxes = new EuropeTaxes(); var saleItems = items.Select(item => taxes.ApplyTaxes(item)).ToList(); // 3) creates a cart and 4) applies taxes var cart = new Cart(); cart.Add(saleItems); taxes.ApplyTaxes(cart); new EuropeShopNotifier().Send(cart); // 4) store to DB SaveToDb(cart); } }
Tímto způsobem je lze rozlišit. Takové změny samozřejmě nelze ve velkém systému dělat najednou, ale lze je dělat postupně. Pokud se například změny týkají daňového modulu, můžete zjednodušit, jak na něm závisí ostatní části systému. To může pomoci zbavit se vysokých závislostí a v budoucnu jej používat jako samostatný nástroj.
public class EuropeShop : Shop { public override void CreateSale() { // 1) extracted to a repository var itemsRepository = new ItemsRepository(); var items = itemsRepository.LoadSelectedItems(); // 2) extracted to a mapper var saleItems = items.ConvertToSaleItems(); // 3) still creates a cart var cart = new Cart(); cart.Add(saleItems); // 4) all routines to apply taxes are extracted to the Tax-object new EuropeTaxes().ApplyTaxes(cart); new EuropeShopNotifier().Send(cart); // 5) extracted to a repository itemsRepository.Save(cart); } }
Pokud jde o testy, tyto scénáře budou dostatečné. Zatím nás jejich implementace nezajímá.
public class EuropeTaxesTests { public void Should_not_fail_for_null() { } public void Should_apply_taxes_to_items() { } public void Should_apply_taxes_to_whole_cart() { } public void Should_apply_taxes_to_whole_cart_and_change_items() { } } public class EuropeShopNotifierTests { public void Should_not_send_when_less_or_equals_to_1000() { } public void Should_send_when_greater_than_1000() { } public void Should_raise_exception_when_cannot_send() { } }
Pravidlo 4:Přidejte pouze testovaný kód
Jak jsem psal dříve, měli byste minimalizovat změny starého kódu. K tomu je možné rozdělit starý a nový/upravený kód. Nový kód lze umístit do metod, které lze zkontrolovat pomocí jednotkových testů. Tento přístup pomůže snížit související rizika. V knize „Efektivní práce se starším kódem“ (odkaz na knihu níže) byly popsány dvě techniky.
Metoda/třída Sprout – tato technika umožňuje vložit velmi bezpečný nový kód do starého. Způsob, jakým jsem přidal oznamovatel, je příkladem tohoto přístupu.
Wrap metoda – trochu složitější, ale podstata je stejná. Ne vždy to funguje, ale pouze v případech, kdy je volán nový kód před/po starém. Při přidělování odpovědností byla dvě volání metody ApplyTaxes nahrazena jedním voláním. K tomu bylo nutné změnit druhou metodu, aby se logika velmi nelámala a bylo možné ji kontrolovat. Takhle vypadala třída před změnami.
public class EuropeTaxes : Taxes { internal override SaleItem ApplyTaxes(Item item) { var saleItem = new SaleItem(item) { SalePrice = item.Price*1.2m }; return saleItem; } internal override void ApplyTaxes(Cart cart) { if (cart.TotalSalePrice <= 300m) return; var exclusion = 30m/cart.SaleItems.Count; foreach (var item in cart.SaleItems) if (item.SalePrice - exclusion > 100m) item.SalePrice -= exclusion; } }
A tady, jak to vypadá potom. Logika práce s prvky vozíku se trochu změnila, ale obecně zůstalo vše při starém. V tomto případě stará metoda volá nejprve novou ApplyToItems a poté její předchozí verzi. To je podstata této techniky.
public class EuropeTaxes : Taxes { internal override void ApplyTaxes(Cart cart) { ApplyToItems(cart); ApplyToCart(cart); } private void ApplyToItems(Cart cart) { foreach (var item in cart.SaleItems) item.SalePrice = item.Price*1.2m; } private void ApplyToCart(Cart cart) { if (cart.TotalSalePrice <= 300m) return; var exclusion = 30m / cart.SaleItems.Count; foreach (var item in cart.SaleItems) if (item.SalePrice - exclusion > 100m) item.SalePrice -= exclusion; } }
Pravidlo 5:„Prolomit“ skryté závislosti
Toto je pravidlo o největším zlu ve starém kódu:použití nového operátor uvnitř metody jednoho objektu k vytvoření dalších objektů, úložišť nebo jiných složitých objektů. proč je to špatné? Nejjednodušším vysvětlením je, že díky tomu jsou části systému vysoce propojené a pomáhá to snížit jejich soudržnost. Ještě kratší:vede k porušení principu „nízká vazba, vysoká soudržnost“. Pokud se podíváte na druhou stranu, pak je příliš obtížné extrahovat tento kód do samostatného nezávislého nástroje. Zbavit se najednou takových skrytých závislostí je velmi pracné. Ale to lze provést postupně.
Nejprve musíte přenést inicializaci všech závislostí do konstruktoru. To platí zejména pro nové operátorů a vytváření tříd. Pokud máte ServiceLocator k získávání instancí tříd, měli byste jej také odebrat do konstruktoru, kde z něj můžete vytáhnout všechna potřebná rozhraní.
Za druhé, proměnné, které ukládají instanci externího objektu/úložiště, musí mít abstraktní typ a lépe rozhraní. Rozhraní je lepší, protože poskytuje více možností pro vývojáře. Ve výsledku to umožní vytvořit atomový nástroj z modulu.
Za třetí, nenechávejte velké metodické listy. To jasně ukazuje, že metoda dělá více, než je uvedeno v jejím názvu. To také naznačuje možné porušení SOLID, zákona Demeter.
Příklad
Nyní se podívejme, jak se změnil kód, který vytváří košík. Pouze blok kódu, který vytváří košík, zůstal nezměněn. Zbytek byl umístěn do externích tříd a může být nahrazen libovolnou implementací. Nyní má třída EuropeShop formu atomového nástroje, který potřebuje určité věci, které jsou explicitně zastoupeny v konstruktoru. Kód se stává snáze vnímatelným.
public class EuropeShop : Shop { private readonly IItemsRepository _itemsRepository; private readonly Taxes.Taxes _europeTaxes; private readonly INotifier _europeShopNotifier; public EuropeShop() { _itemsRepository = new ItemsRepository(); _europeTaxes = new EuropeTaxes(); _europeShopNotifier = new EuropeShopNotifier(); } public override void CreateSale() { var items = _itemsRepository.LoadSelectedItems(); var saleItems = items.ConvertToSaleItems(); var cart = new Cart(); cart.Add(saleItems); _europeTaxes.ApplyTaxes(cart); _europeShopNotifier.Send(cart); _itemsRepository.Save(cart); } }SCRIPT
Pravidlo 6:Čím méně velkých testů, tím lépe
Velké testy jsou různé integrační testy, které se snaží otestovat uživatelské skripty. Nepochybně jsou důležité, ale prověřit logiku nějakého IF do hloubky kódu je velmi drahé. Psaní tohoto testu trvá stejně dlouho, ne-li déle, jako psaní samotné funkce. Jejich podpora je jako další starý kód, který je obtížné změnit. Ale to jsou jen testy!
Je nutné porozumět tomu, které testy jsou potřebné, a toto porozumění jasně dodržovat. Pokud potřebujete kontrolu integrace, napište minimální sadu testů, včetně scénářů pozitivní a negativní interakce. Pokud potřebujete otestovat algoritmus, napište minimální sadu jednotkových testů.
Pravidlo 7:Netestujte soukromé metody
Soukromá metoda může být příliš složitá nebo může obsahovat kód, který není volán z veřejných metod. Jsem si jistý, že jakýkoli jiný důvod, na který si vzpomenete, se ukáže jako charakteristika „špatného“ kódu nebo designu. S největší pravděpodobností by část kódu ze soukromé metody měla být vytvořena jako samostatná metoda/třída. Zkontrolujte, zda není porušena první zásada SOLID. To je první důvod, proč se to nevyplatí dělat. Druhým je, že tímto způsobem nekontrolujete chování celého modulu, ale jak jej modul implementuje. Interní implementace se může změnit bez ohledu na chování modulu. Proto v tomto případě získáte křehké testy a jejich podpora zabere více času, než je nutné.
Abyste se vyhnuli nutnosti testovat soukromé metody, prezentujte své třídy jako sadu atomických nástrojů a nevíte, jak jsou implementovány. Očekáváte nějaké chování, které testujete. Tento postoj platí také pro třídy v kontextu shromáždění. Třídy, které jsou k dispozici klientům (z jiných shromáždění), budou veřejné a ty, které provádějí interní práci, soukromé. I když existuje rozdíl od metod. Interní třídy mohou být složité, takže je lze transformovat na interní a také testovat.
Příklad
Například pro testování jedné podmínky v soukromé metodě třídy EuropeTaxes nebudu psát test pro tuto metodu. Budu očekávat, že daně budou aplikovány určitým způsobem, takže test bude odrážet právě toto chování. V testu jsem ručně spočítal, jaký by měl být výsledek, bral to jako standard a stejný výsledek očekávám od třídy.
public class EuropeTaxes : Taxes { // code skipped private void ApplyToCart(Cart cart) { if (cart.TotalSalePrice <= 300m) return; // <<< I WANT TO TEST THIS CONDIFTION var exclusion = 30m / cart.SaleItems.Count; foreach (var item in cart.SaleItems) if (item.SalePrice - exclusion > 100m) item.SalePrice -= exclusion; } } // test suite public class EuropeTaxesTests { // code skipped [Fact] public void Should_apply_taxes_to_cart_greater_300() { #region arrange // list of items which will create a cart greater 300 var saleItems = new List<Item>(new[]{new Item {Price = 83.34m}, new Item {Price = 83.34m},new Item {Price = 83.34m}}) .ConvertToSaleItems(); var cart = new Cart(); cart.Add(saleItems); const decimal expected = 83.34m*3*1.2m; #endregion // act new EuropeTaxes().ApplyTaxes(cart); // assert Assert.Equal(expected, cart.TotalSalePrice); } }
Pravidlo 8:Netestujte algoritmus metod
Někteří lidé kontrolují počet volání určitých metod, ověřují samotné volání atd., jinými slovy kontrolují vnitřní práci metod. Je to stejně špatné jako testování soukromých. Rozdíl je pouze v aplikační vrstvě takové kontroly. Tento přístup opět poskytuje mnoho křehkých testů, takže někteří lidé neberou TDD správně.
Přečtěte si více…
Pravidlo 9:Neupravujte starší kód bez testů
Toto je nejdůležitější pravidlo, protože odráží týmovou touhu jít touto cestou. Bez touhy jít tímto směrem nemá vše, co bylo řečeno výše, žádný zvláštní význam. Protože pokud vývojář nechce TDD používat (nechápe jeho význam, nevidí výhody atd.), tak jeho skutečný přínos bude zamlžován neustálými diskusemi o tom, jak je to obtížné a neefektivní.
Pokud se chystáte používat TDD, prodiskutujte to se svým týmem, přidejte to do Definition of Done a aplikujte to. Zpočátku to bude těžké, jako u všeho nového. Jako každé umění vyžaduje TDD neustálou praxi a potěšení přichází, když se učíte. Postupně bude přibývat písemných unit testů, začnete pociťovat „zdraví“ vašeho systému a začnete oceňovat jednoduchost psaní kódu, popisujícího požadavky v první fázi. Existují studie TDD provedené na skutečných velkých projektech v Microsoftu a IBM, které ukazují snížení chyb v produkčních systémech ze 40 % na 80 % (viz odkazy níže).
Další čtení
- Kniha „Efektivní práce se starším kódem“ od Michaela Featherse
- TDD, když máte po krk v Legacy Code
- Prolomení skrytých závislostí
- Životní cyklus staršího kódu
- Měli byste otestovat soukromé metody ve třídě?
- Interní jednotky testování
- 5 běžných mylných představ o TDD a testech jednotek
- Zákon Demeter