7 mars 2025 (updated: 7 mars 2025)
Chapters
Programmerne bruker TDD for å ta vare på kvaliteten og påliteligheten til koden de lager og for å forbedre prosessen med å lage den. Imidlertid er de ikke alltid oppmerksomme på at selve skrivingen av tester kan være mer eller mindre optimal.
TDD (Test Driven Development) forutsetter at du begynner å skrive kode ved å lage tester som oppfyller kravene til forretningslogikken og deretter lager implementeringene deres i applikasjonskoden. Denne antagelsen virker enkel, men for at denne tilnærmingen skal gi så mange fordeler som mulig og optimalisere i stedet for å hindre prosessen, bør du følge reglene og tenke på ting som hva du faktisk ønsker å teste, hva du krever av en gitt funksjonalitet eller i hvilken rekkefølge du vil skrive de enkelte kodefragmentene.
I denne artikkelen vil jeg lede deg gjennom å lage en algoritme for den såkalte Game of Life, ved å bruke gode TDD-praksiser.
Livets Spill ble oppfunnet for over et halvt århundre siden av den britiske matematikeren John Conway, og reglene er ganske enkle.
Spillet spilles på et brett, som er et rutenett bestående av et hvilket som helst antall ruter, som vi vil kalle celler. Hver celle kan være levende (fylt med farge) eller død (tom). Cellene som omgir den er dens naboer. Under spillet, mens "livet går videre", skjer såkalte ticks - cellene går over til sin neste tilstand i henhold til følgende regler:
Spillerens rolle er å designe cellejusteringen før den første tick.
Nedenfor er et eksempel som illustrerer forløpet av spillet på et eksempelbrett.
I vår implementering vil tilstanden til brettet vårt bli holdt i en tabell av tabeller, der indeksene til hovedtabellen vil reflektere radene på brettet, og indeksene til under-tabellene i den vil reflektere kolonnene (spesifikke celler) på brettet.
Tester trenger ikke (og bør ofte ikke) skrives i den rekkefølgen programmet vil kjøre. Ved å bruke Game of Life-eksemplet, kan du tenke at hvis det første brukeren gjør i applikasjonen er å velge størrelsen på brettet (eller hvis det ikke er et slikt alternativ, så bare vise det), så ville det være passende å teste slike funksjonaliteter først. I TDD, derimot, starter vi med selve forretningslogikken, som er avgjørende for driften av applikasjonen.
Det første steget vil være en grundig analyse av forretningslogikken og planlegging av de innledende små trinnene for å lage en applikasjon. Det er også verdt å lage generelle antakelser om applikasjonen før vi skriver tester (som bruken av et array av arrays), som vil lede testene våre og implementeringene i riktig retning. På hvert steg bør man vurdere hva det faktisk består av.
Ser vi på testingsiden, bør vi tenke nøye over hva som bør testes helt i begynnelsen. I applikasjonen vår jobber vi med et brett av hvilken som helst størrelse. Så la oss vurdere følgende spørsmål:
Etter refleksjon kan vi komme til konklusjonen om at forretningslogikken for både 1x1, 5x5, 20x20-tabellen og hvilken som helst annen størrelse kan lukkes i en 3x3-tabell, fordi den inkluderer tilfellene for hver celle, uavhengig av dens plassering og antall naboer. 3x3-brettet vil inneholde den midterste cellen med maksimalt 8 naboer, samt celler på kantene og hjørnene av brettet.
I TDD bygger vi koden i små trinn, basert på rød-grønn-refaktor-mønsteret. Dette kan være vanskelig, spesielt hvis vi allerede kjenner driften av hele applikasjonen og vi kanskje tror at det vil være enklere å skrive en test som dekker litt mer komplekse funksjonaliteter. Men det er verdt å holde seg til TDD-forutsetningene hvis vi ønsker å dra nytte av fordelene, som beskyttelse mot å skrive overflødig kode.
I den røde fasen skriver vi alltid en test som ikke skal bestås, noe som kan være ubehagelig i begynnelsen. Testen bør også være så enkel som mulig og inneholde bare den nødvendige koden. Når vi skriver den, er det lurt å ha en plan for videre tester i bakhodet. La oss også være oppmerksomme på navnet, som bør være presist nok til at vi potensielt vet umiddelbart hvilken test som har feilet.
Vi bestemmer at den første testen av applikasjonen vår vil sjekke om den tomme tabellen etter "tick" forblir tom. Vi gjør også en antagelse om at implementeringen vil opprette Board-klassen, og ta den nåværende tilstanden til brettet som et parameter. Vi kan deretter gå videre til å skrive vår første test (vi vil bruke Jest for testing):
I den grønne fasen implementerer vi en gitt funksjonalitet på den enkleste mulige måten - det trenger ikke å være perfekt kode ennå, så vi legger ikke merke til detaljene ennå. Vi skriver det slik at det ikke løper fremover og dekker hva som vil skje i de senere stadiene av applikasjonsutviklingen. Det er også verdt å huske at en godt skrevet test kan ha mange forskjellige implementeringer.
I den grønne fasen sjekker alle andre allerede skrevne tester om endringer på et gitt sted har forårsaket at feilen oppstår et annet sted.
Selv om implementeringen av koden for vår første test vil være veldig enkel, er det allerede på dette stadiet verdt å vurdere hva våre neste steg vil være og hvilken innvirkning denne løsningen vil ha på dem.
Vurder eksemplet nedenfor der vi endelig oppretter en Board klasse som har en tick metode som tar cellene på brettet til neste livsstadium:
Neste steg kan være å sjekke tilfellet der vi starter spillet med én levende celle, som skal dø etter en tick. Så la oss skrive den andre testen:
Teoretisk sett er alt i orden, testen består, så den forrige implementeringen ser ut til å være fin. Imidlertid, i konteksten av TDD, er det dessverre ikke tilfelle. Vår andre test er nå vellykket, selv om vi ikke har gjort noen implementeringsendringer etter å ha skrevet den. Dette betyr at koden vår dekker litt for mye funksjonalitet, og vi bør tilpasse den slik at den relaterer seg så presist som mulig bare til testtilfellet under utvikling (den første testen med et tomt brett). En bedre løsning her vil være som nedenfor:
Med denne implementeringen består vår første test, men den andre feiler.
Og nå er det endelig på tide å introdusere en implementering der den andre testen består, og dette kan være den samme der tick-metoden returnerer et tomt array. Å gå tilbake på denne måten kan virke som et tidkrevende arbeid, fordi vi til slutt skriver den samme implementeringen uansett. Imidlertid inkluderer det en dyp forståelse av hvordan koden vår fungerer og overholder antakelsene til TDD. Se på det slik: hvis vi hadde planlagt den neste testen på forhånd (som TDD sier), kunne vi ha unngått situasjoner av denne typen, fordi vi ville hatt det i bakhodet når vi skrev vårt første implementeringsfragment.
I refaktorering fasen, rydder vi opp i den eksisterende koden og tilpasser den til den nåværende tilstanden til programmet. Vi kan gjøre endringer i hele den eksisterende koden, uten å bekymre oss for å ødelegge noe - vi har endelig tester for hver linje med kode skrevet.
Deretter starter vi syklusen fra begynnelsen til vi oppnår målet om full antatt drift av applikasjonen vår.
Endelig er det på tide å teste noen av de mer kompliserte oppstillingene av celler på brettet enn bare én levende celle som dør etter et tick. Beslutningen om vi skal sjekke driften av algoritmen separat for hver type celleplassering på brettet, eller om vi skal teste hele brettet på en gang, er et spørsmål om programmererens preferanser. Vi må bestemme om saken til hver celle er så spesiell for oss at det er verdt å skrive 9 individuelle tester og deres implementeringer, én for hver av dem. I eksemplet nedenfor har vi bestemt oss for å teste hele brettet samtidig. Til dette formålet vil vi teste noen forskjellige eksempelbrett før og etter tick:
Ok, vi har en test (som selvfølgelig ikke er bestått), så det er på tide å skrive implementeringen til den… Men hvor skal vi starte? La oss se hva vi virkelig trenger å kode her. For at den ovennevnte testen skal bestå, må vi:
Oops… det ser ut som MYE arbeid å gjøre i bare ett trinn av vår TDD-prosess. Nå går vi inn i stadiet med den mer avanserte forretningslogikken i applikasjonen vår og prøver å dekke et mye mer komplekst tilfelle enn før. Vil vi gjøre alle disse tingene på en gang? Eller kanskje vi burde revurdere handlingsplanen vår? La oss tenke: vil vi definitivt fortsette å teste hele brettet på dette tidspunktet? Eller kanskje er det en måte å redusere kompleksiteten til testene og koden selv? Var den opprinnelige planen vår så god for sikkert?
Hvis vi tenker på det, i stedet for å teste oppførselen til alle cellene på brettet etter hvert tick, kunne vi først fokusere på hva som skjer med en enkelt, spesifikk celle. Dette forenkler testtilfellene og deres implementeringer betydelig. Så la oss ikke være redde for å starte på nytt. Ja - helt fra begynnelsen! I motsetning til hva som kan se ut til, kan det spare oss for mye tid. Husk: det er ikke verdt å gå inn i noe som viser seg å ikke være en god løsning selv etter lang tid med arbeid på det.
Det er et godt øyeblikk å bruke den nye .failing-funksjonen til Jest. Ved å bruke den kan vi få en test som faktisk krasjer, til å bestå. Det er nyttig i situasjoner der vi ønsker at en gitt test ikke skal bestå i en periode, men senere skal lykkes. Vi kan bare la testen vår være i testkoden uten å bruke .skip-funksjonen, slik at vi ikke glemmer den i fremtiden - vi vil se nøyaktig når den progressive implementeringen av koden vår vil dekke tilfellet fra denne nøyaktige testen - den vil bare krasje da. Så la oss bruke den:
.failing-metoden fungerer ikke sammen med .each-metoden (og dette er en bevisst intensjon fra skaperen, da det å skrive mange mislykkede tester på en gang i én test ville gå litt glipp av målet med denne funksjonen), så hvert tilfelle vil bli testet i en separat test. Husk at for at .failing-funksjonen skal fungere, må du bruke minst versjon 28.1.0 av Jest og versjon 28.0.1 av ts-jest. Nedenfor er et eksempel på riktige devDependencies i package.json:
Som du kan se, er de ovennevnte testene bestått:
Nå er det på tide å finne ut hvilke faktorer som bestemmer skjebnen til en enkelt celle etter hvert tick. Det er 2 ting her: om cellen for øyeblikket er levende eller død, og hvor mange naboer den for øyeblikket har. Vi må vurdere hvordan vi skal planlegge testene. La oss gå tilbake til spillereglene:
Hvor mange testtilfeller gir dette oss? Må vi teste hver av reglene separat? Eller kanskje de kunne på en eller annen måte kombineres med hverandre? Etter en litt mer nøye analyse av reglene kan vi fastslå at:
Og det er det. 3 testtilfeller er nok til å dekke hele forretningslogikken til en enkelt celles livssyklus. Det eneste som gjenstår nå er å bestemme rekkefølgen på testene, der hver påfølgende ikke vil bestå i utgangspunktet. Hvis, for eksempel, vi begynte med det tredje kravet, der en levende celle med 2 naboer overlever og ellers ikke, ville vi også dekke det første kravet med denne implementeringen, noe vi ikke ønsker. Rekkefølgen de er listet opp i ovenfor forhindrer dette fra å skje. I den følgende testen antok vi at vi ville opprette en klasse Cell som inneholder 2 parametere i konstruktøren: tilstanden til cellen (levende - 1 eller død - 0) og antallet av dens levende naboer. Den første testen mislykkes selvfølgelig, fordi klassen Cell ikke engang eksisterer på dette stadiet.
Når du skriver implementeringen, husk at vi ønsker at den ikke skal dekke funksjonaliteten vi ønsker å teste i de følgende testene. Et eksempel på en klasse som inneholder en metode for å sette tilstanden til cellen under test etter "tick" ville se slik ut:
For det andre kravet skriver vi en annen, analog, opprinnelig mislykket test:
Og en implementering som endrer tick-metoden:
Og til slutt testen for det tredje kravet:
Med implementeringen som endrer tick-metoden:
På dette punktet er behovet for å øke lesbarheten av koden tydelig, så nå går vi til refaktoreringen, og bestemmer oss for å opprette flere metoder i Cell-klassen:
Vi må bruke metodene som Cell-klassen gir oss til brettet, som vi vil beskrive (som før, før vi endrer handlingsplanen) med Board-klassen.
Det er virkelig verdt å strukturere testene våre litt. I tillegg til å samle dem alle i en "describe", f.eks. "Game of Life", ville det også vært bra å bruke nestede describes her for "Cell" og "Board" klassene. Vi gjør slike forbedringer i en av refaktoreringfasene (jo tidligere, jo bedre). Som et resultat vil det se noe slik ut:
Nå som vi vet at algoritmen som er ansvarlig for driften av en enkelt celle fungerer korrekt, må vi sjekke om vi vil legge inn de riktige dataene i konstruktøren til Cell-klassen for hver celle på brettet.
Før vi startet vår tilnærming til logikken for en enkelt celle, skrev vi allerede testen vi trenger nå - den der vi brukte .failing-funksjonen. Nå vil implementeringen være mye mindre komplisert ettersom vi allerede har klart å dekke ting som å referere til om cellen er levende eller død, hvor mange naboer den har, og hvordan den vil oppføre seg etter et tick. Det eneste som gjenstår er å overføre denne logikken til hele brettet og bruke tick-metoden til Cell-klassen for hvert felt av det.
Vår implementering etter den grønne og refaktoreringfasen ser slik ut. I dette eksemplet brukte vi det såkalte null objektmønsteret, som vi ikke vil beskrive nærmere her, men kort sagt, det er et designmønster som får en død enhet til å oppføre seg som en tom enhet, slik at det ikke er nødvendig å skille mellom dem, og dette igjen lar deg unngå å sjekke om en gitt verdi er forskjellig fra null. Husk imidlertid at en god test lar deg bruke hvilken som helst implementering som skiller seg fra hverandre, så den nedenfor er ikke den eneste riktige:
På dette punktet kan vi se at testene våre for hele brettet endelig har feilet:
Nå må vi bare fjerne .failing-metoden fra alle testene ovenfor. De viser seg alle å bestå, så vi kan trygt si at vi har fullført jobben vår med suksess.
TDD gir oss primært muligheten til å skrive kode som fungerer godt, bit for bit, og kontrollere den gjennom utviklings- og refaktoreringsprosessen, takket være 100% dekning av koden.
TDD endrer også tilnærmingen til programmering i seg selv. Når vi bruker det, kjemper vi primært med å dekke forretningsantakelsene og ikke med problemer som oppstår fra koden selv. Dette gir oss en sjanse (eller til og med tvinger oss) til en grundig analyse av kravene og konstant verifisering av dem under applikasjonsutviklingsprosessen, som, som vist i eksemplet med Game of Life ovenfor, kan redde deg fra å skrive mye overflødig kode.
Selv om TDD fortsatt ikke overbeviser alle og noen ganger blir ansett som et tidssløseri blant mange utviklere som ikke har brukt det før, er det verdt - for å si det på en moderne måte - å komme ut av komfortsonen og prøve å anvende det i ditt daglige arbeid, fordi de innledende plagene ved å bruke det til slutt vil bli til mer effektivt arbeid, bedre kodekvalitet, og mye mindre frustrasjon med å lete etter feil i koden din.
11 mars 2025 • Maria Pradiuszyk