Testing Javascript

Main Job of

Testing Frameworks

 

  • make error beautiful
  • make errors useful
  • identify where the bug occured
    • proper line number

= To quickly identify bug

Expect

function expect(actual){
  return {
    toBe(expected){
      if(result!==expcted){
      	throw new Error(`${actual} is not equal to ${expected}`)
      }
    },
    toEqual(expected){},
    toBeGreaterThan(expected){}
  }
}
  • simpler than multiple if statements
  • chainable
const result = sum(4,5)
expected = 9
expect(result).toBe(expected)
const result = sum(4,5)
expected = 9

if(result!==expected){
  throw new Error(`${result} is not equal to ${expected}`)
}

test

async function test(title, callback){
  try{
    await callback() // async because callback can be a promise
    console.log(`✅ ${title}`)
  }catch(err){
    console.error(`❌ ${title}`)
    console.error(err)
  }
}
  • run multiple tests
  • better error message
test('sum adds two numbers',()=>{
  const result = sum(2,4)
  const expected = 7
  expect(result).toBe(expected)
})

test('sum adds two numbers',()=>{
  const result = sum(2,4)
  const expected = 6
  expect(result).toBe(expected)
})
const result = sum(2,4)
expected = 7
expect(result).toBe(expected) 
// --- stops here because of error ---

const result = sum(2,4)
expected = 6
expect(result).toBe(expected)

Mocking

monkey patching

const thumbwar = require('./thumbwar')
const utils = require('./utils')

test('returns winner',()=>{
  const originalGetWinner = utils.getWinnner
  utils.getWinner = (p1,p2) => p1 //<--- main patching
  
  const winner = thumbwar('A','B')
  expect(winner).toBe('A')
  
  //cleanup
  utils.getWinner = originalGetWinner
})

to avoid implementation detail

jest.fn

const thumbwar = require('./thumbwar')
const utils = require('./utils')

test('returns winner',()=>{
  const originalGetWinner = utils.getWinnner
  utils.getWinner = jest.fn((p1,p2) => p1)
  
  const winner = thumbwar('A','B')
  expect(winner).toBe('A')
  expect(utils.getWinner).toHaveBeenCalledTimes(2)
  expect(utils.getWinner).toHaveBeenCalledWith('A','B')
  expect(utils.getWinner.mock.calls).toEqual([
    ['A','B']
  ])
  
  //cleanup
  utils.getWinner = originalGetWinner
})

  • keeps track of what argument it is called with, etc
function fn(impl){
  const mockFn = (...args){
    mockFn.mock.calls.push(args)
    impl(...args)
  }
  mockFn.mock = {calls:[]}
  return mockFn
}

jest.spyOn

test('returns winner',()=>{
  const originalGetWinner = utils.getWinnner
  utils.getWinner = jest.fn((p1,p2) => p1)
  
  //expect()
  
  //cleanup
  utils.getWinner = originalGetWinner
})

  • keeping track of original function is annoying
function fn(impl=()=>{}){
  const mockFn = (...args){
    mockFn.mock.calls.push(args)
    impl(...args)
  }
  mockFn.mock = {calls:[]}
  mockFn.mockImplementation = newImpl => (impl = newImpl) 
  return mockFn
}

function spyOn(obj, prop){
  const originalValue = obj
  obj[prop]= fn()
  obj[prop].mockRestore = ()=>{obj[prop] = originalValue}
}
test('returns winner',()=>{
  jest.spyOn(utils,'getWinner')
  utils.getWinner.mockImplementation((p1,p2) => p1))
  
  //expect
  
  //cleanup
  utils.getWinner.mockRestore()
})

Beware that mockFn.mockRestore only works when the mock was created with jest.spyOn. Thus you have to take care of restoration yourself when manually assigning jest.fn()

jest.mock

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

test('returns winner',()=>{
  //expect()
  
  //cleanup
  utils.getWinner.mockReset()
})

  • to mock a module
const utilsPath = require.resolve('../utils')
require.cache[utilsPath] = {
  id:utilsPath,
  filename: utilsPath,
  loaded:true,
  exports:{
    getWinner: fn((p1,p2)=>p1)
  }
}

// clean up
delete require.cache[utilsPath]
test('returns winner',()=>{
  jest.spyOn(utils,'getWinner')
  utils.getWinner.mockImplementation((p1,p2) => p1))
  
  //expect
  
  //cleanup
  utils.getWinner.mockReset()
})

__mocks__

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

test('returns winner',()=>{
  //expect()
  
  //cleanup
  utils.getWinner.mockReset()
})

  • global mocking
  • no need to provide custom implementation again & again
//__mocks__/utils.js
module.exports = {
  getWinner: jest.fn((p1,p2)=>p1)
}
jest.mock('..utils/')

test('returns winner',()=>{
  //expect()
  
  //cleanup
  utils.getWinner.mockReset()
})

Tearing Down

type action
mockFn.mockClear()
jest.clearAllMocks()
clears calls, instances, results
mockFn.mockReset()
jest.resetAllMocks()
mockClear() + clear return values & implementation
mockFn.mockRestore()
jest.restoreAllMocks()
 
mockReset() + restore to original

Testing Backend

1. Pure Function:

describe('isPassWordAcceptable',()=>{
  test('valid password',()=>{
    expect(isPasswordAcceptable('Aabc!')).toBeTruthy()
  })
  test('too short',()=>{
    expect(isPasswordAcceptable('Aab!')).toBeFalsy()
  })
  test('no uppercase',()=>{
    expect(isPasswordAcceptable('aabc!')).toBeFalsy()
  })
  test('no lowercase',()=>{
    expect(isPasswordAcceptable('AABC!')).toBeFalsy()
  })
})
describe('isPassWordAcceptable',()=>{
  allowedPasswords = ['Aabc!']
  disallowedPasswords = ['Aab!','aabc!','AABC!']
  allowedPasswords.forEach(password=>{
    test('valid password',()=>{
    	expect(isPasswordAcceptable(password)).toBeTruthy()
  	})
  })
  disallowedPasswords.forEach(password=>{
    test('valid password',()=>{
    	expect(isPasswordAcceptable(password)).toBeFalsy()
  	})
  })
})

✅ 1st idea -> use loop

❌ Lot of Duplicate code

ways to handle multiple conditions

const disallowedPasswords = [
  {
    reason: "too short",
    value: "123",
  },
  {
    reason: "too big",
    value: "1234567",
  },
];
const allowedPassowords = ["1234", "123456", "12345"];
test.each(disallowedPasswords)("$reason", ({ password }) => {
  expect(isPasswordAcceptable(password)).toBeFalsy();
});
test.each(allowedPassowords)("valid", (password) => {
  expect(isPasswordAcceptable(password)).toBeTruthy();
});

✅ 2nd idea -> use jest's inbuilt test.each

❌ But since reasons not present, error msgs are not helpful

describe('isPassWordAcceptable',()=>{
  allowedPasswords = ['Aabc!']
  disallowedPasswords = ['Aab!','aabc!','AABC!']
  disallowedPasswords.forEach(password=>{
    test('valid password',()=>{
    	expect(isPasswordAcceptable(password)).toBeFalsy()
  	})
  })
})

2. Middlewares

2. Generating data for multiple branch

const getMyTestObj = (overrides) => ({
  error: jest.fn(),
  req: jest.fn(),
  res: jest.fn(),
  next: jest.fn(),
  ...overrides,
})
describe('error-middleware', () => {
  test('headerssent', () => {
    const {error, req, res, next} = getMyTestObj({res: {headersSent: true}})
    errorMiddleware(error, req, res, next)
    expect(next).toHaveBeenCalledWith(error)
    ...
  })
  test('error instanceof UnauthorizedError', () => {
   const {error, req, res, next} = getMyTestObj({
      error: new UnauthorizedError('401', {message: 'Not authorized'}),
      res: {headersSent: false, status: jest.fn(), json: jest.fn()},
    })
    errorMiddleware(error, req, res, next)
    expect(res.status).toHaveBeenCalledWith(401)
    ...
  })
})

use factory function

❌ difficult to know what changed b/w test

describe('error-middleware', () => {
  test('headerssent', () => {
    const error = jest.fn()
    const req = jest.fn()
    const res = {headersSent: true}
    const next = jest.fn()
    errorMiddleware(error, req, res, next)
    expect(next).toHaveBeenCalledWith(error)
    ...
  })
  test('error instanceof UnauthorizedError', () => {
    const error = new UnauthorizedError('401', {message: 'Not authorized'})
    const req = jest.fn()
    const res = {headersSent: false, status: jest.fn(), json: jest.fn()}
    const next = jest.fn()
    errorMiddleware(error, req, res, next)
    expect(res.status).toHaveBeenCalledWith(401)
    ...
  })
})

3. Controllers

  • often interact with database & other services
  • collection of middleware that applies some business logic

Snapshot Testing

  • Big snapshots are difficult to maintain; Take small, focused snapshots

References

  • What all is available in global scope: (eq: describe, test):
    • https://jestjs.io/docs/api
  • Assertions (expect().toBe()):
    • https://jestjs.io/docs/expect
  •  

Service Worker

- Offline jobs

- Notification

- Caching

- intercept n/w request

- prefetch stuff

Web Worker

- Extra pair of hands

- Computation

Browser APIs

Both run worker context.

Therefore non-blocking

Service Worker

- proxy that sits b/w browser & network

Web Worker

- scripts that run in background

// main thread
var myWorker = new Worker('multiplyWorker.js');
myWorker.postMessage([first.value, second.value])
          
// multiplyWorker.js
onmessage = function(e) {
  console.log('Message received from main script');
  var workerResult =  (e.data[0] * e.data[1]);
  console.log('Posting message back to main script');
  postMessage(workerResult);
}

// main thread
myWorker.terminate();
Made with Slides.com