Programvaretesting

Vi tester programvare for å finne feil og demonstrere kvalitet. Aktiviteten er helt nødvendig for å kunne svare på om systemet vi utvikler oppfører seg som forventet og tilfredstiller de kravene som er satt. Vi må evaluere både om vi har utviklet programvaren rett og om vi har utviklet rett programvare. Testing er derfor en kjerneaktivitet i all systemutvikling.

Ulike tilnærminger

Vi kan tilnærme oss testingen på ulike måter. Hvilken angrepsmåte som er riktig er avhengig av hvem som skal utføre testingen og hvor god kjennskap vi har til den interne strukturen i programmet.

Black-box vs white-box

Black-box-testing behandler systemet som en sort boks hvor vi ikke har kunnskap om programkode eller intern struktur. Vi vet hva systemet skal gjøre, men ikke hvordan det skjer. Målet er å sjekke om systemet oppfører seg som det skal ved å sammenlikne forventet resultat med faktisk resultat. Det er altså funksjonaliteten til systemet som testes. Black-box-testing baserer seg vanligvis på spesifiserte krav, hvor man utvikler test caser med utgangspunkt i f.eks. use caser eller brukerhistorier.

Black-box-testing

Figur 1: Med black-box-testing har vi ingen kunnskap om intern struktur

White-box-testing forholder seg til systemet som en gjennomsiktig boks hvor testeren har inngående kjennskap til kildekoden. Kunnskap om systemets algoritmer og datastrukturer gjør det lettere å vite hvilken input vi skal bruke for å kontrollere de ulike stiene gjennom programkoden. Med white-box-testing kan vi teste på ulike nivåer, fra kontrollflyt og enkeltfunksjoner til grensesnitt og krav. En tilnærming hvor man kombinerer kunnskap om hvordan systemet fungerer (white-box) med et fokus på hva systemet skal gjøre (black-box) kalles ofte gray-box-testing.

White-box-testing

Figur 2: White-box-testing betyr inngående kjennskap til kildekode og struktur

Manuell vs automatisert

Når en person må utføre testingen uten bruk av automatiseringsverktøy sier vi at den er manuell. Manuell testing er ofte nødvendig når man skal teste større, mer komplekse deler av systemet. Prosessen kan være fri og utforskende eller basere seg på en formell testplan. Med sistnevnte vil testeren ta rollen som sluttbruker og utfører et sett med test caser. Et test case knyttes vanligvis mot et spesifisert krav og definerer en rekke steg som testeren må utføre. For hvert steg sjekkes faktisk resultat mot forventet resultat. Et test case for å logge inn på Blackboard kan f.eks. se slik ut:

TC-1: Logge inn på Blacboard Learn (uten 2FA). Beskrivelse: En ansatt eller student ved NTNU skal kunne logge seg inn på Blackboard Learn via en nettleser. Forutsetninger: Den ansatte/studenten har en dedikert brukerkonto ved NTNU.

StegAktivitetForventet resultatFaktisk resultatStatus
1Åpne en nettleser og gå til ntnu.blackboard.com.Innloggingssiden til BB Learn vises med knappen "Logg på (Feide)".-Ok
2Klikk på knappen "Logg på (Feide)".Side med skjema for innlogging vises. Skjemaet har felter for brukernavn og passord, og en knapp med teksten "Logg inn" .-Ok
3Skriv inn brukernavnet "testbruker" og passord "testpassord", og klikk på knappen "Logg inn".Du er logget inn på Blacboard Learn.Innloggingssiden viser følgende: "Innlogging feilet".Feil
Tabell 1: Eksempel på et test case

Mens manuelle tester utføres av mennesker vil automatiserte tester defineres i kode eller skript og utføres av en maskin. Testene varierer i kompleksitet, fra å verifisere én enkelt funksjon til mer avanserte interaksjoner. Automatisert testing er en forutsetning for kontinuerlig integrasjon (CI) og kontinuerlig utrulling/leveranse (CD).

Tradisjonell vs smidig

Når vi snakker om tradisjonell systemutvikling tenker vi gjerne på fossefallsmodellen. Her deles arbeidet inn i distinkte faser, som gjennomføres i streng sekvens. Systemet blir formelt testet av en uavhengig testgruppe først etter at alt er implementert, som vist i figur 3:

Fossefallsmodellen

Figur 3: Tradisjonell tilnærming med fossefallsmodellen

I den smidige modellen er testingen en kontinuerlig aktivitet. Testingen er faktisk så sentral at den driver resten av utviklingen. Vi kaller dette for Test Driven Development. Med TDD skriver vi testen først, deretter implementere vi koden som trengs for å tilfredsstille den. Prosessen er iterativ, som betyr at test og funksjon utvikles om hverandre i flere runder:

TDD

Figur 4: Test-først med TDD

TDD gir oss flere fordeler:

Smidig testing dikterer altså at utviklerne skal være involvert i testingen fra første stund, mens den tradisjonelle modellen forteller oss at testene skal utføres av en uavhengig gruppe når alt er ferdig utviklet. Vi kan trekke lærdommer fra begge tilnærmingene. Vi bør begynne å teste så tidlig som mulig, og de som utvikler har en viktig rolle i kvalitetssikringen av systemet. Samtidig er det lurt å involvere eksterne i form av en kunderepresentant eller domeneekspert. Disse er ofte ikke interessert i systemspesifikke detaljer, og blir derfor involvert senere i utviklingsløpet.

Testnivåer

Vi kan også kategorisere testene etter hvor spesifikke de er, eller når de legges til i utviklingsløpet. Det er vanlig å operere med fire distinkte nivåer: enhetstesting, integrasjonstesting, systemtesting og akseptansetesting.

Enhetstesting

Vi finner enhetstestene på det første nivået fordi de er mest spesifikke og lages av utviklere mens de jobber med koden. En enhetstest tester en konkret funksjon, klasse eller komponent i systemet. Vi kjører dem som en del av en automatisert prosess, gjerne når vi kompilerer og bygger eller sjekker inn kode i et versjonskontrollsystem. Enhetstesting er en naturlig del av en CI-prosess. Det finnes rammeverk for å utvikle slike tester, f.eks. JUnit for Java, PyUnit for Python eller Jest for Javascript.

Integrasjonstesting

Når vi skal verifisere samspillet mellom de ulike modulene og tjenestene i applikasjonen tyr vi til integrasjonstester. Nå fokuserer vi på grensesnitt og interaksjon mellom komponenter, hvor en komponent kan være en bit av systemet vi selv utvikler eller del av en ekstern tjeneste som systemet er avhengig av. En test kan f.eks. verifisere integrasjonen mellom en login-modul og en autorisasjonstjeneste, en annen samhandlingen mellom persisteringslaget i applikasjonen og databasen.

Systemtesting

En systemtest skal evaluere om systemet virker i henhold til de krav som er satt. Dette gjelder både funksjonelle og ikke-funksjonelle krav. Funksjonelle krav testes ofte manuelt, men kan automatiseres ved å gjengi menneske-maskin-interaksjon i kode. Rammeverk som Selenium, Cypress og Playwright kan benyttes for å automatisere funksjonelle tester i webapplikasjoner. Ikke-funksjonelle krav kan vurderes blant annet gjennom brukertesting, ytelsestesting og sikkerhetstesting.

Akseptansetesting

Akseptansetesting er en formell prosess hvor en autoritet avgjør om systemet tilfredstiller kriteriene som er satt for godkjenning. Autoriteten er vanligvis representert ved systemeier, og kriteriene er definert i en kravspesifikasjon eller som brukerhistorier med tilhørende akseptkriterier. En godkjent akseptansetest er en form for validering, og medfører som regel at systemet kan produksjonssettes.

Prinsipper

Når vi lager tester er det visse prinsipper som bør følges. Disse er særlig relevante når vi utarbeider enhetstester, men gjelder også for annen type testvirksomhet.

Arrange-Act-Assert

En test følger vanligvis et bestemt mønster kalt Arrange-Act-Assert (AAA):

  1. Arrange: Forbered testdata, variabler og oppsett som testen trenger.

  2. Act: Kjør testen.

  3. Assert: Verifiser resultatet.

Vi kan verifisere både tilstand og oppførsel:

Positiv og negativ testing

Positiv testing går utifra at systemet fungerer som forventet. Vi baserer oss på korrekt og valid input og tester for vellykkede tilfeller. Men vi bør også verifisere at systemet håndterer feiltilfeller i form av uønsket input og uforutsigbar oppførsel. Dette kalles negativ testing, og er viktig for å sikre et robust og stabilt system. Vi bør derfor utarbeide både positive og negative tester.

Nøyaktighet og presisjon

En test bør være både nøyaktig og presis. La oss si at vi har en liste med tall (eksempelet er hentet fra boka 97 Things Every Programmer Should Know):

3 1 4 1 5 9

Vi ønsker å sortere listen i stigende rekkefølge og implementerer en funksjon for dette. Når vi skal teste funksjonen er det naturlig å sjekke at at det som returneres er en liste med tall i stigende rekkefølge. Vi kan også sjekke at resultatet inneholder nøyaktig like mange elementer som lista vi startet med. Men er dette nok? Tenk deg at algoritmen vår har en feil som fyller hele resultatlisten med det første tallet fra den opprinnelige listen. Da ender vi opp med følgende resultat:

3 3 3 3 3 3

Listen tilfredstiller kravene i testen vår, men det er åpenbart ikke dette vi er ute etter. I vårt tilfelle er det kun ett resultat som er riktig:

1 1 3 4 5 9

Testen må være både nøyaktig og presis: vi må sjekke at listen er sortert og at den holder en permutasjon av de opprinnelige verdiene.

Dokumentasjon

En god test bidrar til å dokumentere koden den tester. Den viser hvordan koden virker ved å:

Skriv testene slik at de som prøver å forstå koden får en lettere jobb. Når vi lager enhetstester bør vi blant annet bruke meningsfulle og beskrivende navn, og vi bør samle tester som logisk hører sammen.

80/20-regelen

Testdekning er et mål på hvor mye av kildekoden som testes. Det kan være fristende å ha 100% dekning som mål. I praksis er dette svært vanskelig, fordi det betyr at vi må sjekke alle mulige kombinasjoner av input, betingelser og stier i koden vår. Vi må også ta hensyn til at testutvikling er en tidkrevende og kostbar aktivitet. 80/20-regelen (Pareto-prinsippet) forteller oss at 80% av alle feil kan knyttes til 20% av kodebasen. Feil har en tendens til å samles rundt et lite subsett av moduler og funksjoner i koden vår. Det betyr at vi ikke trenger å teste absolutt alt for å sikre tilstrekkelig kvalitet. Dessverre må vi fortsatt identifisere den delen av koden som faktisk produserer feilene.

80/20-regelen gjør ikke nødvendigvis testutviklingen lettere, men den sier noe om hvor vi bør rette blikket. Det handler ikke om hvor mange tester vi har, men om kvaliteten på testene. Fem tester som i praksis verifiserer det samme gir liten verdi, selv om det øker testdekningen. Siden målet er å finne riktig nivå på testingen mer enn å oppnå full testdekning, må vi være ekstra kritiske til hva vi faktisk velger å teste. Det kan være lurt å ta utgangspunkt i kode som kalles ofte fra andre moduler, eller som er koblet direkte mot forretningskritisk funksjonalitet. En feil her kan gi store negative konsekvenser for resten av systemet.

Forutsigbarhet

Testene våre er avhengige av et forutsigbart testmiljø. Vi må kunne garantere repeterbarhet og at testene produserer det samme resultatet hver gang de kjører (presisjon). Det forutsetter at vi har et testdatasett som ser likt ut før hver gjennomkjøring. Det er flere måter å oppnå dette på:

Det kan være fristende å kopiere reelle data fra et produksjonssystem inn i en testdatabase. Det gir oss gode testdata, men er problematisk hvis databasen inneholder personopplysninger eller annen sensitiv informasjon. I slike tilfeller er det tryggest å basere seg på fiktive data, selv om opplysningene kan maskeres.

Enhetstesting med Jest

Vi skal nå se nærmere på grunnleggende enhetstesting med Jest, som er et bibliotek for å teste JavaScript-kode.

Node.js og NPM

Før vi begynner må vi installere Node.js og NPM. Node.js er et runtime-system for å kjøre JavaScript-kode utenfor nettleseren, mens NPM er en pakkebehandler. Sistnevnte gjør det enklere å installere biblioteker vi kan benytte oss av når vi programmerer. Verktøyene lastes ned samlet fra nodejs.org.

Opprett et nytt prosjekt

Det første vi må gjøre er å opprette et nytt prosjekt. Lag en ny katalog og kall den "testing-jest":

Initialiser prosjektet med npm:

Kommandoen genererer filen "package.json", som inneholder metadata som er relevant for prosjektet: prosjektnavn, versjon, kjøreskript, avhengigheter med mer. Når vi oppgir flagget yes vil npm bruke standardverdier.

Installer avhengigheter

Neste steg er å installere og konfigurere bibliotekene vi trenger.

Jest

Først installerer vi selve testbiblioteket:

Jest er nå lagt til som en modul i prosjektet i katalogen "node_modules". Flagget save-dev registrerer modulen som en avhengighet (dependency) i "package.json"-fila. Det kan være verdt å merke seg at npm opererer med to typer avhengigheter:

For å kunne kjøre testene må vi definere følgende skript i "package.json":

Skriptet kjøres fra kommandolinja på følgende vis:

Babel

Når vi koder ønsker vi å bruke nyere syntaks i form av ES6-kode (ECMAScript 2015). ES6 gir oss blant annet klasser og arrow-funksjoner. Node.js har ikke full støtte for denne syntaksen, så vi må transpilere ES6-koden vår til kompatibel form, altså ES5. Babel gjør dette mulig. Det meste vi trenger ble installert som en del av Jest, men vi mangler fortsatt en sentral komponent kalt preset-env:

Babel er avhengig av en c-kompilator for visse funksjoner, så hvis du bruker macOS og får feilmeldingen "gyp: No Xcode or CLT version detected!" må du først følge instruksjonene på denne siden. Instruksjonene er også relevante hvis du får en liknende feilmelding i Windows.

Til slutt må vi konfigurere Babel. Opprett fila ".babelrc". Den må ligge i rotkatalogen for prosjektet, altså i "testing-jest". Fila skal ha følgende innhold:

Jest vil håndtere alt som har med Babel og transpilering å gjøre uten at vi trenger å tenke noe mer på dette.

Legg til en funksjon som kan testes

Lag filen "app.js" og skriv inn følgende kode:

Funksjonen isLeapYear returnerer true dersom året er et skuddår og false hvis det ikke er det. Store Norske Leksikon forteller oss at skuddår er "år som er delelige på 4 og som ikke er delelige med 100, med unntak for år som er delelige på 400". For å være sikre på at funksjonen oppfyller betingelsene og fungerer som forventet lager vi noen enhetstester.

Test for år som er skuddår

Først tester vi for år som er skuddår. Med utgangspunkt i beskrivelsen over bør vi dekke følgende tilfeller:

Opprett en ny fil kalt "app.test.js". Det er i denne fila vi skal skrive enhetstestene våre. Navnet gir en logisk knytning til "app.js", og jest vil automatisk fange opp fila fordi navnet slutter på ".test.js".

Siden vi skal teste funksjonen isLeapYear må vi importere den i "app.test.js". Legg til følgende kodesnutt øverst i fila:

Opprett så en enhetstest som dekker det første tilfellet:

Testen vår tar to parametre:

  1. En beskrivelse av hva vi tester.

  2. En funksjon som kjører koden vi ønsker å teste (Act) og som verifiserer resultatet (Assert).

Når vi kjører testen fra terminalen med npm run test får vi følgende resultat:

Så tester vi for et år som er delelig på 400:

Vi har nå to tester som sjekker om et år er skuddår. Det er naturlig å gruppere disse sammen, noe vi får til med describe:

Når vi kjører testene får vi følgende utskrift:

Test for år som ikke er skuddår

Vi må også teste for år som ikke er skuddår:

Utvid "app.test.js" med følgende tester:

Vi har nå fire tester som produserer følgende rapport:

Before og after

Vi har nå sett hvordan Jest kan hjelpe oss med å kjøre en test (Act) og verifisere resultatet (Assert), samt gruppere tester som logisk hører sammen. Noen ganger må vi også gjøre forberedelser før vi kjører testene (Arrange). Det kan være vi må nullstille testdata eller initialisere viktige variabler. Kanskje må vi også rydde opp etter at testene har kjørt. Jest har egne livsyklus-metoder for dette. Før testene kjører kan vi bruke beforeAll, alternativt beforeEach hvis vi ønsker å gjenta for hver test. På samme måte kan vi rydde opp etter oss med afterAlleller afterEach. Vi vil se nærmere på dette i senere leksjoner.

Testdekning

Vi kan måle testdekning med Jest ved å legge til følgende konfigurasjon i "package.json":

Linje 2 gjør at jest kompilerer data om testdekning, mens linje 3 forteller oss hvilke filer som skal analyseres.

Kommandoen npm run test vil nå analysere dekningsgrad i tillegg til å kjøre testene:

Jest lagrer en mer komplett rapport i HTML-format under coverage/lcov-report.

Kildekode

Fullstendig kildekode kan lastes ned fra https://gitlab.com/ntnu-dcst2002/testing-jest.