Systek
Published in

Systek

Test Drevet Adventskalender

Hvert år holdes en programmerings-konkurranse i forbindelse med jula. En adventskalender med programmerings-oppgaver kan du si. Klokken 6:00 om morgenen slippes en ny oppgave og utviklere over hele verden kaster seg over PCen for å løse oppgaven raskest mulig.

Hver oppgave har en historie med et godt beskrevet eksempel. Samt en større input som brukes til den endelige løsningen. Oppgavene blir stadig mer utfordrende etter hvert som julen nærmer seg. Det jeg snakker om er selvfølgelig https://adventofcode.com/

Dette er en utmerket konkurranse for å utfordre seg selv. Det er fritt fram hvordan du ønsker å løse oppgavene — hvilken som helst teknologi er lov. Jeg anbefaler at du prøver å løse noen oppgaver og deretter ser gjennom de hundrevis av variasjonene andre har kommet opp med. Det er et aktivt Reddit samfunn med deltakere som poster løsninger, frustrasjoner, kreative visuelle eksempler og selvfølgelig, memes. Det er utrolig å se hvor mange måter en kan gå fram på. Alt fra kryptiske one-liners, til fine visuelle gjennomførte applikasjoner.

I år tenkte jeg å løse oppgavene ved å bruke test drevet utvikling i C#. Dette er ingen fasit for hvordan man skal bruke TDD, det er heller ikke nødvendigvis den optimale algoritmen for å løse oppgaven raskest mulig. Det er ingen krav til at du skal ha en optimal løsning i Advent Of Code, alle veier som leder til riktig svar er godkjent. Du kan finne alle mine løsninger her: https://github.com/jansviland/AdventOfCode

Dette er ment som et eksempel på hvordan en kan brukte TDD for å løse disse oppgavene. Denne framgangsmåten har hjulpet meg til enkelt å finne neste steg for å komme nærmere riktig løsning. Og hjulpet meg til å være 100% sikker på at alle steg er korrekt, helt til jeg er i mål. Det er en måte å utvikle på som jeg finner veldig givende og effektiv. Med feedback hele veien har du hele tiden progresjon. Når alle testene passerer kan du føle seg ganske trygg på at alt er riktig.

TDD er utmerket for denne type konkurranse siden det er gode eksempler som kan brukes til å lage unit tester.

Del 1

Jeg har valgt å bruke dag 5 som eksempel, du finner oppgaven beskrevet her: https://adventofcode.com/2022/day/5. Den starter med en beskrivelse av alver som har en rekke kasser med gaver som skal leveres:

Her er et eksempel på hvordan pakkene kan være stablet:

Hver bokstav [D], [N], [Z], [C] … representerer en kasse, og hvert tall representerer en stabel med kasser. Etter å ha fått denne beskrivelsen så får vi en rekke med instrukser, som for eksempel kan se slik ut:

Steg 1: move 1 from 2 to 1

Steg 2: move 3 from 1 to 3

Steg 3: move 2 from 2 to 1

Steg 4: move 1 from 1 to 2

Etter å ha fulgt flere av disse instruksene skal du til slutt komme opp med en endelig fordeling av kasser og kan sende inn riktig svar.

Dette ovenfor er bare et veldig enkelt eksempel. For å finne den endelige løsningen må du bruke en stor tekstfil som er unik for hver person som deltar, med rundt 500 flytteoperasjoner.

Det høres kanskje ut som en krevende oppgave. Men heldigvis med TDD så trenger vi ikke å bekymre oss for hvordan dette skal løses helt enda. Vi starter med å skrive tester og utifra oppgaveteksten er det noen åpenbare tester vi kan lage med en gang.

Vi har en tekstfil som input. Det kan vi representere med en string array, string[]. Det er også en endelig løsning på eksempeloppgaven vår. Som er en liste over de øverste pakkene fra venstre til høyre. Og gitt vår test input, så skal dette resultere i: “CMZ”.

Allerede kan vi definere vår første test.

Første Test

Jeg starter med å lagde en klasse som jeg kaller for ISolutionService. Det er en interface med kun en metode definert. Den tar en string array (vår input som du så ovenfor) og resulterer i en ny string. Resultatet (skal resultere i “CMZ”). Vi startet med at implementeringen ganske enkelt gjør “throw new NotImplementedException();”.

Deretter kan vi bruke ISolutionService til å definere en test

Det er litt oppsett her, vi lager en string array med eksempel input´en vår. Deretter bruker vi en ISolutionService til å kjøre “Part1” og den metoden skal resultere i svaret “CMZ”.

Når vi kjører testen så får vi følgende:

Dette er korrekt, i TDD så starter man alltid med en feilende test. I dette tilfelle feiler det naturligvis fordi vi ikke har implementert metoden enda.

Nå har vi allerede en test som sjekker om vi får riktig endelig svar på eksempeloppgaven. Men det er mer i oppgavebeskrivelsen som kan brukes til videre testing. Min neste tanke er at jeg vil gjøre om våre tekst strings til noe mer konkret som en List<Stack<Crate>>. Noe slikt:

Her kan vi definere en ny metode som parser strings og skal resultere i en List, med Stacks. Vi har en liste med flere Stacks, og hver Stack har kasser som jeg kaller for “Crate”. Hver kasse har et navn, det vil være en bokstav som er hentet fra inputten, [D], [N], [Z] etc.

For å teste dette kan vi lage en litt mer detaljert test case:

Test for parsing av tekst input

Vi starter med å teste at antall stacks er riktig. I vår test input så har vi 3 stacks. I den første Stacken har vi 2 elementer [Z] og [N]. I den neste har vi tre elementer [M], [C] og [D], og til slutt har vi en stack med [P].

Deretter har jeg laget en test for å sjekke at riktig Crate ligger på toppen av hver Stack. Det skal være [N], [D] og [P]. Til slutt en test case for hver enkelt Crate, hvor jeg sjekker om de finnes i riktig Stack.

Kjører vi testene våre nå ser det slik ut:

Igjen starter vi med en feilene test før vi begynner på implentasjonen. Begge metodene våre gjør kun en “throw new NotImplementedException();” siden vi ikke har begynt på implementeringen.

For å få en enda bedre forståelse av hva oppgaven går ut på, har det blitt laget flere animasjoner av løsningen. Her kan du se visuelt hva som foregår (dette er ikke eksempel input´en, men et større instruksjonssett):

Vi kan nå velge om vi har lyst å begynne å implementere “ParseInput” metoden, eller definere flere test caser. For eksempel en test case for hvordan en “flytte” operasjon skal fungere. Personlig syntes jeg det er greit å komme i gang når man først har testcaser som dekker “neste steg”. Hvis du ikke ønsker å se løsningen, kan du hoppe videre til neste test.

Her er et eksempel på hvordan man kan løse List<Stack<Crate>> ParseInput(string[] input). Jeg har prøvd å forklare detaljert hva som skjer i oppsummeringen.

Dette vil resultere i:

Wohoo! Første test er vellykket! Alle stacks og crates blir satt riktig. Dette bør fungere på hvilken som helst input, både testeksemplet og annen input som følger samme mønster. Vår første test feiler fremdeles siden vi ikke har begynt på den enda. Det er fremdeles et steg vi må gjøre ferdig før vi kan begynne på den.

Nå kan det hende at løsningen over kan gjøres på en enda bedre måte, med mindre instruksjoner. Men det som er viktig her, er at man får riktig resultat. Vi skal kun kjøre denne metoden én gang, og inputen er ganske begrenset, derfor trenger vi ikke bruke tid på optimalisering. Hadde det vært 500 millioner kasser og stacks som var beskrevet, så hadde saken vært annerledes.

For neste steg skal vi flytte X antall kasser fra en posisjon til en ny posisjon. Vi kan lage en testcase hvor vi tar en liste med Stacks og en string som inneholder en flytte instruksjon. Dette skal da resultere i en ny liste med Stacks hvor kassene blitt re-konfigurert.

Vi kan beskrive det slik:

Fra eksemplet har vi:

Vi har også eksempler på hvordan det skal se ut etter hver flytte instruksjon. Igjen så kan du lese den fulle beskrivelsen her: https://adventofcode.com/2022/day/5

Vi kan lage flere testcaser for flyttestegene basert på eksemplet beskrevet i oppgaveteksten. Etter å ha gjort en flytteoperasjon, kan vi teste at alle kasser er plassert riktig, på samme måte som vi testet at kasser var plassert riktig etter å ha parset input tekst stringene. Deretter kan vi gjøre en ny flytteoperasjon og igjen teste at alt er riktig igjen.

Etter å ha gjort ferdig dette gjenstår det bare å kjøre alle flytte-instruksjonene i rekkefølge.

Fra eksemplene har vi dette utgangspunktet:

Steg 1: move 1 from 2 to 1

Steg 2: move 3 from 1 to 3

Steg 3: move 2 from 2 to 1

Steg 4: move 1 from 1 to 2

Den endelige løsning på test eksemplet over. Når alle flytteoperasjonene er gjennomført, er de øverste kassene fra venstre til høyre, “CMZ”.

Vi kan lage testcaser hvor vi verifiserer at hvert steg resulterer i samme resultat som eksemplene viser. For eksempel:

Test for flytte operasjonene — del 1

Disse testcasene er kanskje “overkill”. Kanskje hadde det vært nok å kun teste størrelsen på hver stack, eller hvilken kasse som er øverst etter en flytteoperasjon. Med et mindre antall asserts så hadde vi kanskje fått samme resultat. Det er vanskelig å vite på forhånd hva som potensielt kan gå galt, er det en “off-by-one” feil et sted, en Exception på null i et scenario, en edge-case man ikke har tenkt på?

Hvor mange og hvor detaljerte test caser man skal ha er en balansegang. Hvor kompleks må en metode være for at man skal lage grundige test caser? Hvor triviell må en metode være for at man skal droppe å teste? Dette er et valg du må vurdere selv.

Med unit-tester så er det ofte en del repetisjon. Så å lage “grundige tester” kan være så enkelt som å kopiere en tidligere test og bytte ut et to-tall med et tre-tall, derfor er det ofte raskt og enkelt å teste grundig. Det kan se ut som mye arbeid, men i realiteten går det ganske fort å skrive mange test-caser. (Så lenge du vet hva svaret skal bli til slutt)

Testene som kjører går også vanligvis ekstremt raskt. Du kan kjøre gjennom tusenvis av testcaser på få sekunder. Det som ofte tar lang tid i utvikling er en bug du ikke finner. En bug som kanskje hadde vært oppdaget umiddelbart om du hadde hatt en ekstra assert i en testcase.

Totalt sett sparer tester deg for mye tid ettersom du kan automatisere tiden det tar å bygge, kjøre og utføre de ønskete operasjonene du jobber med. Med unit tester får du nærmest øyeblikkelig svar på om koden returnerer nøyaktig det du ønsker. Dette gjør at utviklingsarbeidet går effektivt og raskt.

Videre til oppgaven, neste steg er å implementere “MoveCrate” metoden, den kan gjøres slik, ikke så komplisert:

Og nå som vi har alle elementene som skal til, kan vi implementere RunPart1(string[] input) som vi lagde en test case for helt på starten. Vi trenger bare å parse inputten og utføre flytte-operasjonene, som vi har implementert.

Og alle test casene passerer!

Jeg lagde også en metode for å printe ut underveis, på samme måte som eksemplene viser. Når jeg kjører mot den faktiske inputen ser det slik ut:

Endelig løsning — del 1

Og etter 500 slike omrokeringer printes ut den endelige løsningen!

(Om du registerer deg på advent of code vil du få en annen input og en annen løsning)

Del 2

Dette var del 1 av oppgaven, nå som den er løst, avsløres del 2. Advent of Code har alltid to deler. Hvor man bygger videre på løsningen fra del 1 for å løse del 2.

Den nye variasjonen er at vi nå ikke plukker en pakke av gangen som vi gjorde nå, men flere på en gang. I del 2, vil resultatet se litt anderledes ut. Her er et eksempel på den nye variasjonen, vi starter med:

Flytter 2 pakker fra stack 2, til stack 1:

En liten endring fra sist, nå er [C] og [M] en en annen rekkefølge enn vi hadde tidligere. Den samme operasjonen i del 1 ville ført til at [C] ble lagt nederst først, og [M] på toppen etterpå. Denne gangen flyttes begge samtidig, og [C] og [M] beholder den rekkefølgen de hadde når de lå på toppen av Stack 2.

Den endelige løsningen for del 2 blir nå:

Med pakke [M], [C] og [D] på toppen, fra venstre til høyre. Dette kan vi skrive en ny test på. Den er nesten helt lik som den første test casen vi skrev:

Test resultat - del 2

ISolutionService blir også utvidet til:

Har også gjort om på “MoveCrate”, til “MoveCratesOneAtATime” og “MoveCratesMultipleAtATime”. Når vi kjører unit testene igjen, ser det slik ut:

Igjen så kan vi fortsette med test caser for flytteoperasjonene, med bare noen få justeringer til testene vi lagde. Vi tester resultatet etter move 2, 3 og 4. Denne gangen ser vi kun på hvilke kasser som er på toppen av hver Stack. Det bør være nok til å verifisere at hver flytte operasjon har fungert som det skal.

Tester for flytte operasjon - del 2

Når vi kjører alle våre test caser ser det slik ut:

Som alltid så starter vi med feilene testcaser først, i henhold til TDD.

Deretter er det bare å implementere “MoveCratesMultipleAtATime”. Vi kan løse den ved å kunne gjøre en liten endring til “MoveCratesOneAtATime”:

Når vi kjører alle testene nå så får vi:

Wohoo! Alt fungerer som det skal, da kan vi kjøre hovedapplikasjonen som bruker en input med 500 flytteoperasjoner. Når dette kjører en liten stund så printes det ut en ny løsning:

Endelig løsning — del 2

Her er det endelige resultatet når vi kjører det i terminalen:

Og vipps så har vi løst Part 2!

Hvor finner jeg koden?

Du kan finne alle mine Advent of Code løsninger her: https://github.com/jansviland/AdventOfCode. I løpet av året vil det forhåpentligvis ligge 24 prosjekter under 2022, med alle oppgavene fra i år. Alle ligner litt på oppgaven beskrevet her. Jeg har brukt samme framgangsmåte for alle oppgavene. Skrevet tester underveis og verifisert at alle steg går riktig for seg. Deretter kjørt mot hoved inputfilen og verifisert riktig svar. Kommer også til å lage animasjoner og andre måter å illustrere riktig løsning på.

For å finne prosjektet jeg har jobbet med for denne løsningen, naviger til 2022, day 5. Du kan kjøre testene lokalt ved å navigere til: “\adventofcode\2022\AdventOfCode.2022.Day5.Tests” og kjøre “dotnet test”. Du kan kjøre selve applikasjonen ved å navigere til “\adventofcode\2022\AdventOfCode.2022.Day5” og kjøre “dotnet run”.

Hvis du åpner solution filen i Visual Studio eller Rider vil alle prosjekter og test prosjekter dukke opp i rekkefølge.

Oppsummering

I dette innlegget har vi sett på hva TDD er og hvordan det kan brukes for å sikre at programvaren du skriver er feilfri og oppfyller kravene som er satt. Vi har sett på hvordan TDD fungerer i praksis ved at vi først skriver tester for å dekke en bestemt funksjonalitet, før man implementerer funksjonaliteten selv. Vi har sett på hvordan man kan gå fram stegvis med TDD når man får nye krav.

TDD er en viktig teknikk for utviklere å ha i sin verktøykasse, da den kan bidra til å øke kodekvaliteten og redusere antall feil i programvaren. Hvis du ikke allerede bruker TDD i din utviklingsprosess, oppfordrer jeg deg til å prøve det selv og se hvordan det kan forbedre din egen arbeidsprosess. Se gjerne på mine tester som jeg har skrevet for Advent of Code, håper det kan være en inspirasjon.

Jeg håper denne artikkelen kan være til til nytte for andre som ønsker å komme i gang med TDD. Takk for at du leste :)

Ta gjerne kontakt med oss hvis du har innspill, kommentarer, spørsmål eller er nysgjerrig på hvordan din karriere kan få et løft!

--

--

Systek er et IT-konsulentselskap med hovedsete i Oslo. Denne bloggen er et sted hvor våre konsulenter ytrer seg om hva vi brenner for innen teknologi og metode i IT-prosjekter.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store