Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mikahimself/158bd17454750f38b98e40659751ef78 to your computer and use it in GitHub Desktop.
Save mikahimself/158bd17454750f38b98e40659751ef78 to your computer and use it in GitHub Desktop.
Notes from Javascript Testing Practices and Principles course on FrontendMasters.com

Javascript Testing Practices and Principles

Material available on GitHub.

Introduction

Creating a very simple assertion library

You can create a simple assertion library by creating a function that returns an object that holds a method. This lets us use the expect(result).toBe(expectedResult) structure. However, this solution will only run the first item, and it also hides the exact location of any errors that pop up.

let result = sum(3, 7)
let expected = 10
expect(result).toBe(expected);

result = subtract(7, 3)
expected = 10
expect(result).toBe(expected);


function expect(actual) {
  return {
    toBe(expected) {
      if (actual !== expected) {
            throw new Error(`Expected result to be ${expected}, got ${actual} instead.`)
      }
    }
  }
}

Creating a very simple testing framework

We can improve upon our little assertion library by adding a simple framework around it. The framework, or a test function, wraps the tests it runs inside a try-catch block to prevent errors from stopping the program. The test function accepts two arguments, a title and a callback function. The title is the name of the test that's being run and the callback function contains the setup of the test and a call to the expect function:

function test(title, callback) {
  try {
    callback()
    console.log(`✔️ ${title}`)
  } catch (error) {
    console.error(`❌ ${title}`)
    console.error(error)
  }
}

test('subtract subtracts numbers', () => {
  const result = subtract(7, 3)
  const expected = 4
  expect(result).toBe(expected);
})

This lets the users know which tests produced errors and which ones ran successfully, as well as providing a stack trace that provides more details.

Unit testing with Jest

Jest lets you use several types of assertions when running tests:

toBe

The toBe assertion is similar to ===. You can use it to check if primitive values match, or check if two variables refer to the same object. Note that even if two objects that have been created separately have the same property values, they will not match with toBe as they are two different objects.

test('toBe', () => {
  expect(1).toBe(1)
  expect(true).toBe(true)
  expect({}).not.toBe({})
])

toEqual

If you need to compare objects (or arrays), you can use the toEqual assertion. This lets you match two separate objects that are "visually" identical.

test('toEqual', () => {
  const subject = { a: { b: 'c' }, d: 'e' }
  const actual =  { a: { b: 'c' }, d: 'e' }
  expect(subject).toEqual(actual)
  
  const subArray = [1, 2, { three: 'four', five: { six: 7 }}]
  const actArray = [1, 2, { three: 'four', five: { six: 7 }}]
  expect(subArray).toEqual(actArray)
})

toMatchObject

In case you need to check for partial matches with objects or arrays, you can use the toMatchObject assertion. This assertion passes if the subject contains at least the properties that are defined in the object/array it is being compared against.

test('toMatchObject', () => {
  const subject = { a: { b: 'c' }, d: 'e' }
  const actual =  { a: { b: 'c' }}
  expect(subject).toMatchObject(actual)
  
  const subArray = [1, 2, { three: 'four', five: { six: 7 }}]
  const actArray = [1, 2, five: { six: 7 }}]
  expect(subArray).toMatchObject(actual)
})

Schemas

When comparing objects, Jest lets you use schemas to check an object's structure in case you only want to check that, and not the actual property values:

test('using schemas', () => {
  const birthday = {
    day: 15,
    month: 04,
    year: 1984,
    meta: { display: 'Apr 15th, 1984' }
  }
  
  const schema = {
    day: expect.any(Number),
    month: expect.any(Number),
    year: expect.any(Number),
    meta: { display: expect.stringContaining('1984') }
    // there's also expect.arrayContaining() and expect.objectContaining()
  }
  expect(birthday).toEqual(schema)
})

Mock functions

You can use Jest to create mock functions to be used in tests. The mock function object contains plenty of data about how the mock function was used during the tests:

test('mock functions', () => {
  const myFn = jest.fn()
  myFn('first', { second: 'value' })
  
  const allCalls  = myFn.mock.calls
  const firstCall = allCalls[0]
  const firstArg  = firstCall[0]
  const secondArg = firstCall[1]
  
  expect(firstArg).toBe('first')
  expect(secondArg).toEqual({ second: 'value' })

})

Writing a simple test

Unit tests typically target small units of code, for example functions. Unit tests make sure that the function is behaving as expected and, for instance, returns correct, expected values.

import { isPasswordAllowed } from '../auth'

test('isPasswordAllowed rejects non-allowed passwords', () => {
  // Make sure all assertions get run. Useful especially with async testing
  expect.assertions(4)
  
  expect(isPasswordAllowed('')).toBe(false)
  expect(isPasswordAllowed('ffffffff')).toBe(false)
  expect(isPasswordAllowed('88888888')).toBe(false)
  expect(isPasswordAllowed('Nakkimaakari-77')).toBe(true)
})

In addition to using functionality that Jest provides, like the expect.assertions(), it is good practice to make sure the tests work and are run by breaking both the test AND source code when writing the tests.

The example above contains multiple similar assertions. To enhance readability, we can group these tests under a describe() block. We can also make the results more descriptive and the test capable of handling multiple inputs by creating a test factory using arrays and forEach.

describe('isPasswordAllowed', () => {
  const allowedPasswords = ['Nakkimaakari-77]
  const disallowedPasswords = ['', 'ffffffff', '88888888']
  
  allowedPasswords.forEach(pwd => {
    it(`"${pwd}" should be allowed`, () => {
      expect(isPasswordAllowed(pwd)).toBe(true)
    })
  }
  
  disallowedPasswords.forEach(pwd => {
    it(`"${pwd}" should not be allowed`, () => {
      expect(isPasswordAllowed(pwd)).toBe(false)
    })
  }

})

Code coverage

Most commonly used tool for managing tests' code coverage is Istanbul. To use it with Jest, simply add --coverage parameter to the Jest command in package.json.

Mocks

Monkey patching

Monkey patching is a quick and dirty way to create, for instance, mock functions. This involves importing (if the function is in a separate file) the function that needs to be mocked, storing the original function in a variable, and replacing it with a mocked version for the duration of the test. At the end of the test, you should revert back to the original function in order to not break other tests. Monkey patching is not a recommended practice, though, as it breaks the contract between the original code and the tests. For example, if someone were to update the getWinner function in the utils file by adding an extra parameter, the test below would still pass, but would have no connection to the updated getWinner function.

import thumbWar from '../thumb-war'
import * as utils from '../utils'

test('returns winner', () => {
  const originalGetWinner = utils.getWinner;
  utils.getWinner = (p1, p2) => p2;

  const winner = thumbWar('Ken Wheeler', 'Kent C. Dodds')
  expect(winner).toBe('Kent C. Dodds')

  utils.getWinner = originalGetWinner
})

Mock with parameter checking

To make sure that changing the number of parameters in a function does not break the link between the test and the actual code, you could do some assertions regarding the mocked function and keep tabs on how many arguments are passed into the function. Below, we add a mock object as a property to the getWinner function. Every time the function is called, we push the arguments passed to the function into the mock.calls array, so that we can check how many times the function was called, and to check what arguments were passed to the function. We also return the value from the args array. This way, if the position/number of the parameters changes in the original function, we'll notice that when the test breaks.

import thumbWar from '../thumb-war'
import * as utils from '../utils'

test('returns winner', () => {
  const originalGetWinner = utils.getWinner
  utils.getWinner = (...args) => {
    utils.getWinner.mock.calls.push(args)
    return args[1]
  }
  utils.getWinner.mock = { calls: [] }

  const winner = thumbWar('Ken Wheeler', 'Kent C. Dodds')
  expect(winner).toBe('Kent C. Dodds')
  expect(utils.getWinner.mock.calls).toHaveLength(2)
  utils.getWinner.mock.calls.forEach(element => {
    expect(element).toEqual(['Ken Wheeler', 'Kent C. Dodds'])
  });
  
  utils.getWinner = originalGetWinner
})

Using jest.spyOn

The jest.spyOn method lets you keep tabs on calls to a certain object and calls to a method within that object. In addition, you can use the mockImplementation() method to create a mock function to replace the original functionality. Using the jest.spyOn method, mocking the original function (and restoring it after the test) becomes quite a bit cleaner:

import thumbWar from '../thumb-war'
import * as utils from '../utils'

test('returns winner', () => {
  const spy = jest.spyOn(utils, 'getWinner').mockImplementation((p1, p2) => p2)
  
  const winner = thumbWar('Ken Wheeler', 'Kent C. Dodds')
  expect(winner).toBe('Kent C. Dodds')
  
  spy.mockRestore()
})

Note that even with jest.spyOn, we are still modifying the utils import namespace. This time around, Jest is the one doing the modifications. But since this is not exactly safe, we'll need another way, still, to handle mocking. This is where jest.mock comes in handy.

jest.mock()

The jest.mock method lets you define a module that you want to mock as its first argument, and define a function that returns a module with which you want to replace the original module as the second argument. And you can really think of the return value of the function as a completely new module that you would start building in a new file with the exception that instead of creating a module.exports or export default ... you are simply returning the items. This is made possible by the fact that Jest takes over the module system from Node, and every import statement goes through Jest first, letting Jest see which imports should be replaced with a mock. Any mocks you create in a test file only applies to the tests in that specific file.

import thumbWar from '../thumb-war'
import * as utils from '../utils'

jest.mock(
  '../utils',
  () => {
    return {
      getWinner: jest.fn((p1, p2) => p2)
    }
  }
)

test('returns winner', () => {
  const winner = thumbWar('Ken Wheeler', 'Kent C. Dodds')

  expect(winner).toBe('Kent C. Dodds')
  expect(utils.getWinner).toHaveBeenCalledTimes(2)
  utils.getWinner.mock.calls.forEach(args => {
    expect(args).toEqual(['Ken Wheeler', 'Kent C. Dodds'])
  })
})

When you create a mock version of a module, you don't have to mock each and every property and method in the module. Instead, you can easily use the original versions from the module for any items you don't need a mock for and just mock the ones you need. To do this, you use Jest's require.requireAll() method to require/import the original module, use that as the basic building block for the mock, and simply override the properties/methods that you need to.

import * as utils from '../utils'

jest.mock(
  '../utils',
  () => {
    const actualUtils = require.requireActual('../utils')
    return {
      ...actualUtils,
      getWinner: jest.fn((p1, p2) => p2)
    }
  }
)

If we were to run the test for getting the winner twice after mocking the getWinner() method, we would get an error in the second run, since the test checks how many times the method has been run. To avoid this, we can clear or reset the mock before every run:

beforeEach(() => {
  utils.getWinner.mockClear()
})

Using mocks folder

In addition to mocking out modules individually in files, Jest lets you create mocks that you can define once and use in multiple files. To do this, create a __mocks__ folder next to the __tests__ folder, and create a file with the same name as the module you want to mock inside it, and in the file, create the mock implementation of the file

Utils
|-- __mocks__
|  |-- utils.js
|
|-- __tests__
|  |--
|  |--
|
|-- utils.js

For any test files that want to use this mocked solution, run the jest.mock method without adding an implementation as a second parameter, and Jest will go look for the mocked implementation from the __mocks__ folder.

import * as utils from '../utils'

jest.mock('../utils')

Testing practices

Test object factories

When testing for instance Express applications, we need to create test objects like req and res to be used in the tests. These objects are often either identical, or very similar to each other. Instead of creating these objects again and again in each test, we can create a setup function that returns common versions of both objects that can be used in tests:

function setup() {
  const req = {
    body: {}
  }
  
  const res = {}
  Object.assign(res, {
    status: jest.fn(
      function status() {
        return this;
      }.bind(res),
    ),
    json: jest.fn(
      function json() {
        return this;
      }.bind(res),
    ),
    send: jest.fn(
      function send() {
        return this;
      }.bind(res),
    ),
  })
  return { req, res }
}

In our tests, we can the use the setup function to create our req and res objects, and customize them as needed:

test("it returns some stuff of other", () => {
  const { req, res } = setup();
  req.params = { id: "testUserId"}
  ...
})

Test Driven Development

A common work pattern for test driven development is the red-green-refactor pattern. First, you write as little code as possible to get a failing test, then write as little code as possible to get the test passing, and then refactor both the code and the test into something that resembles shippable code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment