Collin
Collin

Reputation: 409

What's the best way to test express.js API

I'm new in API testing with JavaScript. I've found many solution for testing REST APIs, but not sure what's the best. Im using express.js in the backend and for the tests jest.

I've seen that I can test with jest, by mocking the function or that I can also mock the API directly.

I've a data.js where the object is stored:

let data = [
{
    id: 0,
    name: "empty shopppinglist",
    location: "",
    targetDate: "",
    priority: "",
    isFinished: false,
    items: ["vodka"
    ]
}
]
module.exports = data

Then in the Backend Folder I have this to provide the endpoint and read / return the file:

function getData(){
  let shoppingLists = data
  return shoppingLists
}
module.exports.getData = getData
let shoppingLists = data



app.get('/api/shoppingLists', (req, res) => {
   const listsWithoutItems = getData().map(({ items, ...rest }) => rest)
   res.status(200).send(listsWithoutItems)
})

I'm here also not sure, if I can move the app.get call to a function.

In my test, I'd like to test the behaviour of the API, so if invalid data, that I will get an error 500 etc.. For this I've tried with this test:

describe('Get Endpoint for all Lists', () => {

it('should return 2 lists', async () => {
    myapp.getData = jest.fn(() => [
        {
            "id": 0,
            "name": "filled shopping list",
            "location": "lidl",
            "targetDate": "22.03.1986",
            "priority": "1",
            "isFinished": false,
            "items": ["vanille"
            ]
        }
    ])

    const res = await request(myapp)
        .get('/api/shoppingLists')
    expect(res.statusCode).toEqual(200)
    expect(res.body).toHaveProperty('2')
})
})

Unfortunately, I always get the original entry from data.js and not the mocked result. And by searching for documentation, I've also seen, that I can mock the whole app.js where my API is defined. But now I'm not sure what is the better way to do that.

Upvotes: 2

Views: 2896

Answers (2)

I think I've reached the setup I wanted:

  • await everywhere, no infinitely deeply nested callbacks
  • no external libraries besides mocha and express, an analogous setup would work for other systems
  • no mocking. No mocking extra effort. It just starts a clean server on a random port for each test, and closes the server at the end of the test, exactly like the real thing

Not shown in this example, you would want to finish things of by running the app with a temporary in-memory SQLite database unique to every test when NODE_ENV=test is used. The real production server would run on something like PostgreSQL, and an ORM like sequelize would be used so that the same code works on both. Or you could have a setup that creates the DB once and truncates all tables before each test.

app.js

#!/usr/bin/env node

const express = require('express')

async function start(port, cb) {
  const app = express()
  app.get('/', (req, res) => {
    res.send(`asdf`)
  })
  app.get('/qwer', (req, res) => {
    res.send(`zxcv`)
  })
  return new Promise((resolve, reject) => {
    const server = app.listen(port, async function() {
      try {
        cb && await cb(server)
      } catch (e) {
        reject(e)
        this.close()
        throw e
      }
    })
    server.on('close', resolve)
  })
}

if (require.main === module) {
  start(3000, server => {
    console.log('Listening on: http://localhost:' + server.address().port)
  })
}

module.exports = { start }

test.js

const assert = require('assert');
const http = require('http')

const app = require('./app')

function testApp(cb) {
  return app.start(0, async (server) => {
    await cb(server)
    server.close()
  })
}

// https://stackoverflow.com/questions/6048504/synchronous-request-in-node-js/53338670#53338670
function sendJsonHttp(opts) {
  return new Promise((resolve, reject) => {
    try {
      let body
      if (opts.body) {
        body = JSON.stringify(opts.body)
      } else {
        body = ''
      }
      const headers = {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(body),
        'Accept': 'application/json',
      }
      if (opts.token) {
        headers['Authorization'] = `Token ${opts.token}`
      }
      const options = {
        hostname: 'localhost',
        port: opts.server.address().port,
        path: opts.path,
        method: opts.method,
        headers,
      }
      const req = http.request(options, res => {
        res.on('data', data => {
          let dataString
          let ret
          try {
            dataString = data.toString()
            if (res.headers['content-type'].startsWith('application/json;')) {
              ret = JSON.parse(dataString)
            } else {
              ret = dataString
            }
            resolve([res, ret])
          } catch (e) {
            console.error({ dataString });
            reject(e)
          }
        })
        // We need this as there is no 'data' event empty reply, e.g. a DELETE 204.
        res.on('end', () => resolve([ res, undefined ]))
      })
      req.write(body)
      req.end()
    } catch (e) {
      reject(e)
    }
  })
}

it('test root', () => {
  // When an async function is used, Mocha waits for the promise to resolve
  // before deciding pass/fail.
  return testApp(async (server) => {
    let res, data

    // First request, normally a POST that changes state.
    ;[res, data] = await sendJsonHttp({
      server,
      method: 'GET',
      path: '/',
      body: {},
    })
    assert.strictEqual(res.statusCode, 200)
    assert.strictEqual(data, 'asdf')

    // Second request, normally a GET to check that POST.
    ;[res, data] = await sendJsonHttp({
      server,
      method: 'GET',
      path: '/',
      body: {},
    })
    assert.strictEqual(res.statusCode, 200)
    assert.strictEqual(data, 'asdf')
  })
})

it('test /qwer', () => {
  return testApp(async (server) => {
    let res, data
    ;[res, data] = await sendJsonHttp({
      server,
      method: 'GET',
      path: '/qwer',
      body: {},
    })
    assert.strictEqual(res.statusCode, 200)
    assert.strictEqual(data, 'zxcv')
  })
})

package.json

{
  "name": "tmp",
  "version": "1.0.0",
  "dependencies": {
    "express": "4.17.1"
  },
  "devDependencies": {
    "mocha": "6.2.2"
  },
  "scripts": {
    "test": "mocha test test.js"
  }
}

With this, running:

npm install
./app

runs the server normally as desired. And running:

npm test

makes the tests work as desired. Notably, if you hack any of the asserts to wrong values, they will throw, the server closes without hanging, and at the end failing tests show as failing.

The async http requests are also mentioned at: Synchronous request in Node.js

Tested on Node.js 14.17.0, Ubuntu 21.10.

Upvotes: 2

Estus Flask
Estus Flask

Reputation: 222334

It's impossible to mock getData as myapp.getData. module.exports.getData = getData doesn't serve a good purpose for testing because it allows to mock getData only if it's used everywhere as exports.getData() instead of getData(), which is impractical.

data needs to be mocked in a module it's defined. Then all modules that depend on it should be re-imported per test in order to be affected by a mock. jest.isolateModules or jest.resetModules can be used in conjunction with require to provide test-specific module mocks.

beforeEach(() => {
  jest.resetModules();
});

it('should return 2 lists', async () => {
  jest.mock('.../data', () => [...]);
  const myapp = require('.../app');

  const res = await request(myapp)
  ...
});

Upvotes: 0

Related Questions