TDD for an Express application

TDD for an Express application

TDD has been proven to significantly reduce the amount of bugs in software releases. Moreover, with CI/CD systems, tests can be run automatically before the application deployment, preventing a faulty application to reach its end user. This guide goes through the steps required to set up a TDD workflow with an Express application.

An example application

Let's start with the application we would like to test. Here we'll use the following Express application:

const express = require('express')
const cors = require('cors')

const app = express()
app.use(cors())

const port = 7070

const fruits = ['apple', 'banana', 'cherry']

const create_fruit = (req, res) => {
  const {new_fruit} = req.body
  fruits.push(new_fruit)
  res.send(new_fruit)
}

const read_fruits = (req, res) => {
  res.send(fruits)
}

const read_fruit = (req, res) => {
  const {index} = req.params
  const fruit = fruits[index]
  res.send(fruit)
}

app.route('/fruits')
  .post(create_fruit)
  .get(read_fruits)

app.route('/fruits/:index')
  .get(read_fruit)

app.listen(port, () => {
  console.log(`App listening on port ${port}`)
})

module.exports = app

This simple application manages fruits via a REST API. Fruits can be queried or created via the /fruits route using an HTTP GET or POST request respectively. Moreover, individual fruits can be retrieved using their index via the /fruits/:index route.

In this example, it appears clear that the application expects fruits to be of type String and indices to be integers. Thus, to make sure that this application is built properly, let us write some tests.

Installing testing modules

To manage tests, we can leverage an testing framework called Mocha. Additionally, we'll add Chai, a library which helps with writing test success or failure conditions and Supertest, which allows us to test the application via its REST API. Those modules can be installed with NPM, but we won't be needing them in production so let's add them as development dependencies as so:

npm install chai mocha supertest --save-dev

Writing tests

Tests are meant to be written in separate .js files placed in a directory named test. It is usually wise to have one test file per controller. As such, for our example, we'll create the test folder with a file named fruits.js in it. Those tests follow the Mocha, Chai and Supertest syntax and should express the execution conditions we mentioned earlier. As such, this is one way those tests could be written:

const app = require("../index.js")
const request = require("supertest")
const {expect} = require("chai")

describe("/fruits", () => {

  describe("GET /fruits/:index", () => {

    it("Should not allow to query a fruit using an integer as index", async () => {
      const {status} = await request(app).get("/fruits/2")
      expect(status).to.equal(200)
    })

    it("Should not allow to query a fruit using a String as index", async () => {
      const {status} = await request(app).get("/fruits/b")
      expect(status).to.not.equal(200)
    })

  })

  describe("POST /fruits/", () => {

    it("Should allow to create a fruit as String", async () => {
      const {status} = await request(app)
        .post("/fruits")
        .send({fruit: 'Peach'})

      expect(status).to.equal(200)
    })

    it("Should not allow to create a fruit as Array", async () => {
      const {status} = await request(app)
        .post("/fruits")
        .send({fruit: [1,2,3]})

      expect(status).to.not.equal(200)
    })

  })

})

Notice how the test file imports the express application at its first line.

Modifying package.json to simplify the execution of tests

In order to run tests easily, let's configure the npm run test command to perform the necessary actions. To do so, we edit the package.json file at the scripts section, where the original content should like this:

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},

Here, let's replace the original content of the test command by our own:

"scripts": {
  "test": "mocha --timeout 10000 --exit"
},

Running the tests

We are now ready to test our application through its REST API. let us go ahead an do so by running

npm run test

The result should look as follows:

  /fruits
    GET /fruits/:index
      ✔ Should not allow to query a fruit using an integer as index
      1) Should not allow to query a fruit using a String as index
    POST /fruits/
      ✔ Should allow to create a fruit as String
      2) Should not allow to create a fruit as Array


  2 passing (39ms)
  2 failing

  1) /fruits
       GET /fruits/:index
         Should not allow to query a fruit using a String as index:

      AssertionError: expected 200 to not equal 200
      + expected - actual


      at Context.<anonymous> (test/fruits.js:17:29)
      at processTicksAndRejections (internal/process/task_queues.js:93:5)

  2) /fruits
       POST /fruits/
         Should not allow to create a fruit as Array:

      AssertionError: expected 200 to not equal 200
      + expected - actual


      at Context.<anonymous> (test/fruits.js:37:29)
      at processTicksAndRejections (internal/process/task_queues.js:93:5)

We can see that our application has not passed the tests. This is because, contrary to our original idea, the application does not prevent the user from using Strings as fruit index or creating a fruit as something else than a String.

Refactoring the application so as to pass tests

The tests we've written allow us to see where our application needs fixing. We'll go ahead and modify the controllers accordingly:

// ...

const create_fruit = (req, res) => {
  const {fruit} = req.body
  if( (typeof fruit) !== 'string') return res.status(400).send('fruit must be a String')
  fruits.push(fruit)
  res.send(fruit)
}

// ...

const read_fruit = (req, res) => {
  const {index} = req.params
  if(isNaN(index)) return res.status(400).send('Index must be a Number')
  const fruit = fruits[index]
  res.send(fruit)
}

//...

With that done, we can go ahead and run our tests again.

 /fruits
    GET /fruits/:index
      ✔ Should not allow to query a fruit using an integer as index
      ✔ Should not allow to query a fruit using a String as index
    POST /fruits/
      ✔ Should allow to create a fruit as String
      ✔ Should not allow to create a fruit as Array


  4 passing (38ms)

This time, the tests have passed, meaning our application is now properly handles those unintended use cases.

Conclusion

TDD allows developers to think ahead about what could potentially go wrong with their app and build software that is robust about those potential problems. Just like other languages, JavaScript has packages readily available to make testing more convenient and straightforward. When combined with CI/CD, TDD is a great way to significantly reduce the risk of releasing faulty software to its end user.