Artiklen "Sliding Responsibility of the Repository Pattern" rejste flere spørgsmål, som er meget svære at besvare. Har vi brug for et depot, hvis fuldstændig tilsidesættelse af tekniske detaljer er umulig? Hvor komplekst skal depotet være, for at dets tilføjelse kan anses for værdifuldt? Svaret på disse spørgsmål varierer afhængigt af den vægt, der lægges i udviklingen af systemer. Det nok sværeste spørgsmål er følgende:har du overhovedet brug for et depot? Problemet med "flydende abstraktion" og den voksende kompleksitet af kodning med en stigning i abstraktionsniveauet tillader ikke at finde en løsning, der ville tilfredsstille begge sider af hegnet. Inden for rapportering fører intentionsdesign f.eks. til skabelsen af et stort antal metoder for hvert filter og sortering, og en generisk løsning skaber en stor kodningsoverhead.
For at få et fuldstændigt billede så jeg på problemet med abstraktioner i form af deres anvendelse i en arvekode. Et repository er i dette tilfælde kun af interesse for os som et værktøj til at opnå kvalitet og fejlfri kode. Selvfølgelig er dette mønster ikke det eneste, der er nødvendigt for anvendelsen af TDD-praksis. Efter at have spist en skæppe salt under udviklingen af flere store projekter og set, hvad der virker og hvad der ikke gør, udviklede jeg et par regler for mig selv, der hjælper mig med at følge TDD-praksis. Jeg er åben for konstruktiv kritik og andre metoder til at implementere TDD.
Forord
Nogle vil måske bemærke, at det ikke er muligt at anvende TDD i et gammelt projekt. Der er en opfattelse af, at forskellige typer integrationstests (UI-test, end-to-end) er mere egnede til dem, fordi det er for svært at forstå den gamle kode. Du kan også høre, at det at skrive test før selve kodningen kun fører til et tab af tid, fordi vi måske ikke ved, hvordan koden vil fungere. Jeg skulle arbejde på flere projekter, hvor jeg kun var begrænset til integrationstests, idet jeg mente, at enhedstest ikke er vejledende. Samtidig blev der skrevet en masse tests, de kørte en masse tjenester osv. Som følge heraf kunne kun én person forstå dem, som faktisk skrev dem.
I løbet af min praksis nåede jeg at arbejde på flere meget store projekter, hvor der var meget legacy code. Nogle af dem indeholdt test, og de andre gjorde det ikke (der var kun en intention om at implementere dem). Jeg deltog i to store projekter, hvor jeg på en eller anden måde forsøgte at anvende TDD-tilgangen. I den indledende fase blev TDD opfattet som en Test First-udvikling. Til sidst blev forskellene mellem denne forenklede forståelse og den nuværende opfattelse, kort fortalt BDD, tydeligere. Uanset hvilket sprog der bruges, forbliver hovedpunkterne, jeg kalder dem regler, ens. Nogen kan finde paralleller mellem reglerne og andre principper for at skrive god kode.
Regel 1:Brug Bottom-Up (Inside-Out)
Denne regel henviser snarere til metoden til analyse og softwaredesign, når nye stykker kode indlejres i et arbejdsprojekt.
Når du designer et nyt projekt, er det helt naturligt at forestille dig et helt system. På dette trin styrer du både sættet af komponenter og arkitekturens fremtidige fleksibilitet. Derfor kan du skrive moduler, der nemt og intuitivt kan integreres med hinanden. En sådan Top-Down tilgang giver dig mulighed for at udføre et godt forhåndsdesign af fremtidens arkitektur, beskrive de nødvendige ledelinjer og få et komplet billede af, hvad du i sidste ende ønsker. Efter et stykke tid bliver projektet til det, man kalder arvekoden. Og så begynder det sjove.
På det tidspunkt, hvor det er nødvendigt at indlejre en ny funktionalitet i et eksisterende projekt med en masse moduler og afhængigheder imellem dem, kan det være meget svært at sætte dem alle i hovedet for at lave det rigtige design. Den anden side af dette problem er mængden af arbejde, der kræves for at udføre denne opgave. Derfor vil bottom-up-tilgangen være mere effektiv i dette tilfælde. Med andre ord opretter du først et komplet modul, der løser den nødvendige opgave, og derefter bygger du det ind i det eksisterende system, og laver kun de nødvendige ændringer. I dette tilfælde kan du garantere kvaliteten af dette modul, da det er en komplet enhed af det funktionelle.
Det skal bemærkes, at det ikke er så enkelt med tilgangene. For eksempel, når du designer en ny funktionalitet i et gammelt system, vil du, om du kan lide det eller ej, bruge begge tilgange. Under den indledende analyse skal du stadig evaluere systemet, derefter sænke det til modulniveau, implementere det og derefter gå tilbage til hele systemets niveau. Efter min mening er det vigtigste her ikke at glemme, at det nye modul skal være en komplet funktionalitet og være uafhængig, som et separat værktøj. Jo mere strengt du vil overholde denne tilgang, jo færre ændringer vil der blive foretaget i den gamle kode.
Regel 2:Test kun den ændrede kode
Når du arbejder med et gammelt projekt, er der absolut ingen grund til at skrive test for alle mulige scenarier i metoden/klassen. Desuden er du måske slet ikke opmærksom på nogle scenarier, da der kan være masser af dem. Projektet er allerede i produktion, kunden er tilfreds, så du kan slappe af. Generelt er det kun dine ændringer, der forårsager problemer i dette system. Derfor bør kun de testes.
Eksempel
Der er et online-butiksmodul, som opretter en vogn med udvalgte varer og gemmer den i en database. Vi er ligeglade med den konkrete implementering. Udført som gjort – dette er den gamle kode. Nu skal vi introducere en ny adfærd her:Send en meddelelse til regnskabsafdelingen i tilfælde af, at vognens omkostninger overstiger $1000. Her er koden vi ser. Hvordan indfører man ændringen?
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); GemTilDb(vogn); }}
Ifølge den første regel skal ændringerne være minimale og atomare. Vi er ikke interesserede i dataindlæsning, vi er ligeglade med afgiftsberegning og opsparing til databasen. Men vi er interesserede i den beregnede vogn. Hvis der var et modul, der gør det, der kræves, så ville det udføre den nødvendige opgave. Det er derfor, vi gør dette.
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); // NY FEATURE ny EuropeShopNotifier().Send(cart); GemTilDb(vogn); }}
En sådan anmelder fungerer på egen hånd, kan testes, og ændringerne i den gamle kode er minimale. Det er præcis, hvad den anden regel siger.
Regel 3:Vi tester kun krav
For at aflaste dig selv fra antallet af scenarier, der kræver test med enhedstest, tænk på, hvad du faktisk har brug for fra et modul. Skriv først for det minimumssæt af betingelser, du kan forestille dig som krav til modulet. Minimumssættet er sættet, som når det suppleres med et nyt, ændrer modulets adfærd ikke meget, og når det fjernes, virker modulet ikke. BDD-tilgangen hjælper meget i dette tilfælde.
Forestil dig også, hvordan andre klasser, der er klienter til dit modul, vil interagere med det. Skal du skrive 10 linjer kode for at konfigurere dit modul? Jo enklere kommunikationen er mellem delene af systemet, jo bedre. Derfor er det bedre at vælge moduler, der er ansvarlige for noget specifikt fra den gamle kode. SOLID vil hjælpe i dette tilfælde.
Eksempel
Lad os nu se, hvordan alt beskrevet ovenfor vil hjælpe os med koden. Først skal du vælge alle de moduler, der kun er indirekte forbundet med oprettelsen af vognen. Sådan er ansvaret for modulerne fordelt.
public class EuropeShop :Shop{ public override void CreateSale() {// 1) load from DB var items =LoadSelectedItemsFromDb(); // 2) Tax-object opretter SaleItem og // 4) går gennem varer og pålægger skatter var taxes =new EuropeTaxes(); var saleItems =items.Select(item => taxes.ApplyTaxes(item)).ToList(); // 3) opretter en indkøbskurv og 4) pålægger skatter var cart =new Cart(); cart.Add(saleItems); taxes.ApplyTaxes(cart); new EuropeShopNotifier().Send(cart); // 4) gemme til DB SaveToDb(cart); }}
På denne måde kan de skelnes. Sådanne ændringer kan naturligvis ikke foretages på én gang i et stort system, men de kan foretages gradvist. Når ændringer for eksempel vedrører et skattemodul, kan du forenkle, hvordan andre dele af systemet er afhængige af det. Dette kan hjælpe med at slippe af med høje afhængigheder og bruge det i fremtiden som et selvstændigt værktøj.
public class EuropeShop :Shop{ public override void CreateSale() {// 1) udtrukket til et lager var itemsRepository =new ItemsRepository(); var items =itemsRepository.LoadSelectedItems(); // 2) udtrukket til en mapper var saleItems =items.ConvertToSaleItems(); // 3) opretter stadig en indkøbskurv var cart =new Cart(); cart.Add(saleItems); // 4) alle rutiner til at anvende skatter udtrækkes til Tax-objektet nye EuropeTaxes().ApplyTaxes(cart); new EuropeShopNotifier().Send(cart); // 5) udtrukket til et depot itemsRepository.Save(cart); }}
Hvad angår testene, vil disse scenarier være tilstrækkelige. Indtil videre har deres implementering ikke interesseret os.
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:Tilføj kun testet kode
Som jeg skrev tidligere, bør du minimere ændringer i den gamle kode. For at gøre dette kan den gamle og den nye/modificerede kode opdeles. Den nye kode kan placeres i metoder, som kan kontrolleres ved hjælp af enhedstests. Denne tilgang vil hjælpe med at reducere de tilknyttede risici. Der er to teknikker, der er blevet beskrevet i bogen "Working Effectively with Legacy Code" (link til bogen nedenfor).
Sprout metode/klasse – denne teknik giver dig mulighed for at indlejre en meget sikker ny kode i en gammel. Den måde, jeg tilføjede anmelderen på, er et eksempel på denne tilgang.
Indpakningsmetode – lidt mere kompliceret, men essensen er den samme. Det virker ikke altid, men kun i tilfælde hvor en ny kode kaldes før/efter en gammel. Ved tildeling af ansvar blev to opkald af ApplyTaxes-metoden erstattet af et opkald. Til dette var det nødvendigt at ændre den anden metode, så logikken ikke bryder meget, og den kunne kontrolleres. Sådan så klassen ud før ændringerne.
public class EuropeTaxes :Taxes{ internal override SaleItem ApplyTaxes(Item item) { var saleItem =new SaleItem(item) { SalePrice =item.Price*1.2m }; retursalgVare; } intern tilsidesættelse void ApplyTaxes(Cart cart) { if (cart.TotalSalePrice <=300m) return; var ekskludering =30m/cart.SaleItems.Count; foreach (var vare i kurv.Udsalgsvarer) if (vare.Udsalgspris - ekskludering> 100m) vare.Udsalgspris -=ekskludering; }}
Og her hvordan det ser ud. Logikken i at arbejde med elementerne i vognen ændrede sig lidt, men generelt forblev alt det samme. I dette tilfælde kalder den gamle metode først en ny ApplyToItems og derefter dens tidligere version. Dette er essensen af denne teknik.
public class EuropeTaxes :Taxes{ intern override void ApplyTaxes(Cart cart) { ApplyToItems(cart); ApplyToCart(cart); } privat void ApplyToItems(Cart cart) { foreach (var item in cart.SaleItems) item.SalePrice =item.Price*1.2m; } privat void ApplyToCart(Cart cart) { if (cart.TotalSalePrice <=300m) return; var ekskludering =30m / cart.SaleItems.Count; foreach (var vare i kurv.Udsalgsvarer) if (vare.Udsalgspris - ekskludering> 100m) vare.Udsalgspris -=ekskludering; }}
Regel 5:"Bryd" skjulte afhængigheder
Dette er reglen om det største onde i en gammel kode:brugen af den nye operatør inde i metoden for et objekt for at skabe andre objekter, depoter eller andre komplekse objekter. Hvorfor er det slemt? Den enkleste forklaring er, at dette gør systemets dele stærkt forbundne og er med til at reducere deres sammenhæng. Endnu kortere:fører til overtrædelse af princippet om "lav kobling, høj sammenhæng". Hvis du ser på den anden side, så er denne kode for svær at udtrække til et separat, uafhængigt værktøj. At slippe af med sådanne skjulte afhængigheder på én gang er meget besværligt. Men dette kan gøres gradvist.
Først skal du overføre initialiseringen af alle afhængigheder til konstruktøren. Dette gælder især den nye operatører og oprettelse af klasser. Hvis du har ServiceLocator til at hente forekomster af klasser, bør du også fjerne den til konstruktøren, hvor du kan trække alle de nødvendige grænseflader ud fra den.
For det andet skal variabler, der gemmer forekomsten af et eksternt objekt/depot, have en abstrakt type og bedre en grænseflade. Grænsefladen er bedre, fordi den giver en udvikler flere muligheder. Som et resultat vil dette gøre det muligt at lave et atomværktøj ud af et modul.
For det tredje skal du ikke efterlade store metodeark. Dette viser tydeligt, at metoden gør mere, end den er angivet i dens navn. Det er også tegn på en mulig overtrædelse af SOLID, Demeterloven.
Eksempel
Lad os nu se, hvordan koden, der opretter kurven, er blevet ændret. Kun kodeblokken, der skaber vognen, forblev uændret. Resten blev placeret i eksterne klasser og kan erstattes af enhver implementering. Nu tager EuropeShop-klassen form af et atomværktøj, der har brug for visse ting, der er eksplicit repræsenteret i konstruktøren. Koden bliver lettere at opfatte.
Regel 6:Jo færre store test, jo bedre
Store tests er forskellige integrationstest, der forsøger at teste brugerscripts. Uden tvivl er de vigtige, men det er meget dyrt at kontrollere logikken i nogle IF i dybden af koden. At skrive denne test tager samme tid, hvis ikke mere, som at skrive selve funktionaliteten. At støtte dem er som en anden gammel kode, som er svær at ændre. Men det er kun tests!
Det er nødvendigt at forstå, hvilke tests der er nødvendige og klart overholde denne forståelse. Hvis du har brug for et integrationstjek, skal du skrive et minimumssæt af tests, inklusive positive og negative interaktionsscenarier. Hvis du har brug for at teste algoritmen, skal du skrive et minimalt sæt enhedstests.
Regel 7:Test ikke private metoder
En privat metode kan være for kompleks eller indeholde kode, der ikke kaldes fra offentlige metoder. Jeg er sikker på, at enhver anden grund, du kan komme i tanke om, vil vise sig at være karakteristisk for en "dårlig" kode eller design. Mest sandsynligt bør en del af koden fra den private metode gøres til en separat metode/klasse. Tjek om det første princip i SOLID er overtrådt. Dette er den første grund til, at det ikke er værd at gøre det. Det andet er, at man på denne måde ikke tjekker opførselen af hele modulet, men hvordan modulet implementerer det. Den interne implementering kan ændre sig uanset modulets adfærd. Derfor får du i dette tilfælde skrøbelige tests, og det tager mere tid end nødvendigt at understøtte dem.
For at undgå behovet for at teste private metoder skal du præsentere dine klasser som et sæt atomare værktøjer, og du ved ikke, hvordan de implementeres. Du forventer en adfærd, som du tester. Denne holdning gælder også for klasser i forbindelse med forsamlingen. Klasser, der er tilgængelige for kunder (fra andre forsamlinger) vil være offentlige, og dem, der udfører internt arbejde - private. Selvom der er forskel på metoder. Interne klasser kan være komplekse, så de kan transformeres til interne og også testes.
Eksempel
For for eksempel at teste en betingelse i den private metode i EuropeTaxes-klassen, vil jeg ikke skrive en test for denne metode. Jeg vil forvente, at skatter vil blive anvendt på en bestemt måde, så testen vil afspejle netop denne adfærd. I testen talte jeg manuelt, hvad der skulle være resultatet, tog det som standard og forventer det samme resultat fra klassen.
public class EuropeTaxes :Taxes{ // kode sprunget over privat void ApplyToCart(Cart cart) { if (cart.TotalSalePrice <=300m) return; // <<100m) vare.Udsalgspris -=ekskludering; }}// test suitepublic class EuropeTaxesTests{// kode sprunget over [Fakta] public void Should_apply_taxes_to_cart_greater_300() { #region arrangement // list of items which will create a cart greater 300 var saleItems =new List - (new[]{ ny Vare {Pris =83,34m}, ny Vare {Pris =83,34m},ny Vare {Pris =83,34m}}) .ConvertToSaleItems(); var cart =new Cart(); cart.Add(saleItems); const decimal forventet =83,34m*3*1,2m; #endregion // act new EuropeTaxes().ApplyTaxes(cart); // hævde Assert.Equal(expected, cart.TotalSalePrice); }}
Regel 8:Test ikke metodernes algoritme
Nogle mennesker tjekker antallet af opkald af visse metoder, verificerer selve opkaldet osv., med andre ord kontrollerer det interne arbejde med metoder. Det er lige så slemt som at teste de private. Forskellen er kun i applikationslaget for en sådan kontrol. Denne tilgang giver igen en masse skrøbelige tests, så nogle mennesker tager ikke TDD ordentligt.
Læs mere...
Regel 9:Ændre ikke ældre kode uden test
Dette er den vigtigste regel, fordi den afspejler et teams ønske om at følge denne vej. Uden ønsket om at bevæge sig i denne retning har alt, hvad der er blevet sagt ovenfor, ingen særlig betydning. For hvis en udvikler ikke ønsker at bruge TDD (ikke forstår dens betydning, ikke ser fordelene osv.), så vil dens reelle fordel blive sløret af konstant diskussion, hvor svært og ineffektivt det er.
Hvis du vil bruge TDD, skal du diskutere dette med dit team, tilføje det til Definition of Done og anvende det. I starten vil det være svært, som med alt nyt. Som enhver kunst kræver TDD konstant øvelse, og fornøjelsen kommer, mens du lærer. Gradvist vil der være flere skriftlige enhedstests, du vil begynde at mærke dit systems "sundhed" og begynde at sætte pris på enkelheden ved at skrive kode, der beskriver kravene i første fase. Der er TDD-undersøgelser udført på virkelige store projekter i Microsoft og IBM, som viser en reduktion af fejl i produktionssystemer fra 40 % til 80 % (se nedenstående links).
Yderligere læsning
- Bog "Working Effectively with Legacy Code" af Michael Feathers
- TDD når du er oppe på halsen i Legacy Code
- Brydning af skjulte afhængigheder
- Den ældre kodes livscyklus
- Skal du enhedsteste private metoder på en klasse?
- Indbygget enhedstest
- 5 almindelige misforståelser om TDD og enhedstests
- Demeterloven