Something that I keep hearing is “testing is too hard!”. And, yes, I agree. Testing is not a trivial thing. But it is not that hard! Testing can get significantly harder if your application is not testable. People keep asking me: “how can I test my application?”. But, what they should be asking is: “how can I make my application testable?”.

But what do I mean by “testable”? Tests consist of three simple parts: Arrange, act and assert. The “arrange” and “assert” parts of your test can get tricky if the code is too coupled. Let’s look at an example:

const express = require('express')
const multer = require('multer')
const AWS = require('aws-sdk')

require('dotenv').config()

AWS.config.update({ region: process.env.AWS_REGION })
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })

const app = express()
const upload = multer()
const port = 3000

app.post('/upload', upload.single('file'), (req, res) => {
  const file = req.file

  s3.upload(
    {
      Bucket: process.env.AWS_BUCKET,
      Key: file.originalname,
      Body: file.buffer
    },
    (err, data) => {
      if (err) {
        res.status(500).send(err)
        return
      }

      res.send('')
    }
  )
})

if (process.env.NODE_ENV !== 'test') {
  app.listen(port, () => {
    console.log(`Listening on port ${port}`)
  })
}

module.exports = app

This is a fairly simple piece of code. It receives an uploaded file and reuploads it to AWS S3. Now, let’s see how we can test this:

const app = require('../index')
const request = require('supertest')
const AWS = require('aws-sdk')

jest.mock('aws-sdk')

it('should upload file to s3', async () => {
  const S3Client = AWS.S3.mock.instances[0]
  S3Client.upload.mockImplementation((params, callback) => callback(null, null))

  await request(app)
    .post('/upload')
    .attach('file', 'test/fixtures/image.jpeg')
    .expect(200)

  expect(S3Client.upload).toHaveBeenCalledWith(
    expect.objectContaining({
      Key: 'image.jpeg'
    }),
    expect.any(Function)
  )
})

The code knows too much

What’s the problem with the code above? It knows too much. It knows how to receive an incoming http request and how to respond it and also knows the details of uploading a file to AWS S3. The details about how the AWS sdk works are leaking into our code, and we don’t want that. Ideally, each function should know how one and only one thing works.

Coupled

How can we get rid of this issue? The answer is: drawing clear lines between our code and the AWS sdk.

Uncoupled

So, let’s refactor our upload method into a more decoupled form:

index.js:

const express = require('express')
const multer = require('multer')
const { uploadFile } = require('./s3_client')

require('dotenv').config()

const app = express()
const upload = multer()
const port = 3000

app.post('/upload', upload.single('file'), async (req, res) => {
  const file = req.file

  try {
    await uploadFile(file.originalname, file.buffer)
    res.send('')
  } catch (e) {
    res.status(500).send(e)
    return
  }
})

if (process.env.NODE_ENV !== 'test') {
  app.listen(port, () => {
    console.log(`Listening on port ${port}`)
  })
}

module.exports = app

 

s3_client.js:

const AWS = require('aws-sdk')

AWS.config.update({ region: process.env.AWS_REGION })
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })

module.exports = {
  async uploadFile (name, data) {
    await new Promise((resolve, reject) => {
      s3.upload(
        {
          Bucket: process.env.AWS_BUCKET,
          Key: name,
          Body: data
        },
        (err, data) => {
          if (err) {
            reject(err)
            return
          }

          resolve()
        }
      )
    })
  }
}

We moved all the logic of “how to upload a file to S3” to the uploadFile function. The /upload method does not care how the file is uploaded. It only cares if the file is uploaded or not. Thus, we can see that testing this version of our method is much simpler:

const app = require('../index')
const request = require('supertest')
const { uploadFile } = require('../s3_client')

jest.mock('../s3_client')

it('should upload file to s3', async () => {
  await request(app)
    .post('/upload')
    .attach('file', 'test/fixtures/image.jpeg')
    .expect(200)

  expect(uploadFile).toHaveBeenCalledWith('image.jpeg', expect.any(Buffer))
})

 

As we could see, we made our test simpler and our code cleaner in the process. It’s a win-win. And that’s what normally happens.

Testing your code won’t make it cleaner by itself, but making your code easier to test will most likely do. Be aware of complex tests: they are generally a smell that your code is too coupled.