Het artikel “Sliding Responsibility of the Repository Pattern” riep verschillende vragen op, die erg moeilijk te beantwoorden zijn. Hebben we een repository nodig als het volledig negeren van technische details onmogelijk is? Hoe complex moet de repository zijn om de toevoeging ervan als waardevol te beschouwen? Het antwoord op deze vragen is afhankelijk van de nadruk die bij de ontwikkeling van systemen wordt gelegd. Waarschijnlijk de moeilijkste vraag is de volgende:heb je zelfs een repository nodig? Het probleem van "stromende abstractie" en de groeiende complexiteit van codering met een toename van het abstractieniveau maken het niet mogelijk om een oplossing te vinden die aan beide zijden van het hek zou voldoen. Bij rapportage leidt het ontwerp van intenties bijvoorbeeld tot de creatie van een groot aantal methoden voor elk filter en elke sortering, en een generieke oplossing zorgt voor een grote coderingsoverhead.
Om een volledig beeld te krijgen, heb ik gekeken naar het probleem van abstracties in termen van hun toepassing in een legacy-code. Een repository is in dit geval alleen interessant voor ons als hulpmiddel om kwaliteitsvolle en foutloze code te verkrijgen. Dit patroon is natuurlijk niet het enige dat nodig is voor de toepassing van de TDD-praktijken. Na een scheutje zout te hebben gegeten tijdens de ontwikkeling van verschillende grote projecten en kijkend wat werkt en wat niet, heb ik een paar regels voor mezelf ontwikkeld die me helpen om de TDD-praktijken te volgen. Ik sta open voor opbouwende kritiek en andere methoden om TDD te implementeren.
Voorwoord
Sommigen zullen merken dat het niet mogelijk is om TDD toe te passen in een oud project. Er is een mening dat verschillende soorten integratietesten (UI-tests, end-to-end) hiervoor geschikter zijn omdat het te moeilijk is om de oude code te begrijpen. Ook hoor je dat het schrijven van tests voorafgaand aan de eigenlijke codering alleen maar tot tijdverlies leidt, omdat we misschien niet weten hoe de code zal werken. Ik moest aan verschillende projecten werken, waar ik me alleen beperkte tot integratietests, in de overtuiging dat unittests niet indicatief zijn. Tegelijkertijd werden er veel tests geschreven, ze voerden veel services uit, enz. Het resultaat was dat slechts één persoon ze kon begrijpen, die ze in feite heeft geschreven.
Tijdens mijn praktijk slaagde ik erin om aan verschillende zeer grote projecten te werken, waar veel legacy-code was. Sommige bevatten tests, andere niet (het was alleen de bedoeling om ze uit te voeren). Ik heb deelgenomen aan twee grote projecten, waarin ik op de een of andere manier de TDD-aanpak probeerde toe te passen. In de beginfase werd TDD gezien als een Test First-ontwikkeling. Uiteindelijk werden de verschillen tussen dit vereenvoudigde begrip en de huidige perceptie, kortweg BDD genoemd, duidelijker. Welke taal ook wordt gebruikt, de belangrijkste punten, ik noem ze regels, blijven gelijk. Iemand kan parallellen vinden tussen de regels en andere principes van het schrijven van goede code.
Regel 1:Bottom-Up (Inside-Out) gebruiken
Deze regel verwijst eerder naar de analysemethode en softwareontwerp bij het inbedden van nieuwe stukjes code in een werkend project.
Wanneer u een nieuw project ontwerpt, is het volkomen natuurlijk om u een heel systeem voor te stellen. In dit stadium beheers je zowel de set componenten als de toekomstige flexibiliteit van de architectuur. Daarom kunt u modules schrijven die eenvoudig en intuïtief met elkaar kunnen worden geïntegreerd. Zo'n Top-Down aanpak stelt je in staat om vooraf een goed ontwerp te maken van de toekomstige architectuur, de benodigde richtlijnen te beschrijven en een compleet beeld te hebben van wat je uiteindelijk wilt. Na een tijdje verandert het project in wat de legacy-code wordt genoemd. En dan begint de pret.
In het stadium waarin het nodig is om een nieuwe functionaliteit in te bedden in een bestaand project met een heleboel modules en afhankelijkheden ertussen, kan het erg moeilijk zijn om ze allemaal in je hoofd te krijgen om het juiste ontwerp te maken. De andere kant van dit probleem is de hoeveelheid werk die nodig is om deze taak te volbrengen. Daarom zal de bottom-upbenadering in dit geval effectiever zijn. Met andere woorden, u maakt eerst een complete module die de noodzakelijke taak oplost, en vervolgens bouwt u deze in het bestaande systeem in, waarbij u alleen de noodzakelijke wijzigingen aanbrengt. In dit geval kunt u de kwaliteit van deze module garanderen, aangezien het een complete eenheid van het functionele is.
Opgemerkt moet worden dat het niet zo eenvoudig is met de benaderingen. Als je bijvoorbeeld een nieuwe functionaliteit in een oud systeem ontwerpt, zul je, of je het nu leuk vindt of niet, beide benaderingen gebruiken. Tijdens de eerste analyse moet je het systeem nog evalueren, het vervolgens verlagen naar het moduleniveau, het implementeren en dan teruggaan naar het niveau van het hele systeem. Naar mijn mening is het belangrijkste hier om niet te vergeten dat de nieuwe module een volledige functionaliteit moet zijn en onafhankelijk moet zijn, als een afzonderlijke tool. Hoe strikter u zich aan deze aanpak houdt, hoe minder wijzigingen er in de oude code worden aangebracht.
Regel 2:Test alleen de gewijzigde code
Als je met een oud project werkt, is het absoluut niet nodig om tests te schrijven voor alle mogelijke scenario's van de methode/klasse. Bovendien ben je je misschien helemaal niet bewust van sommige scenario's, want er kunnen er genoeg zijn. Het project is al in productie, de klant is tevreden, dus u kunt ontspannen. Over het algemeen veroorzaken alleen uw wijzigingen problemen in dit systeem. Daarom moeten alleen zij worden getest.
Voorbeeld
Er is een online winkelmodule, die een winkelwagentje met geselecteerde artikelen maakt en opslaat in een database. Het gaat ons niet om de specifieke uitvoering. Klaar zoals gedaan - dit is de oude code. Nu moeten we hier een nieuw gedrag introduceren:stuur een melding naar de boekhoudafdeling als de kosten van het winkelwagentje hoger zijn dan $ 1000. Hier is de code die we zien. Hoe de verandering door te voeren?
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); } }
Volgens de eerste regel moeten de veranderingen minimaal en atomair zijn. We zijn niet geïnteresseerd in het laden van gegevens, we geven niet om de belastingberekening en het opslaan in de database. Maar we zijn geïnteresseerd in de berekende winkelwagen. Als er een module was die doet wat nodig is, dan zou hij de noodzakelijke taak uitvoeren. Daarom doen we dit.
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); } }
Zo'n melder werkt op zichzelf, is te testen en de wijzigingen in de oude code zijn minimaal. Dit is precies wat de tweede regel zegt.
Regel 3:We testen alleen vereisten
Om jezelf te ontlasten van het aantal scenario's dat moet worden getest met unit-tests, bedenk dan wat je eigenlijk nodig hebt van een module. Schrijf eerst voor de minimale set voorwaarden die u zich kunt voorstellen als vereisten voor de module. De minimale set is de set, die, wanneer aangevuld met een nieuwe, het gedrag van de module niet veel verandert, en wanneer verwijderd, werkt de module niet. De BDD-aanpak helpt in dit geval enorm.
Stel je ook voor hoe andere klassen die klanten van je module zijn, ermee omgaan. Moet u 10 regels code schrijven om uw module te configureren? Hoe eenvoudiger de communicatie tussen de onderdelen van het systeem, hoe beter. Daarom is het beter om modules die verantwoordelijk zijn voor iets specifieks uit de oude code te selecteren. SOLID zal in dit geval helpen.
Voorbeeld
Laten we nu eens kijken hoe alles wat hierboven is beschreven ons zal helpen met de code. Selecteer eerst alle modules die slechts indirect zijn gekoppeld aan het maken van de winkelwagen. Zo wordt de verantwoordelijkheid voor de modules verdeeld.
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); } }
Zo zijn ze te onderscheiden. In een groot systeem kunnen dergelijke wijzigingen natuurlijk niet in één keer worden doorgevoerd, maar ze kunnen wel geleidelijk worden doorgevoerd. Als wijzigingen bijvoorbeeld betrekking hebben op een belastingmodule, kunt u vereenvoudigen hoe andere delen van het systeem ervan afhankelijk zijn. Dit kan helpen om van hoge afhankelijkheden af te komen en het in de toekomst te gebruiken als een op zichzelf staand hulpmiddel.
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); } }
Wat de tests betreft, deze scenario's zijn voldoende. Tot dusver interesseert hun implementatie ons niet.
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() { } }
Regel 4:Voeg alleen geteste code toe
Zoals ik eerder schreef, moet u wijzigingen in de oude code minimaliseren. Hiervoor kunnen de oude en de nieuwe/aangepaste code worden opgesplitst. De nieuwe code kan in methodes worden geplaatst die met unit tests kunnen worden gecontroleerd. Deze aanpak helpt de bijbehorende risico's te verminderen. Er zijn twee technieken beschreven in het boek “Effectief werken met legacy code” (link naar het boek hieronder).
Sprout-methode/klasse - met deze techniek kunt u een zeer veilige nieuwe code in een oude insluiten. De manier waarop ik de kennisgever heb toegevoegd, is een voorbeeld van deze aanpak.
Wrap-methode - een beetje ingewikkelder, maar de essentie is hetzelfde. Het werkt niet altijd, maar alleen in gevallen waarin een nieuwe code wordt aangeroepen voor/na een oude. Bij het toewijzen van verantwoordelijkheden zijn twee calls van de ApplyTaxes methode vervangen door één call. Hiervoor was het nodig om de tweede methode te veranderen, zodat de logica niet veel kapot gaat en het kan worden gecontroleerd. Zo zag de klas eruit voor de veranderingen.
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; } }
En hier hoe het eruit ziet. De logica van het werken met de elementen van de kar veranderde een beetje, maar over het algemeen bleef alles hetzelfde. In dit geval roept de oude methode eerst een nieuwe ApplyToItems aan en vervolgens de vorige versie. Dit is de essentie van deze techniek.
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; } }
Regel 5:"Breek" verborgen afhankelijkheden
Dit is de regel over het grootste kwaad in een oude code:het gebruik van de nieuwe operator binnen de methode van een object om andere objecten, opslagplaatsen of andere complexe objecten te maken. Waarom is dat erg? De eenvoudigste verklaring is dat dit de onderdelen van het systeem sterk met elkaar verbindt en helpt om hun samenhang te verminderen. Nog korter:leidt tot schending van het principe "lage koppeling, hoge cohesie". Als je naar de andere kant kijkt, dan is deze code te moeilijk om in een aparte, onafhankelijke tool te extraheren. Het is erg arbeidsintensief om in één keer van dergelijke verborgen afhankelijkheden af te komen. Maar dit kan geleidelijk gebeuren.
Eerst moet u de initialisatie van alle afhankelijkheden overdragen aan de constructor. Dit geldt in het bijzonder voor de nieuwe operators en het maken van klassen. Als je ServiceLocator hebt om instanties van klassen te krijgen, moet je deze ook naar de constructor verwijderen, waar je alle benodigde interfaces eruit kunt halen.
Ten tweede moeten variabelen die de instantie van een extern object/repository opslaan een abstract type hebben, en beter een interface. De interface is beter omdat deze een ontwikkelaar meer mogelijkheden biedt. Als resultaat zal dit het mogelijk maken om een atoomgereedschap uit een module te maken.
Ten derde, laat geen grote methodebladen achter. Hieruit blijkt duidelijk dat de methode meer doet dan in de naam wordt genoemd. Het is ook indicatief voor een mogelijke overtreding van SOLID, de wet van Demeter.
Voorbeeld
Laten we nu eens kijken hoe de code waarmee de winkelwagen wordt gemaakt, is gewijzigd. Alleen het codeblok dat de winkelwagen maakt, bleef ongewijzigd. De rest is in externe klassen geplaatst en kan door elke implementatie worden vervangen. Nu heeft de EuropeShop-klasse de vorm van een atomaire tool die bepaalde dingen nodig heeft die expliciet in de constructor worden weergegeven. De code wordt gemakkelijker waar te nemen.
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
Regel 6:Hoe minder grote tests, hoe beter
Grote tests zijn verschillende integratietests die gebruikersscripts proberen te testen. Ze zijn ongetwijfeld belangrijk, maar het is erg duur om de logica van een of andere IF in de diepte van de code te controleren. Het schrijven van deze test kost evenveel tijd, zo niet meer, als het schrijven van de functionaliteit zelf. Het ondersteunen ervan is als een andere oude code, die moeilijk te veranderen is. Maar dit zijn slechts tests!
Het is noodzakelijk om te begrijpen welke tests nodig zijn en dit begrip duidelijk na te leven. Als je een integratiecontrole nodig hebt, schrijf dan een minimale reeks tests, inclusief scenario's voor positieve en negatieve interactie. Als je het algoritme moet testen, schrijf dan een minimale set eenheidstests.
Regel 7:Test geen privémethoden
Een privémethode kan te complex zijn of code bevatten die niet vanuit openbare methoden wordt aangeroepen. Ik ben er zeker van dat elke andere reden die je kunt bedenken een kenmerk zal blijken te zijn van een "slechte" code of ontwerp. Hoogstwaarschijnlijk moet een deel van de code van de private methode een aparte methode/klasse worden gemaakt. Controleer of het eerste principe van SOLID wordt geschonden. Dit is de eerste reden waarom het niet de moeite waard is om dit te doen. De tweede is dat je op deze manier niet het gedrag van de hele module controleert, maar hoe de module het implementeert. De interne implementatie kan veranderen ongeacht het gedrag van de module. Daarom krijg je in dit geval kwetsbare tests en het kost meer tijd dan nodig is om ze te ondersteunen.
Om te voorkomen dat u privémethoden moet testen, moet u uw klassen presenteren als een reeks atomaire hulpmiddelen en u weet niet hoe ze worden geïmplementeerd. Je verwacht een bepaald gedrag dat je aan het testen bent. Deze houding geldt ook voor lessen in het kader van de vergadering. Klassen die beschikbaar zijn voor klanten (van andere assemblages) zullen openbaar zijn, en die welke intern werk uitvoeren - privé. Hoewel, er is een verschil met methoden. Interne klassen kunnen complex zijn, zodat ze kunnen worden omgezet in interne klassen en ook kunnen worden getest.
Voorbeeld
Om bijvoorbeeld één voorwaarde in de private methode van de EuropeTaxes-klasse te testen, zal ik geen test voor deze methode schrijven. Ik verwacht dat belastingen op een bepaalde manier zullen worden toegepast, dus de test zal dit gedrag weerspiegelen. In de test telde ik handmatig wat het resultaat zou moeten zijn, nam het als standaard en verwachtte hetzelfde resultaat van de klas.
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); } }
Regel 8:Test het algoritme van methoden niet
Sommige mensen controleren het aantal oproepen van bepaalde methoden, verifiëren de oproep zelf, enz., Met andere woorden, controleren het interne werk van methoden. Het is net zo erg als het testen van de particuliere. Het verschil zit alleen in de applicatielaag van zo'n check. Deze benadering geeft opnieuw veel fragiele tests, waardoor sommige mensen TDD niet goed gebruiken.
Lees meer…
Regel 9:pas oude code niet aan zonder tests
Dit is de belangrijkste regel omdat het de wens van het team weerspiegelt om dit pad te volgen. Zonder de wens om in deze richting te gaan, heeft alles wat hierboven is gezegd geen speciale betekenis. Want als een ontwikkelaar TDD niet wil gebruiken (de betekenis niet begrijpt, de voordelen niet ziet, enz.), dan zal het echte voordeel ervan vertroebeld worden door constante discussie over hoe moeilijk en inefficiënt het is.
Als je TDD gaat gebruiken, bespreek dit dan met je team, voeg het toe aan Definition of Done en pas het toe. In het begin zal het moeilijk zijn, zoals met alles wat nieuw is. Zoals elke kunst vereist TDD constante oefening, en plezier komt als je leert. Geleidelijk aan zullen er meer schriftelijke eenheidstests zijn, u zult de "gezondheid" van uw systeem beginnen te voelen en de eenvoud van het schrijven van code beginnen te waarderen, waarbij u de vereisten in de eerste fase beschrijft. Er zijn TDD-onderzoeken uitgevoerd op echt grote projecten bij Microsoft en IBM, waaruit blijkt dat het aantal bugs in productiesystemen is verminderd van 40% naar 80% (zie de links hieronder).
Verder lezen
- Boek “Effectief werken met legacy-code” door Michael Feathers
- TDD tot aan je nek in Legacy Code
- Verborgen afhankelijkheden doorbreken
- De levenscyclus van de oude code
- Moet je privémethoden op een klas testen?
- Internals voor het testen van eenheden
- 5 veelvoorkomende misvattingen over TDD- en eenheidstests
- Wet van Demeter