- Tester er minst like viktig, om ikke viktigere, enn resten av koden

Pål Bøckmann om hvordan du skriver de beste testene. - Gode tester sparer bedriften masse penger senere.

Pål Bøckmann jobber som systemutvikler i Alv, og er glad i gode tester. 📸: Alv
Pål Bøckmann jobber som systemutvikler i Alv, og er glad i gode tester. 📸: Alv Vis mer

I et systemutviklingsprosjekt kan fort tester være noe som blir nedprioritert. Det tar tid å skrive gode tester, og man ser ikke umiddelbart verdien av arbeidet. Dette gjør at det ikke ser så sexy ut i økonomiregnskapet.

Likevel er tester minst like viktig, om ikke viktigere, enn resten av koden som skal gjøre jobben til programmet ditt. Sannsynligvis vil gode tester spare bedriften masse penger senere.

Målet med gode tester er å skaffe tillit til applikasjonen, at koden gjør som forventet og at du får beskjed dersom noe er galt. Verdien av gode tester blir veldig tydelig i det noen andre (eller deg selv) i fremtiden må gjøre en større endring i koden og vedkommende begynner å tenke: «Dette tør jeg ikke endre på, fordi jeg skjønner ikke hva det gjør», eller enda verre: «Det er lettere å begynne på nytt enn å endre på dette» fordi man er redd for å ødelegge noe.

Hvis man nå er så heldig at man er på et prosjekt der testing blir prioritert, hvordan skal man skrive best mulige tester og få mest mulig ut av dem?

Det skal jeg prøve å gi et par tips til i denne artikkelen.

Best practices

Hvilke prinsipper er viktig å tenke på når man skal skrive testene sine? Et godt sted å starte er de kjente FIRST-prinsippene. Det vil si:

  1. Fast (raske) Testene bør være veldig raske. Vi snakker millisekunder. I et prosjekt med tusenvis av tester må utviklerne kunne kjøre testene ofte uten at det stopper utviklingsfarten deres.
  2. Isolated (isolerte) Hver test bør være liten og teste kun én ting. Når en test feiler skal du med en gang vite akkurat hva som har gått galt og hvor. Tester skal ikke forstyrre andre tester på noen som helst måte.
  3. Repeatable (gjentagbare) Tester må kunne kjøres gjentatte ganger uten endring i resultat. De skal ikke avhenge av noe oppsett på forhånd som kan være utilgjengelig, og de skal ikke etterlate noe som gjør ny kjøring umulig.
  4. Self-validating (selv-validerende) Tester skal selv kunne bestemme om resultatet er forventet eller ikke og avgjøre selv om testen er pass eller fail. Det skal ikke være nødvendig for en tredjepart å undersøke manuelt om resultatet er gyldig eller ikke.
  5. Timely (betimelige) Tester skal skrives til rett tid, rett før koden som skal løse den aktuelle oppgaven. Også kjent som testdrevet utvikling. Noen definerer også dette punktet som «thorough», eller «grundige».

Så hvordan skal man følge disse prinsippene når man skriver testene sine? La oss starte på toppen og jobbe oss gjennom listen.

«Selv bruker jeg NCrunch til Visual Studio og skjønner ikke hvordan jeg klarte å jobbe før jeg fikk det.»

#1: Fart 💨

Først har vi hurtighet. Kanskje selvsagt, men det er noen store fordeler ved å ha raske tester.

For det første er det ingen som gidder å kjøre testene sine spesielt ofte dersom det tar flere minutter hver gang. Det skal kanskje litt til å komme opp i minutt-skalaen, men om man for eksempel benytter seg av mange eksterne systemer som databaser kan visse operasjoner fort ta litt tid. Jo sjeldnere man kjører testene sine jo lenger tid tar det før man oppdager at noe er feil.

Jeg anbefaler på det sterkeste å ha et verktøy som kjører testene dine automatisk hver gang det oppdager en endring.

Selv bruker jeg NCrunch til Visual Studio og skjønner ikke hvordan jeg klarte å jobbe før jeg fikk det. NCrunch viser også en fargeindikasjon på hver linje om det er en eller flere tester som går innom den linjen og om de er grønne eller røde. I tillegg forteller det meg hvor lang tid testen brukte på å kjøre, noe som gjør det veldig enkelt å identifisere kodesnutter som kjører tregt. Her er det nok enklere å følge regelen på enhetstester enn integrasjonstester.

Noen ganger er det kanskje nødvendig å ha en test som kjører et stort datasett gjennom hele applikasjonen din, og er dataen og applikasjonen komplisert nok kan det fort ta noen minutter. Av og til er dette dessverre ikke til å unngå.

Dersom du havner i en situasjon der du har flere trege tester som likevel er viktige å få kjørt kan det være et triks å ignorere disse testene under utvikling, og heller kjøre dem under bygg i CI/CD pipeline.

#2: Isolasjon 🙅

Videre har vi at testene skal være isolerte.

Kort forklart vil det si at testene dine skal ikke avhenge av noen eksterne faktorer. De skal ikke være avhengige av noen eksterne systemer eller annen infrastruktur for å gjøre jobben sin. Like fullt skal testene også være uavhengige av hverandre. Hver test skal gjøre sin egen setup av ressurser den trenger for å rydde opp etter seg når den har kjørt ferdig.

Grunnen til dette er at man ikke vil ende i en situasjon der man begynner å tvile på om testen har feilet på grunn av noe annet eller om koden faktisk er feil. I det man hører setningen «Bare kjør den igjen, den feiler av og til» så vet man at det er noe feil.

Dette punktet er relativt enkelt å følge for enhetstester, men kan være mer komplisert for integrasjonstester som kanskje ikke er mulige å skrive uten å inkludere en eller annen ekstern faktor.

Et unntak når fra denne regelen er for eksempel når man skal skrive sikkerhetstester. For å holde kravet om isolasjon kan dependency injection være til stor hjelp. Hvis man for eksempel har kode som skal lese fra en fil, kan dette være vanskelig å skrive tester for da er testene først er nødt til å skrive til filen koden skal lese fra. Dette er et typisk eksempel hvor testene kan påvirke hverandre ved å skrive ulik data til samme fil. En løsning kan være å implementere en slags in memory database i testene hvor man leser data fra.

En positiv sideeffekt med gode tester er at de tvinger frem bedre arkitektur og dersom man ikke klarer å gjøre testene isolert er dette ofte et tegn på at man er på vei inn i spaghettiarkitektur.

«Pass ekstra godt på når du bruker noe som DateTime.Now.»

#3: Gjentagbarhet 🎠

Neste punkt på listen er at testene skal være gjentagbare.

Med dette menes at uansett hvor mange ganger testene kjøres skal de alltid gi det samme resultatet gitt at koden er uendret. Det skal også være likegyldig hvilken rekkefølge testene kjøres i.

Tenk også på at testene skal gjøre det samme i alle miljøer. Det er kjedelig når alle testene er grønne mens du sitter og utvikler lokalt, men så feiler i deploy pipelinen din som gjør at du må gjøre endringer på en branch du trodde var ferdig og klar til å gå i produksjon.

To kjente problemer man ofte ser er enten at man har introdusert tilfeldighet, eller at man ved feilende tester lager korrupt data som gjør at man ikke lenger kan kjøre testen uten å rydde opp. Tilfeldighet kommer ofte i form av at man har lagt inn en random generator et sted. Det kan med fordel erstattes med et statisk seed.

En situasjon der gjentagbarhet kan være en utfordring er hvis man for eksempel har parallellisering av flere operasjoner som gjør at koden ikke alltid oppfører seg likt. Dette er vanskelig å unngå dersom koden krever det, og er noe å være obs på. En annen gjenganger er tester som bruker dato og tid. Pass ekstra godt på når du bruker noe som DateTime.Now at dataen som koden finner eller genererer når du skriver testen er ikke nødvendigvis lik om én måned.

#4: Selv-validering 🤖

Vi går videre til selv-validerende tester.

Hovedpoenget her er at det ikke skal være nødvendig med noe manuelt arbeid for å sjekke om resultatet av testen er ok eller ikke. Resultatet av testen skal være informasjon nok.

Dersom du må inn og sjekke hva resultatet faktisk ble, hvorfor det ble sånn, eller hva som skjedde underveis, bør du kanskje skrive om testen.

«Fokuser på å test alle brukstilfeller heller enn å oppnå 100% testdekning.»

#5: Betimelighet 🔍

Det siste punktet er definert av blant annet Robert C. Martin som “timely”, mens andre foretrekker å la T stå for “thorough", altså grundige. Vi kan utforske begge definisjoner her.

Dersom man ser på definisjonen av betimelig vil det si at testene skal skrives samtidig som man skriver produksjonskode, helst rett før koden som skal løse et aktuelt problem. Dette er standard praksis når man jobber med TDD.

Grunnen til at mange foretrekker å definere dette som “thorough”, eller grundig, er at den første definisjonen er nyttig for enhetstester, men ikke spesielt hjelpsom for andre typer tester. Med ordet grundig menes at alle stier skal testes.

Det er lett å tenke på alle happy paths ettersom det er det man vil at koden skal utføre, men det er litt mer utfordrende å forestille seg alle mulige måter en bruker eller et system kan bruke applikasjonen og koden din på. Her er det meningen å finne alle edge cases, teste for ulovlig input, potensielle sikkerhetshull og lignende.

Fokuser på å test alle brukstilfeller heller enn å oppnå 100% testdekning. Husk at høy testdekning er ingen garanti for god kodekvalitet og at 100% testdekning ofte heller har gjort det dyrere å gjøre endringer enn å faktisk ha hjulpet deg.

Kombinasjonen av høy testdekning med gode tester plassert rett sted er nøkkelen til et godt produkt.

Konklusjon

For å oppsummere så bør testene dine i størst mulig grad følge FIRST-prinsippene: De bør være raske, isolerte, gjentagbare, selv-validerende og betimelige (eventuelt nøye).

Testene bør være konsise, leselige og inneholde kun nødvendig informasjon.

Uansett om du har hørt om alt dette før, eller om du ikke har noe særlig erfaring med testing, håper jeg at du fikk noe nyttig ut av denne artikkelen, om det er helt ny kunnskap eller bare en påminnelse.

Jeg vil avslutte med å si at disse reglene og tipsene er ikke skrevet i sten. Noen ganger er det ikke fordelaktig, eller ikke en gang mulig, å følge noen av dem. Så en god porsjon med skjønn er også kjekt å ha med seg.

Likevel er dette alle gode tips som er veldig fine å følge i størst mulig grad for å gjøre jobben til de som skal vedlikeholde eller videreutvikle koden din litt lettere!