DEV Community

Kane Ong
Kane Ong

Posted on • Updated on

A recommended way to do functional testing with K.O architecture

In my last article, I introduced a new architecture pattern to improve the productivity of express.js API development. Functional testing with the new architecture requires another article to discuss, so here it is.

How to read this article

It is recommended to read my previous article before continuing.

Demo project

Let's reuse the last project that we created.

GitHub logo dividedbynil / ko-architecture

A Minimalist Architecture Pattern for ExpressJS API Applications

K.O Architecture Demo

  • Framework: ExpressJS
  • Database: MongoDB
  • Authentication: JSON Web Token

Experiment data

APIs document

Postman APIs collection and environment can be imported from ./postman/

Pre-running

Update the ./config.js file

module.exports = {
  saltRounds: 10,
  jwtSecretSalt: '87908798',
  devMongoUrl: 'mongodb://localhost/kane',
  prodMongoUrl: 'mongodb://localhost/kane',
  testMongoUrl: 'mongodb://localhost/test',
}

Import experiment data

Open a terminal and run:

mongod

Open another terminal in this directory:

bash ./data/import.sh

Start the server with

npm start

Start development with

npm run dev



Prerequisite

Import testing libraries

npm i -D mocha chai

Here we choose mocha as the testing framework, chai as the assertion library.

Let's add a test script in package.json.

"scripts": {
  ...
  "test": "./node_modules/mocha/bin/mocha"
},

Unit testing

./common/response.js

function errorRes (res, err, errMsg="failed operation", statusCode=500) {
  console.error("ERROR:", err)
  return res.status(statusCode).json({ success: false, error: errMsg })
}

function successRes (res, data, statusCode=200) {
  return res.status(statusCode).json({ success: true, data })
}

...

The res arguments in the functions above are slightly tricky for unit testing. However, it is not difficult to mock res.

For example (the code below is using jest testing framework):

const { errData, errorRes, successRes } = require('./response')

// mocking res
const res = () => ({
  err: '',
  errMsg: '',
  statusCode: 0,
  result: {},
  status (code) {
    this.statusCode = code
    return this
  },
  json (obj) {
    this.result = obj
    return this
  }
})

describe('errorRes', () => {
  it('should return default error status code', () => {
    const obj = errorRes(res(), '')
    expect(obj.statusCode).toBe(500)
  })

  it('should return custom error status code', () => {
    const obj = errorRes(res(), '', '', 401)
    expect(obj.statusCode).toBe(401)
  })

  it('should return default errMsg', () => {
    const obj = errorRes(res(), '')
    expect(obj.result.error).toBe('failed operation')
  })

  it('should return custom errMsg', () => {
    const errMsg = 'invalid operation'
    const obj = errorRes(res(), '', errMsg)
    expect(obj.result.error).toBe(errMsg)
  })
})
...

common/crud.js

const { errData, errorRes, successRes } = require('../common/response')
const mongoose = require('mongoose')


function create (model, populate=[]) {
  return (req, res) => {
    const newData = new model({
      _id: new mongoose.Types.ObjectId(),
      ...req.body
    })
    return newData.save()
      .then(t => t.populate(...populate, errData(res)))
      .catch(err => errorRes(res, err))
  }
}

function read (model, populate=[]) {
  return (req, res) => (
    model.find(...req.body, errData(res)).populate(...populate)
  )
}
...

The model arguments in the functions above are not that simple to do mocking. Feel free to try it out and leave a code snippet in the comment if you made it.

A recommended way

A recommended way to test K.O architecture is to use an HTTP assertion library to do bottom-up integration testing. By doing so we can avoid creating complex mock functions.

Import http assertion library - supertest

npm i -D supertest

Let's create a minimal express app to test the functions in common/response.js.

test/response.test.js

const { errData, errorRes, successRes } = require('../common/response')
const request = require('supertest');
const bodyParser = require('body-parser');
const express = require('express');
const app = express();

app
.use(bodyParser.json())
.post('/errorRes', (req, res) => errorRes(res, ...req.body))
.post('/successRes', (req, res) => successRes(res, ...req.body))
.post('/errData', (req, res) => {
  const { errMsg, err } = req.body
  errData(res, errMsg) (err, { custom: true })
})

Using the post HTTP method is more convenient as we utilize req.body as the function argument carrier.

Let's test the express app.

test/response.test.js

function test (funcStr, args) {
  return request(app)
          .post('/'+funcStr)
          .send(args)
}

describe ('response', function () {
  describe('errorRes', function () {
    it('should return default status code & message', function (done) {
      test('errorRes', ['error'])
        .expect(500, {
          success: false,
          error: 'failed operation'
        }, done)
    })

    it('should return custom errMsg', function (done) {
      test('errorRes', ['error', 'test'])
        .expect(500, {
          success: false,
          error: 'test'
        }, done)
    })

    it('should return custom errMsg & status code', function (done) {
      test('errorRes', ['error', 'test', 401])
        .expect(401, {
          success: false,
          error: 'test'
        }, done)
    })
  })

  describe('successRes', function () {
    it('should return default status code & data', function (done) {
      test('successRes', [])
        .expect(200, {
          success: true,
          data: {}
        }, done)
    })

    it('should return custom data', function (done) {
      test('successRes', [{ custom: true }])
        .expect(200, {
          success: true,
          data: { custom: true }
        }, done)
    })

    it('should return custom data & status code', function (done) {
      test('successRes', [{ custom: true }, 201])
        .expect(201, {
          success: true,
          data: { custom: true }
        }, done)
    })
  })

  describe('errData', function () {
    it('should return default status code & error', function (done) {
      test('errData', { err: true })
        .expect(500, {
          success: false,
          error: 'failed operation'
        }, done)
    })

    it('should return default status code & data', function (done) {
      test('errData', { err: false })
        .expect(200, {
          success: true,
          data: { custom: true }
        }, done)
    })

    it('should return custom error message', function (done) {
      test('errData', { err: true, errMsg: 'custom' })
        .expect(500, {
          success: false,
          error: 'custom'
        }, done)
    })
  })
})

Let's create another minimal express app to test functions in common/crud.js.

test/curd.test.js

const { create, read, update, remove } = require('../common/crud')
const Restaurant = require('../models/Restaurant')
const User = require('../models/User')
const request = require('supertest')
const expect = require('chai').expect
const bodyParser = require('body-parser')
const express = require('express')
const mongoose = require('mongoose')
// check the updated github readme for example
const { testMongoUrl } = require('../config')
const app = express()

mongoose.connect(testMongoUrl, {
  useNewUrlParser: true,
  autoIndex: false,
  useFindAndModify: false,
  useUnifiedTopology: true,
})

app
.use(bodyParser.json())
.post('/create', create(Restaurant, ['owner']))
.post('/read', read(Restaurant, ['owner']))
.post('/update/:_id', update(Restaurant, ['owner']))
.post('/remove/:_id', remove(User))

Using a new mongo database (testMongoUrl) to do testing is more convenient to remove testing data after each test run and not interfere with existing data.

function test (funcStr, args) {
  return request(app)
          .post('/'+funcStr)
          .send(args)
}

describe('crud', function () {
  const user = {
    "name": "Test",
    "email": "test@test.com",
    "type": "admin",
    "password": "12345"
  }

  const restaurant = {
    "name": "Brand New Restaurant",
    "location": {
      "type": "Point",
      "coordinates": [-73.9983, 40.715051]
    },
    "available": true
  }

  const testUser = new User({
    _id: new mongoose.Types.ObjectId(),
    ...user
  })

  before(function (done) {
    testUser.save(done)
  })

  after(function (done) {
    mongoose.connection.db.dropDatabase(function(){
      mongoose.connection.close(done)
    })
  })

  describe('create', function () {
    it('should not create new restaurant without owner', function (done) {
      const data = restaurant
      test('create', data)
        .expect(500, done)
    })
    it('should return new restaurant and populate', function (done) {
      const data = { owner: testUser._id, ...restaurant }
      test('create', data)
        .expect(200)
        .expect(function (res) {
          restaurant._id = res.body.data._id
          expect(res.body.data).to.be.a('object')
          expect(res.body.data).to.deep.include(restaurant)
          expect(res.body.data.owner.name).to.equal('Test')
          expect(res.body.data.owner.password).to.equal(undefined)
        })
        .end(done)
    })
  })

  describe('read', function () {
    it('should return restaurant and populate', function (done) {
      const data = [{ "available": true, _id: restaurant._id }]
      test('read', data)
        .expect(200)
        .expect(function (res) {
          expect(res.body.data).to.be.a('array')
          expect(res.body.data[0]).to.deep.include(restaurant)
          expect(res.body.data[0].owner.name).to.equal('Test')
          expect(res.body.data[0].owner.password).to.equal(undefined)
        })
        .end(done)
    })
    it('should return no restaurant', function (done) {
      const data = [{ "available": false }]
      test('read', data)
        .expect(200)
        .expect(function (res) {
          expect(res.body.data.length).to.equal(0)
        })
        .end(done)
    })
  })

  describe('update', function () {
    it('should return updated restaurant and populate', function (done) {
      const { _id } = restaurant
      const data = { name: 'New name' }
      test('update/'+_id, data)
        .expect(200)
        .expect(function (res) {
          expect(res.body.data).to.be.a('object')
          expect(res.body.data).to.deep.include({...restaurant, ...data})
          expect(res.body.data.name).to.equal('New name')
          expect(res.body.data.owner.name).to.equal('Test')
          expect(res.body.data.owner.password).to.equal(undefined)
        })
        .end(done)
    })
  })

  describe('remove', function () {
    it('should remove user', function (done) {
      const { _id } = testUser
      test('remove/'+_id)
        .expect(200)
        .expect(function (res) {
          expect(res.body.data).to.be.a('object')
          expect(res.body.data).to.have.property('ok')
          expect(res.body.data.ok).to.equal(1)
        })
        .end(done)
    })
  })

})

Let's create another minimal express app to test functions in common/middleware.js.

test/middleware.test.js

const { notFound, onlyAdmin, notOnlyMember } = require('../common/middleware')
const request = require('supertest');
const bodyParser = require('body-parser');
const express = require('express');
const app = express();

app
.use(bodyParser.json())
.post('/notFound', notFound)
.post('/onlyAdmin', addUserType, onlyAdmin, notFound)
.post('/notOnlyMember', addUserType, notOnlyMember, notFound)

function addUserType (req, res, next) {
  req.user = { type: req.body.type }
  next()
}

function test (funcStr, args) {
  return request(app)
          .post('/'+funcStr)
          .send({ type: args })
}

describe('middleware', function () {
  describe('notFound', function () {
    it('should return not found', function (done) {
      test('notFound')
        .expect(404, done)
    })
  })

  describe('onlyAdmin', function () {
    it('should access', function (done) {
      test('onlyAdmin', 'admin')
        .expect(404, done)
    })
    it('should not access', function (done) {
      test('onlyAdmin', 'member')
        .expect(401, done)
    })
  })

  describe('notOnlyMember', function () {
    it('should access', function (done) {
      test('notOnlyMember', 'admin')
        .expect(404, done)
    })
    it('should access', function (done) {
      test('notOnlyMember', 'owner')
        .expect(404, done)
    })
    it('should not access', function (done) {
      test('notOnlyMember', 'member')
        .expect(401, done)
    })
  })
})

End-to-end testing

After testing all functions in the common folder, it's up to you to continue end-to-end testing in the mocha framework or use postman. I use postman as the environment variables and tests are easier to share among teammates.

Feel free to leave a comment if you have any questions.

Top comments (1)