Testing av REST-ressurser

I denne leksjonen skal vi se hvordan vi kan automatisere testing av REST-ressurser. Vi bruker rammeverket Jest for å skrive testene og en dedikert testdatabase for å sikre stabilitet i testmiljøet. Dere vil også lære hvordan vi kan imitere oppførsel og tilstand med en teknikk kalt mocking.

Leksjonen forutsetter grunnleggende kunnskap om programvaretesting og REST.

Todo API 2.0

Systemet vi skal teste (System Under Test) er en ny versjon av Todo APIet vi introduserte i leksjonen om REST. I det opprinnelige APIet lagret vi oppgavene våre direkte i internminnet. I den nye versjonen av APIet lagres alle data i en diskret MySQL-database. Dette øker kompleksiteten i eksempelet, men gir oss samtidig et mer realistisk system å teste.

Det er verdt å merke seg at APIet tilbyr de samme endepunktene som før. Vi velger å fortsatt bruke "v1" her fordi selve grensesnittet – URLen til ressursene – ikke har endret seg selv om vi nå bruker en database for å lagre oppgavene:

MetodeEndepunktBeskrivelse
GET/api/v1/tasksHent alle oppgaver
GET/api/v1/tasks/:idHent en gitt oppgave
POST/api/v1/tasksLag en ny oppgave
DELETE/api/v1/tasks/:idSlett en gitt oppgave

Fokuset i denne leksjonen vil være utviklingen av automatiserte tester som verifiserer disse endepunktene. Men før vi faktisk begynner å kode disse må vi få APIet oppe og gå.

Sett opp databaseløsningen

Når vi tester systemet må vi kunne garantere repeterbarhet og at testene produserer det samme resultatet hver gang de kjører. Det forutsetter at vi har et testdatasett som ser likt ut før hver gjennomkjøring. Når vi utvikler ellers er ikke kravene til forutsigbarhet i datasettet like stort. Derfor oppretter vi to databaser, en for utvikling og en for test. NTNU tilbyr en dedikert MySQL-tjener og egne terminalverktøy som forenkler prosessen. Tjeneren er en intern ressurs på NTNU og du må derfor være koblet på NTNUs nett for å nå den i etterkant.

For å komme i mål må vi opprette en egen databasebruker, lage de to databasene (dev og test) og sette rettigheter, samt opprette tabellen som persisterer oppgavene. I beskrivelsen under må du bytte ut “username” med ditt eget brukernavn ved NTNU.

Opprett en databasebruker

Koble opp mot login.stud.ntnu.no via SSH:

Kjør så følgende kommando:

Vi har nå laget en databasebruker. Vi må deretter sette passord på brukeren:

Lag databasene

Først oppretter vi utviklingsdatabasen:

Deretter testdatabasen:

Sett rettigheter

Vi ønsker at databasebrukeren vår skal ha full tilgang til databasene vi nettopp laget. Først setter vi rettigheter for utviklingsdatabasen:

Kommandoen over vil starte opp en teksteditor hvor man kan skrive inn rettighetene (vi er standard i de fleste terminal-programmer). Filen skal se noenlunde slik ut etter endring:

Så må du gjøre det samme for testdatabasen:

Opprett oppgave-tabell

Du kan nå koble deg opp mot databasene med en MySQL-klient. Det enkleste er å bruke phpMyAdmin. Logg deg på med databasebrukeren som du nettopp laget (username_todo) og opprett oppgave-tabellen med følgende skript:

Husk at dette må gjøres for begge databaser.

Last ned APIet og installer avhengigheter

Last ned kildekoden fra gitlab:

Installer deretter alle avhengigheter med npm:

Koble databasene mot APIet

Når vi kjører testene ønsker vi at APIet skal kobles mot testdatabasen. For annen utvikling vil vi at systemet skal bruke utviklingsdatabasen. Hvilken database vi skal gå mot avhenger derfor av miljøet vi befinner oss i. Vi løser dette kravet til fleksibilitet ved å lagre databasekonfigurasjonen i miljøvariabler. Vi må definere følgende variabler:

Her har vi valgt å lagre miljøvariablene i konfigurasjonfiler. Siden vi har to miljøer trenger vi to filer. Det er verdt å merke seg at disse aldri bør sjekkes inn i versjonskontroll fordi de inneholder sensitive opplysninger.

Først lager vi en fil med databasekonfigurasjonen for utviklingsmiljøet vårt. Opprett fila "config.js" i rotkatalogen til prosjektet og legg til følgende innhold:

Erstatt “username” på linje 5 og 7 med ditt eget brukernavn på NTNU og bytt ut "password" på linje 6 slik at det samsvarer med databaseoppsettet ditt.

Så trenger vi en konfigurasjonsfil for testmiljøet. Lag en ny katalog kalt "test" og lagre en kopi av "config.js"-fila du nettopp opprettet her. Deretter endrer du variabel-verdiene slik at de peker mot testdatabasen vår.

Det er koden i mysql-pool.js som er ansvarlig for å opprette koblingen mot databasen, noe den gjør basert på miljøvariablene vi har definert. Hvilken konfigurasjonsfil som skal brukes avhenger av om vi er i utvikling eller test. I praksis bestemmer vi dette når vi definerer kjøreskriptene i "package.json". For utviklingsmiljøet ser det slik ut:

Selve APIet startes med følgende kommando fra terminalen:

Vi har nå et fungerende REST API som vi kan utvikle tester mot.

Testing med testdatabase

Før vi skriver testene må vi installere og konfigurere de nødvendige bibliotekene.

Installer Jest og Axios

Først installerer vi selve testbiblioteket:

Vi ønsker å programmatisk gjennomføre HTTP-forspørsler mot endepunktene i APIet. Her har vi valgt å bruke axios, en HTTP-klient med støtte for promises som fungerer godt sammen med Jest:

Vi må også legge til et skript i “package.json” slik at vi kan kjøre testene med NPM:

Argumentet --setUpFiles ./test/config gir oss tilgang til konfigurasjonsfila for testmiljøet.

Klargjør testmiljøet

Lag en ny fil kalt “todo-api-db.test.js" i katalogen "test". Jest vil automatisk fange opp fila fordi navnet slutter på ".test.js”.

Testene våre må ha tilgang til axios-biblioteket for HTTP-forespørsler. Vi må også ha tilgang til mysql-pool og APIet vårt, samt taskService for klargjøring av testdatabasen. Det første vi må gjøre er derfor å importere disse avhengighetene. Legg til følgende kode i test-fila:

Så må vi sette opp axios:

Vi trenger også en liste med oppgaver. Utvid “todo-api-db.test.js" med følgende:

Vi ønsker at testene skal kjøre isolert og uavhengige av hverandre. En test skal f.eks. ikke være avhengig av at en annen har kjørt for å få tilgang til nødvendige data. Vi må derfor garantere at testdatasettet er det samme før hver test. Vi må også passe på at webtjeneren avsluttes og at koblingen mot testdatabasen frigjøres etter at testene har kjørt. Livsyklus-metodene i Jest hjelper oss her:

Utvid test-fila med kodesnutten under:

På linje 1 oppretter vi variabelen webServer. Vi må gjøre dette utenfor "scopet" til livssyklus-metodene fordi vi trenger tilgang til variabelen både når vi starter webtjeneren (linje 2) og når vi rydder opp etter oss (linje 17). På linje 5-6 itererer vi over listen med oppgaver i testData og sletter disse fra testdatabasen. På linje 8-9 setter vi inn de samme testdataene på nytt. For å være helt sikre på at slettingen er ferdig før vi setter inn nye testdata bruker vi nøkkelordene async og await. Koden på linje 12 til 18 frigjør ressurser og garanterer forutsigbarhet i testmiljøet.

Om async/await

Målet med async/await er å forenkle håndteringen av promises. Når vi skriver async foran en funksjon sier vi at funksjonen returnerer et promise. Dette skjer selv om vi ikke eksplisitt returnerer et promise-objekt inne i funksjonen. Så hvis vi f.eks. skriver return 1 vil JavaScript pakke det inn slik: return Promise.resolve(1). Funksjonene merket async på linje 4 og 12 i koden over vil derfor begge returnere et promise.

I en async funksjon kan vi skrive await foran funksjonskall som returnerer et promise. await garanterer at promiset vi venter på fullfører før videre kjøring.

For å håndtere unntakstilfeller – situasjoner hvor promiset feiler med "reject" – må vi bruke try-catch:

Uten async/await ville vi skrevet noe sånt som:

For den som vil lære mer om async/await og try/catch anbefaler vi følgende ressurser:

Uthenting av oppgaver

Uthenting av oppgaver omfatter de to første endepunktene i APIet vårt:

MetodeEndepunktBeskrivelse
GET/api/v1/tasksHent alle oppgaver
GET/api/v1/tasks/:idHent en gitt oppgave

Vi bør teste både for suksess-scenario og feiltilfeller. I testene er det lurt å verifisere både statuskode og nyttelast (data). Vi grupperer testene og gir testsettet et logisk navn med describe:

Først tester vi suksess-scenarioene. Vi legger til to tester i testsettet:

Vi lager en ny test for uthenting av alle oppgaver på linje 3 og definerer test-funksjonen som async. På linje 4 kaller vi axios-funksjonen get, som gjennomfører en GET-forespørsel mot det aktuelle endepunktet. get returnerer et promise og vi kan derfor sikre at forespørselen kjører ferdig med await. Siden vi tester for et vellykket resultat forventer vi at returnert statuskode er 200 OK og at nyttelasten inneholder alle oppgavene fra testdatabasen (linje 6-7).

På linje 10 definerer vi testen for uthenting av kun én oppgave. Med unntak av endepunkt og forventet resultat er koden så og si identisk med forrige test.

Vi bør også teste for feiltilfeller. Vi har gjengitt funksjonsignaturene under, men selve implementasjonen er en del av øvingen tilknyttet leksjonen:

Opprettelse av oppgaver

Opprettelse av oppgaver omfatter følgende endepunkt i APIet:

MetodeEndepunktBeskrivelse
POST/api/v1/tasksLag en ny oppgave

Vi grupperer testene for opprettelse av oppgaver med describe og gir testsettet et logisk navn:

Koden for å teste suksess-scenarioet ser slik ut:

På linje 4 definerer vi oppgaven vi ønsker å opprette. Så kaller vi APIet og sender med den nye oppgaven på linje 5 (merk at vi nå bruker post-funksjonen). Vi forventer at responsen returnerer status 201 Created og den relative lokasjonen til den nye ressursen via header-feltet Location.

Funksjonsignaturer for feiltilfeller er gjengitt under, men selve implementasjonen er en del av øvingen tilknyttet leksjonen:

Sletting av oppgaver

Sletting av oppgaver omfatter følgende endepunkt i APIet:

MetodeEndepunktBeskrivelse
DELETE/api/v1/tasks/:idSlett en gitt oppgave

Her har vi kun en test:

På linje 4 sender vi en forespørsel til APIet om å slette en oppgave. Nå bruker vi delete-funksjonen. Vi vet at testdatabasen vår har oppgaven vi ønsker å slette og forventer derfor en respons med status 200.

Testing med mocking

Testene våre er avhengige av et forutsigbart testdatasett for å fungere. Vi har løst denne utfordringen med en dedikert testdatabase. Dette er slettes ingen dum løsning. Vi ønsker imidlertid å isolere det vi skal teste i så stor grad som mulig, fordi avhengigheter til eksterne komponenter øker kompleksiteten i testmiljøet. Men vi kan ikke bare fjerne koblingen til databasen. Løsningen er å bytte ut komponenten vi er avhengig av med noe som likner. Denne kunstige erstatningen kalles gjerne for en “Test Double”. En mock er en type test double som imiterer oppførselen til et objekt på en kontrollert måte. Ved å bytte ut koden som kommuniserer med MySql-tjeneren med en mock kan vi teste uavhengig av databasen. Jest tilbyr egne mocking-funksjoner som hjelper oss i arbeidet.

All kommunikasjonen med databasen foregår som kjent via klassen TaskService. Dette forenkler jobben vår betraktelig, fordi vi kan lage en mock av hele klassen med jest.mock og deretter mocke enkeltfunksjoner etter hvert som vi trenger dem med jest.fn.

Klargjør testmiljøet

Opprett filen “todo-api-mock.test.js” i “test”-katalogen. Jest vil automatisk fange opp fila fordi navnet slutter på ".test.js”.

Først må vi importere de nødvendige bibliotekene og klassene:

Vi må også konfigurere axios. Merk at vi benytter port 3001:

Så lager vi en mock av task-service:

Koden på linje 1 erstatter taskService med en mock. Mocken har de samme metodene som den opprinnelige klassen, men de gjør ingenting annet enn å returnere undefined. Fra nå av er det mocken som gjelder.

Vi trenger fortsatt en liste med oppgaver. Utvid “todo-api-mock.test.js" med følgende:

Den siste biten av klargjøringen er å sørge for at APIet vårt starter og avslutter på riktig måte:

Da vi jobbet mot testdatabasen måtte vi konfigurere tilkoblingen og sørge for at databasen ble populert med testdata før kjøring. Vi måtte også rydde opp etter oss. Med mocking er dette unødvendig.

Eksempel med uthenting av oppgaver

Vi kan nå skrive en test som viser mocking i praksis. Vi tar utgangspunkt i endepunktet for å hente ut alle oppgaver:

MetodeEndepunktBeskrivelse
GET/api/v1/tasksHent alle oppgaver

Legg til følgende kode i “todo-api-mock.test.js”:

Objektet taskService er en mock, og funksjonen getAll returnerer undefined som standard. På linje 4 binder vi imidlertid getAll mot en ny funksjon. Den nye funksjonen returnerer et promise med oppgavene som etterspørres, akkurat som det virkelige objektet vårt ville gjort. Vi simulerer med andre ord oppførselen og slipper dermed avhengigheten til databasen. Deretter kaller vi Todo APIet mot endepunktet som henter ut alle oppgavene (linje 6). Koden som ligger bak dette endepunktet vil kalle taskService.getAll, som vi nettopp har mocket.

Som en kuriositet nevner vi at koden på linje 4 kan erstattes med taskService.getAll.mockResolvedValue(testData). Jest tilbyr mockResolvedValue(value) som en hjelpefunksjon for å gjøre koden tydeligere og lettere å lese (også kalt syntaktisk sukker).

Kildekode

Fullstendig kildekode som inkluderer den nye versjonen av Todo APIet, tester mot testdatabasen og mock-tester kan lastes ned fra https://gitlab.com/ntnu-dcst2002/todo-api-v2-with-tests.