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.
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:
Metode | Endepunkt | Beskrivelse |
---|---|---|
GET | /api/v1/tasks | Hent alle oppgaver |
GET | /api/v1/tasks/:id | Hent en gitt oppgave |
POST | /api/v1/tasks | Lag en ny oppgave |
DELETE | /api/v1/tasks/:id | Slett 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å.
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.
Koble opp mot login.stud.ntnu.no
via SSH:
xxxxxxxxxx
11ssh username@login.stud.ntnu.no
Kjør så følgende kommando:
xxxxxxxxxx
11mysql-useradm create username_todo
Vi har nå laget en databasebruker. Vi må deretter sette passord på brukeren:
xxxxxxxxxx
11mysql-useradm passwd username_todo
Først oppretter vi utviklingsdatabasen:
xxxxxxxxxx
11mysql-dbadm create username_todo_dev
Deretter testdatabasen:
xxxxxxxxxx
11mysql-dbadm create username_todo_test
Vi ønsker at databasebrukeren vår skal ha full tilgang til databasene vi nettopp laget. Først setter vi rettigheter for utviklingsdatabasen:
xxxxxxxxxx
11mysql-dbadm editperm username_todo_dev
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:
xxxxxxxxxx
41Database 'username_todo_dev':
2# User Select Insert Update Delete Create Drop Alter Index Temp Lock References
3# ---------------- ------ ------ ------ ------ ------ ------ ----- ----- ---- ---- ----------
4username_todo Y Y Y Y Y Y Y Y Y Y Y
Så må du gjøre det samme for testdatabasen:
xxxxxxxxxx
11mysql-dbadm editperm username_todo_test
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:
xxxxxxxxxx
51CREATE TABLE IF NOT EXISTS Tasks (
2 id INTEGER NOT NULL,
3 title TEXT NOT NULL,
4 done BOOLEAN,
5 PRIMARY KEY (id));
Husk at dette må gjøres for begge databaser.
Last ned kildekoden fra gitlab:
xxxxxxxxxx
11git clone https://gitlab.com/ntnu-dcst2002/todo-api-v2.git
Installer deretter alle avhengigheter med npm:
xxxxxxxxxx
21cd todo-api-v2
2npm install
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:
MYSQL_HOST
MYSQL_USER
MYSQL_PASSWORD
MYSQL_DATABASE
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:
xxxxxxxxxx
71// DO NOT commit this file into version control
2// Make sure this file is added to .gitignore
3
4process.env.MYSQL_HOST = 'mysql.stud.ntnu.no';
5process.env.MYSQL_USER= 'username_todo';
6process.env.MYSQL_PASSWORD = 'password';
7process.env.MYSQL_DATABASE = 'username_todo_dev';
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:
xxxxxxxxxx
31scripts": {
2 "start": "nodemon start.js --exec babel-node --require ./config"
3 },
Selve APIet startes med følgende kommando fra terminalen:
xxxxxxxxxx
11npm run start
Vi har nå et fungerende REST API som vi kan utvikle tester mot.
Før vi skriver testene må vi installere og konfigurere de nødvendige bibliotekene.
Først installerer vi selve testbiblioteket:
xxxxxxxxxx
11npm install jest --save-dev
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:
xxxxxxxxxx
11npm install axios --save-dev
Vi må også legge til et skript i “package.json” slik at vi kan kjøre testene med NPM:
xxxxxxxxxx
31"scripts": {
2 "test": "jest --setupFiles ./test/config"
3}
Argumentet --setUpFiles ./test/config
gir oss tilgang til konfigurasjonsfila for 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:
xxxxxxxxxx
41import axios from "axios";
2import pool from "../mysql-pool";
3import todoApi from "../todo-api";
4import taskService from "../task-service";
Så må vi sette opp axios:
xxxxxxxxxx
11axios.defaults.baseURL = "http://localhost:3000";
Vi trenger også en liste med oppgaver. Utvid “todo-api-db.test.js" med følgende:
xxxxxxxxxx
51const testData = [
2 { id: 1, title: 'Les leksjon', done: 1 },
3 { id: 2, title: 'Møt opp på forelesning', done: 0 },
4 { id: 3, title: 'Gjør øving', done: 0 }
5];
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:
beforeAll
: I denne metoden starter vi APIet vårt mot port 3000.
beforeEach
: Her sletter vi unna gamle rader og sette inn de testdata vi definerte over.
afterAll
: Her rydder vi opp etter at alle testene har kjørt; vi sletter oppgavene i testdatabasen, lukker webtjeneren, og frigjøre databasekoblingen.
Utvid test-fila med kodesnutten under:
xxxxxxxxxx
181let webServer;
2beforeAll(() => webServer = todoApi.listen(3000));
3
4beforeEach(async () => {
5 const deleteActions = testData.map(task => taskService.delete(task.id));
6 await Promise.all(deleteActions);
7
8 const createActions = testData.map(task => taskService.create(task));
9 await Promise.all(createActions);
10});
11
12afterAll(async () => {
13 const deleteActions = [1, 2, 3, 4].map(id => taskService.delete(id));
14 await Promise.all(deleteActions);
15
16 pool.end();
17 webServer.close();
18});
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.
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:
xxxxxxxxxx
51try {
2 const result = await someFunctionReturningAPromise();
3} catch (error) {
4 //handle error
5}
Uten async/await ville vi skrevet noe sånt som:
xxxxxxxxxx
31someFunctionReturningAPromise()
2 .then( ) //handle resolved promise
3 .catch(error) //handle rejected promise
For den som vil lære mer om async/await og try/catch anbefaler vi følgende ressurser:
Uthenting av oppgaver omfatter de to første endepunktene i APIet vårt:
Metode | Endepunkt | Beskrivelse |
---|---|---|
GET | /api/v1/tasks | Hent alle oppgaver |
GET | /api/v1/tasks/:id | Hent 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
:
xxxxxxxxxx
31describe('Fetch tasks (GET)', () => {
2
3});
Først tester vi suksess-scenarioene. Vi legger til to tester i testsettet:
xxxxxxxxxx
171describe('Fetch tasks (GET)', () => {
2
3 test("Fetch all tasks (200 OK)", async () => {
4 const response = await axios.get("/api/v1/tasks");
5
6 expect(response.status).toEqual(200);
7 expect(response.data).toEqual(testData);
8 });
9
10 test("Fetch task (200 OK)", async () => {
11 const expected = [testData[0]];
12 const response = await axios.get("/api/v1/tasks/1");
13
14 expect(response.status).toEqual(200);
15 expect(response.data).toEqual(expected);
16 });
17});
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:
xxxxxxxxxx
111test.skip('Fetch all tasks (500 Internal Server Error)', async () => {
2 //todo
3});
4
5test.skip('Fetch task (404 Not Found)', async () => {
6 //todo
7});
8
9test.skip('Fetch task (500 Internal Server error)', async () => {
10 //todo
11});
Opprettelse av oppgaver omfatter følgende endepunkt i APIet:
Metode | Endepunkt | Beskrivelse |
---|---|---|
POST | /api/v1/tasks | Lag en ny oppgave |
Vi grupperer testene for opprettelse av oppgaver med describe
og gir testsettet et logisk navn:
xxxxxxxxxx
31describe('Create new task (POST)', () => {
2
3});
Koden for å teste suksess-scenarioet ser slik ut:
xxxxxxxxxx
91describe('Create new task (POST)', () => {
2
3 test("Create new task (201 Created)", async () => {
4 const newTask = { id: 4, title: "Ny oppgave", done: false };
5 const response = await axios.post("/api/v1/tasks", newTask);
6 expect(response.status).toEqual(201);
7 expect(response.headers.location).toEqual("tasks/4");
8 });
9});
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:
xxxxxxxxxx
71test.skip('Create new task (400 Bad Request)', async () => {
2 //todo
3});
4
5test.skip('Create new task (500 Internal Server error)', async () => {
6 //todo
7});
Sletting av oppgaver omfatter følgende endepunkt i APIet:
Metode | Endepunkt | Beskrivelse |
---|---|---|
DELETE | /api/v1/tasks/:id | Slett en gitt oppgave |
Her har vi kun en test:
xxxxxxxxxx
71describe('Delete task (DELETE)', () => {
2
3 test("Delete second task (200 OK)", async () => {
4 const response = await axios.delete("/api/v1/tasks/2");
5 expect(response.status).toEqual(200);
6 });
7});
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.
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
.
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:
xxxxxxxxxx
31import axios from "axios";
2import todoApi from "../todo-api";
3import taskService from "../task-service";
Vi må også konfigurere axios. Merk at vi benytter port 3001:
xxxxxxxxxx
11axios.defaults.baseURL = "http://localhost:3001";
Så lager vi en mock av task-service:
xxxxxxxxxx
11jest.mock('../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:
xxxxxxxxxx
51const testData = [
2 { id: 1, title: 'Les leksjon', done: 1 },
3 { id: 2, title: 'Møt opp på forelesning', done: 0 },
4 { id: 3, title: 'Gjør øving', done: 0 }
5];
Den siste biten av klargjøringen er å sørge for at APIet vårt starter og avslutter på riktig måte:
xxxxxxxxxx
31let webServer;
2beforeAll(() => webServer = todoApi.listen(3001));
3afterAll(() => webServer.close());
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.
Vi kan nå skrive en test som viser mocking i praksis. Vi tar utgangspunkt i endepunktet for å hente ut alle oppgaver:
Metode | Endepunkt | Beskrivelse |
---|---|---|
GET | /api/v1/tasks | Hent alle oppgaver |
Legg til følgende kode i “todo-api-mock.test.js”:
1describe('Fetch tasks (GET)', () => {
2
3 test("Fetch all tasks (200 OK)", async () => {
4 taskService.getAll = jest.fn(() => Promise.resolve(testData));
5
6 const response = await axios.get("/api/v1/tasks");
7 expect(response.status).toEqual(200);
8 expect(response.data).toEqual(testData);
9 });
10});
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).
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.