's Picture

TDD/BDD - Expressen style!

Postad av Oscar Tholander

Vi har tidigare pratat om att Expressenutvecklaren jobbar testdrivet. Men hur jobbar vi med det i Node.js? Det ska jag gå igenom idag!

Vi kommer att bygga ett enklare REST-API för en "Att-göra-lista"-app i ramverket expressjs och testramverket mocha med tillägget mocha-cakes. Kod för denna tutorial hittar ni här.

Vi börjar med att skapa ett s.k. "feature-test". Detta är en typ av test som vi använder för att testa applikationen "end-to-end", d.v.s. att vi kommer starta upp våran applikation och sedan göra riktiga request till den. Med detta tillvägagångssätt så blir det enklare som utvecklare att sätta in sig i sina användares perspektiv, då det annars är alldeles för enkelt att snöa in sig på implementationsdetaljer. Vi låter även detta test driva utvecklingen av applikationen framåt. Applikationen kommer ha följande projektstruktur när vi är färdiga:

todoapp  
├── README.md
├── app.js
├── lib
│   └── storage.js
├── node_modules
├── package.json
└── test
    ├── features
    │   └── todo-feature.js
    ├── lib
    │   └── storageTest.js
    ├── mocha.opts
    └── setup.js

Ett första feature-test

Vi börjar med att skapa vår feature "TODO" och ett enkelt Scenario "Add an item". En feature definieras av en eller fler scenarios. Det är i vårt scenario som vi specificerar vad som ska ske när vi försöker göra det vi vill göra, i detta fall lägga till en punkt på vår "Att-göra-lista".

"use strict";

const app = require("../../");  
const request = require("supertest");

Feature("TODO", () => {  
  Scenario("Add an item", () => {
    When("Posting an item", (done) => {
      const item = {"item": "Write a blog post about testing"};
      request(app)
        .post("/api/todo")
        .send(item)
        .expect(200)
        .expect({id: 1}, done);
    });
  });
});

Vårt första testfall blir ganska rakt på. Det vi försöker förmedla är "När vi skickar ett POST request med innehållet {"item": "Write a blog post about testing"} så förväntar vi oss att få tillbaka ett svar som ger oss HTTP-statuskoden 200 och innehållet {id: 1}". På detta vis blir våra tester en specifikation över funktionalitet som vi vill uppnå. Nu har vi ett test och vi börjar med att köra det med npm test.

Här ser vi att vårt test fallerar då vi inte har någon endpoint uppsatt i vår applikation som matchar sökvägen /api/todo. Nu kan vi skapa upp den i app.js. Vår app.js kommer till en början att se ut på följande sätt:

"use strict";

const express = require("express");  
const app = express();

app.post("/api/todo", (req, res) => {  
  res.sendStatus(200);
});

const server = app.listen(3000, () => {  
  console.log("Listening on port %d", server.address().port);
});

module.exports = app;  

Vi börjar enkelt och låter vår endpoint bara returnera "200, OK" till användaren. Vi kör våra tester en gång till och ser att vi inte får tillbaka det förväntade innehållet {id: 1}.

Ett unit test

Nu kan man egentligen hårdkoda in att man alltid ska returnera {id: 1} och få sitt test att gå igenom. Men vi tänker ett steg längre och hoppar istället direkt på att spara ner datan vi fick och detta driver oss att skriva ett mer renodlat "unit test" för modulen som sköter sparningen. Vi skapar upp två nya kataloger som heter lib under todoapp och todoapp/test. Vi använder oss av denna struktur och konvention för att enkelt hitta tester och dess respektive implementation i våra projekt. Låt oss nu skapa filerna storageTest.js i todoapp/test och storage.js i todoapp/lib.

// storageTest.js
"use strict";

const storage = require("../../lib/storage");

describe("storage", () => {  
  describe("create", () => {
    afterEach(storage.reset); // Will run after each test case within this scope

    it("return the id of the created item", () => {
      const id = storage.create({"item": "testdata"});
      id.should.equal(1);
    });
  });
});

För unit-testning använder vi oss av ren mocha och dess describe/it syntax för att definiera våra tester för vår storage-modul. I det här blogginlägget avgränsar vi oss lite och använder inte en databas. Vår implementation för storage.js ser ut så här:

// storage.js
"use strict";

let items = {};  
let incrementId = 1;

function create(item) {  
  item.id = incrementId++;
  items[item.id] = item;
  return item.id;
}

function reset() {  
  items = {};
  incrementId = 1;
}

module.exports = {  
  create: create,
  reset: reset
};

Vi har ett javascriptobjekt items där vi sparar vår data samt en "auto increment"-variabel som nyckel för tillagda punkter i vår "Att-göra-lista", ungefär som en tabell i en SQL-databas skulle fungera. Det finns även en reset-funktion för att underlätta uppstädning av "databasen" mellan testerna.

För att garantera att inkrementering av identifieraren verkligen fungerar så utökar vi vårt unit test med detta.

it("should auto increment ids when creating multiple items", () => {  
  const idOne = storage.create({"item": "item1"});
  const idTwo = storage.create({"item": "item2"});

  idOne.should.equal(1);
  idTwo.should.equal(2);
});

Hur knyter vi ihop det?

Så vad har vi gjort nu egentligen? Vi har börjat med skriva ett enkelt feature-test som sedan har drivit oss till att skriva ett mindre unit test för ett av våra delproblem. Nu kan vi spara ner en punkt i vår "Att-göra-lista" och returnera det vi förväntar oss. För att kunna parsa JSON-objektet vi skickar till vårt API behöver vi lägga till body-parser och säga till att den ska parsa JSON-data åt oss, mer går att läsa i expressjs dokumentation (http://expressjs.com/en/4x/api.html#req.body).

// app.js

"use strict";

const express = require("express");  
const bodyParser = require("body-parser");  
const storage = require("./lib/storage");

const app = express();  
app.use(bodyParser.json());

app.post("/api/todo", (req, res) => {  
  const id = storage.create(req.body)
  res.send({"id": id});
});

const server = app.listen(3000, () => {  
  console.log("Listening on port %d", server.address().port);
});

module.exports = app;  

Skulle vi köra våra tester nu skulle testerna gå sönder då vårt feature-test inte städar upp efter sig när det är färdigt. Vi lägger till det och todo-feature.js ser ut så här:

// todo-feature.js

"use strict";

const app = require("../../");  
const request = require("supertest");  
const storage = require("../../lib/storage.js")

Feature("TODO", () => {  
  Scenario("Add an item", () => {
    after(storage.reset); // Clean up database when scenario is done.

    When("Posting an item", (done) => {
      const item = {"item": "Write a blog post about testing"};
      request(app)
        .post("/api/todo")
        .send(item)
        .expect(200)
        .expect({id: 1}, done);
    });
  });
});

Kör vi våra tester nu med npm test så kommer alla tester att bli gröna.

Hur går vi vidare?

Nu kan vi börja med vårt nästa scenario för att driva utvecklingen framåt, hämta alla skapade punkter i vår "Att-göra-lista".

Scenario("Fetch all TODOs", () => {  
  after(storage.reset);

  let response;
  Given("There is a bunch of TODOs", () => {
    storage.create({"item": "item one"});
    storage.create({"item": "item two"});
    storage.create({"item": "item three"});
  });

  When("Fetching all TODOs", (done) => {
    request(app)
      .get("/api/todo")
      .expect(200)
      .end((err, res) => {
        if (err) return done(err);
        response = res.body;
        done();
      });
  });

  Then("Response should contain all three TODOs", () => {
    const expectedResponse = {
        "1": {
          id: 1,
          item: "item one"
        },
        "2": {
          id: 2,
          item: "item two"
        },
        "3": {
          id: 3,
          item: "item three"
        }
      };
    response.should.eql(expectedResponse);  
  });
});

Nu har det tillkommit två stycken nya block i vårt scenario, "Given" och "Then". Given använder vi oss för att sätta upp förutsättningarna för vårt testfall. Then använder vi oss för att verifiera vårt test när det har kört klart.

Låt oss ponera att detta är större projekt med 1000-2000 tester, i så fall kommer vårt nya scenario lätt drunkna i all utskrift från övriga tester. Vi kan då köra det enskilda scenariot med följande kommando mocha test/features/todo-feature.js --grep "Fetch all TODOs".

Vi ser att vi får en 404 ifrån API:et då vår GET endpoint för /api/todo inte existerar. Vi lägger till den i app.js och hämtar även upp alla punkter i "Att-göra-listan" utan att driva detta i vårt unit test för storage. Vi gör följande kodtillägg så att vårt scenario passerar.

//app.js

app.get("/api/todo", (req, res) => {  
  res.send(storage.fetchAll());
});
//storage.js

function fetchAll() {  
  return items;
}

Sen för att verifiera att vi inte har råkat göra sönder något annat när vi har implementerat detta scenario så kör vi alla våra tester igen.

Nu är det bara och fortsätta skriva scenarion för att uppdatera och plocka bort punkter på "Att-göra-listan" och implementera det, så har vi ett helt fungerande REST-API för vår "Att-göra-lista". Men detta låter jag läsaren själv utforska i utbildningssyfte. Sedan finns det en hel del andra saker att ta upp angående testning så som att fejka externa beroende mot ett annat REST-API men det får bli ett helt eget blogginlägg om det!

PS. Missa inte vår nästa bloggpost, följ oss på Twitter!

Till startsidan