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.
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-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.
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.
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.
Steg | Aktivitet | Forventet resultat | Faktisk resultat | Status |
---|---|---|---|---|
1 | Åpne en nettleser og gå til ntnu.blackboard.com. | Innloggingssiden til BB Learn vises med knappen "Logg på (Feide)". | - | Ok |
2 | Klikk 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 |
3 | Skriv 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 |
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).
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:
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 gir oss flere fordeler:
Vi begynner å teste tidlig i løpet og får et økt fokus på kvalitetssikring.
Testene er automatiserte, som er en forutsetning for CI/CD.
Testene er koblet til de funksjonelle kravene og er dermed med på å dokumentere systemet.
Vi utvikler mindre komponenter som bidrar til løse koblinger og ryddig kode.
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.
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.
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.
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.
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 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.
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.
En test følger vanligvis et bestemt mønster kalt Arrange-Act-Assert (AAA):
Arrange: Forbered testdata, variabler og oppsett som testen trenger.
Act: Utfør funksjonen eller handlingen som skal testes.
Assert: Verifiser resultatet.
Vi kan verifisere både tilstand og oppførsel:
Tilstand: Sjekk at testen returnerer forventet resultat. Hvis vi tester en funksjon som sorterer en liste med tall er vi interessert i resultatet, altså den sorterte listen.
Oppførsel: Sjekk at koden utføres på riktig måte. Nå er vi interessert i algoritmen som gjennomfører selve sorteringen. Kaller den funksjonene vi forventer, og gjøres det i riktig sekvens?
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.
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.
En god test bidrar til å dokumentere koden den tester. Den viser hvordan koden virker ved å:
beskrive kontekst, utgangspunkt og betingelser som må oppfylles
illustrere hvordan programvaren brukes
definere forventet resultat og tilstand etter kjøring
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.
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.
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å:
Vi kan hardkode verdiene vi trenger i selve testen.
Vi kan lage en testdatabase og et skript som oppretter ønsket tilstand før vi kjører testen.
Vi kan bruke "In-Memory"-databaser som opprettes og slettes for hver økt.
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.
Vi skal nå se nærmere på grunnleggende enhetstesting med Jest, som er et bibliotek for å teste JavaScript-kode.
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.
Det første vi må gjøre er å opprette et nytt prosjekt. Lag en ny katalog og kall den "testing-jest":
21mkdir testing-jest
2cd testing-jest
Initialiser prosjektet med npm:
11npm init --yes
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.
Neste steg er å installere og konfigurere bibliotekene vi trenger.
Først installerer vi selve testbiblioteket:
11npm install jest --save-dev
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:
Biblioteker som vi trenger under utvikling. Da bruker vi flagget save-dev
.
Biblioteker som er nødvendige for å kjøre applikasjonen. Da bruker vi flagget save
.
For å kunne kjøre testene må vi definere følgende skript i "package.json":
31"scripts": {
2 "test": "jest"
3}
Skriptet kjøres fra kommandolinja på følgende vis:
11npm run test
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
:
11npm install @babel/preset-env --save-dev
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:
31{
2 "presets": ["@babel/preset-env"]
3}
Jest vil håndtere alt som har med Babel og transpilering å gjøre uten at vi trenger å tenke noe mer på dette.
Lag filen "app.js" og skriv inn følgende kode:
41export function isLeapYear(year) {
2 return (year % 4 === 0 && year % 100 !== 0) ||
3 (year % 400 === 0);
4}
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.
Først tester vi for år som er skuddår. Med utgangspunkt i beskrivelsen over bør vi dekke følgende tilfeller:
Året er delelig på 4, men ikke på 100.
Året er delelig på 400.
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:
11import {isLeapYear} from './app.js';
Opprett så en enhetstest som dekker det første tilfellet:
31test('Year is divisible by 4 but not by 100', () => {
2 expect(isLeapYear(2020)).toBe(true);
3});
Testen vår tar to parametre:
En beskrivelse av hva vi tester.
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:
81 PASS ./app.test.js
2 ✓ Year is divisible by 4 but not by 100 (2ms)
3
4Test Suites: 1 passed, 1 total
5Tests: 1 passed, 1 total
6Snapshots: 0 total
7Time: 1.53s
8Ran all test suites.
Så tester vi for et år som er delelig på 400:
31test('Year is divisible by 400', () => {
2 expect(isLeapYear(2000)).toBe(true);
3});
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:
91describe('A year is a leap year', () => {
2 test('Year is divisible by 4 but not by 100', () => {
3 expect(isLeapYear(2020)).toBe(true);
4 });
5
6 test('Year is divisible by 400', () => {
7 expect(isLeapYear(2000)).toBe(true);
8 });
9});
Når vi kjører testene får vi følgende utskrift:
101 PASS ./app.test.js
2 A year is a leap year
3 ✓ Year is divisible by 4 but not by 100
4 ✓ Year is divisible by 400
5
6Test Suites: 1 passed, 1 total
7Tests: 2 passed, 2 total
8Snapshots: 0 total
9Time: 1.103s
10Ran all test suites.
Vi må også teste for år som ikke er skuddår:
Året er ikke delelig på 4.
Året er delelig på 100, men ikke på 400.
Utvid "app.test.js" med følgende tester:
91describe('A year is not a leap year', () => {
2 test('Year is not divisible by 4', () => {
3 expect(isLeapYear(1981)).toBe(false);
4 });
5
6 test('Year is divisible by 100 but not by 400', () => {
7 expect(isLeapYear(2100)).toBe(false);
8 });
9});
Vi har nå fire tester som produserer følgende rapport:
131 PASS ./app.test.js
2 A year is a leap year
3 ✓ Year is divisible by 4 but not by 100
4 ✓ Year is divisible by 400
5 A year is not a leap year
6 ✓ Year is not divisible by 4 (1ms)
7 ✓ Year is divisible by 100 but not by 400
8
9Test Suites: 1 passed, 1 total
10Tests: 4 passed, 4 total
11Snapshots: 0 total
12Time: 1.567s
13Ran all test suites.
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 afterAll
eller afterEach
. Vi vil se nærmere på dette i senere leksjoner.
Vi kan måle testdekning med Jest ved å legge til følgende konfigurasjon i "package.json":
41 "jest": {
2 "collectCoverage": true,
3 "collectCoverageFrom": ["*.js"]
4 },
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:
191 PASS ./app.test.js
2 A year is a leap year
3 ✓ Year is divisible by 4 but not by 100 (2ms)
4 ✓ Year is divisible by 400
5 A year is not a leap year
6 ✓ Year is not divisible by 4
7 ✓ Year is divisible by 100 but not by 400
8
9----------|---------|----------|---------|---------|-------------------
10File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
11----------|---------|----------|---------|---------|-------------------
12All files | 100 | 100 | 100 | 100 |
13 app.js | 100 | 100 | 100 | 100 |
14----------|---------|----------|---------|---------|-------------------
15Test Suites: 1 passed, 1 total
16Tests: 4 passed, 4 total
17Snapshots: 0 total
18Time: 2.571s
19Ran all test suites.
Jest lagrer en mer komplett rapport i HTML-format under coverage/lcov-report.
Fullstendig kildekode kan lastes ned fra https://gitlab.com/ntnu-dcst2002/testing-jest.