/mockzen

Make any code testable!

Primary LanguageJavaScriptMIT LicenseMIT

mockzen

Your code isnt untestable, your testing tools are too rigid.

Introduction

Make any piece of code testable! Easily mock any dependencies in your code during testing

  • doesn't matter what paradigm you are using - no rearchitecture to DI and ioc containers required
  • doesn't matter the way you or your NPM dependencies import/export functions, classes, etc.
  • doesn't matter if function, class, instance of class, NPM library, variable, etc.
  • guaranteed mocking or immediate failure - no implicit behavior
  • requires minimal code changes

Just change your code from:

function getRandomFact() {
  const res = await fetch('https://catfact.ninja/fact')
}

to this (wrapping dependency variable in "dep"):

function getRandomFact() {
  const res = await dep(fetch)('https://catfact.ninja/fact')
}

or using code injection (see below):

function getRandomFact() {
  dep.injectable({ fetch })
  const res = await fetch('https://catfact.ninja/fact')
}

During runtime, the code will behave exactly as before!

But in the tests, you can overwrite its behavior. For this, register a mock:

function fakeFetch(url) {
  return {
    async json() {
      return { fact: 'hey'}
    }
  }
}

dep.register(fetch, fakeFetch)

If you did not register the mock, your test will fail, so there's no surprise about whether you correctly mocked something or not!

Get Started

Install:

npm install mockzen

In your test or global setup of your tests, turn on the requirement for mocks like this:

import { dep } from 'mockzen'

dep.enableTestEnv()

Alternatively, set the environment variable MOCKZEN_TEST_ENV to true or 1 for test runners like jest, which lack a global setup function that runs in the same process.

If you want to verify that mockzen is indeed looking up dependencies, add this assertion to your tests:

expect(dep.testEnvEnabled).toBe(true)

See below for setting up code injection.

Naming dependencies

There is no need to name dependencies that are functions or classes. For example:

dep(SomeService)
dep(someFunction)

But you need to name dependencies that can't be looked up using shallow comparison:

For example, this won't work because the variable "api" is not equal to "testApi":

// code
const api = new Api()
dep(api).doSomething()

// test
const testApi = new Api()
dep.register(testApi, /* */)

But your runtime code will still work just fine, and your test will still throw an error to inform you that there was a missing mock.

In such cases, give the dependency a custom name:

// code
const api = new Api()
dep('Api', api).doSomething()

// test
const testApi = new Api()
dep.register('Api', testApi)

Things you can mock

Absolutely anything! While it's recommended to only mock what is necessary, the library doesn't hinder you in any way.

// in code
const retryDelay = dep('retry delay', 10_000)

// in test
dep.register('retry delay', 1)

// all of this works too:
dep(Api) // to inline it: new (dep(API))()
dep('api', new Api)
dep('download', new Api().download)
dep('checks', [0, 2, 4, 8])

Skip mocking

Mocks are required by default. If you have tests that need something mocked only sometimes, disable the mocking requirement in a test like this:

it('...', async () => {
  dep.allow('api')
  dep.allow(fetch)

  // can now execute code without providing mock for api and fetch
  await doSomething()
})

Where to dep()

You need to apply dep() each time you interact with the dependency. To reduce the amount of wraps needed, apply "dep" at a lower level.

For example, instead of:

function handler1() {
  dep(factService).call()
}

function handler2() {
  dep(factService).call()
}

apply it in the FactService:

class FactService {
  call() {
    dep(fetch)('https://....')
  }
}

Alternatively, make dependencies auto-injectable to go from:

function getRandomFact() {
  const cachedFact = dep(redis).get('cats:fact') // 👈 dep() here
  if (cachedFact) {
    return cachedFact
  }
  const { fact } = await dep(fetch)('https://catfact.ninja/fact') // 👈 dep() here
  dep(redis).set('cats:fact', fact) // 👈 dep() here
  return fact
}

to this:

function getRandomFact() {
  dep.injectable({redis, fetch}) // 👈 This is the only change you need to do
  const cachedFact = redis.get('cats:fact')
  if (cachedFact) {
    return cachedFact
  }
  const { fact } = await fetch('https://catfact.ninja/fact')
  redis.set('cats:fact', fact)
  return fact
}

To make this experimental feature work, add the transformer to your configuration file.

jest

Add the following to your package.json or the respective code to your jest config file:

{
  "jest": {
    "transform": {
      "^.+\\.js$": "mockzen/transformers/jest"
    }
  }
}

Aliasing fields is also possible here:

dep.injectable({ MyService })

const apiClient = MyService.createApiClient()
dep.injectable({ 'apiAlias': apiClient }) // 👈 see how you can call dep.injectable multiple times as well.

Then in your tests, register mocks like this:

dep.register(MyService, MyServiceMock)
dep.register('apiAlias', MyServiceMock)

Testing Utilities

Generally, you can just have custom code to record when a function was called, how many times it was called, what arguments it used, etc.

let apiCalled = false

async function fakeCallApi() {
  apiCalled = true
  return true
}
dep.register(callApi, fakeCallApi)

await someCode()

expect(apiCalled).toBe(true)

But we can simplify this using the fake API:

const fakeCallApi = dep.fake(async () => true) // returns the promised value when called
dep.register(callApi, fakeCallApi)

await someCode()

expect(fakeCallApi.called).toBe(true)

fake

Create a fake function like this:

const fakeApi = dep.fake() // returns undefined when called
const fakeApi = dep.fake(() => true) // returns true when called
const fakeApi = dep.fake(async () => true) // returns a promised value when called

Next, register this fake function and use it in your assertions:

const fakeApi = dep.fake()
dep.register(callApi, fakeApi)

await doTheThing()

expect(fakeApi.called).toBe(true)
expect(fakeApi.callCount).toBe(1)
expect(fakeApi.firstCall.firstArg).toEqual('https://...')

You can access different calls through the following fields:

  • calls: an array of all calls
  • firstCall: holds details of the first call to the function
  • secondCall: holds details of the second call to the function
  • lastCall: holds details of the last call to the function

Each call has the following properties:

  • args: an array of arguments used to call the function
  • firstArg: the first argument
  • secondArg: the second argument
  • lastArg: the last argument

Emptying the registry

dep.reset()

Writing library code

If you are writing a library that will be integrated into other applications, create your own registry to not interfere with the application code:

// dep.js
import { createRegistry } from 'mockzen'
export const dep = createRegistry()
// now import and use this version of "dep" where ever you need it!

Note that the environment variable MOCKZEN_TEST_ENV does not affect custom registries. This is again so they don't interfere with application code. Please use the explicit dep.enableTestEnv()!

Use Cases

Assert function was called

const { dep } = require('mockzen')
const { callApi } = require('services/api')

it('will ...', async () => {
  const fakeApi = dep.fake()
  dep.register(callApi, fakeApi)
  
  await doTheThing()

  expect(fakeApi.called).toBe(true)
})

Mock a (static) class method

const fakeApi = dep.fake()
class FakeClass {
  callApi = fakeApi
}
dep.register(RealClass, FakeClass)

Return different mocks depending on the amount of times called

You also have the meta information available inside the callback for such scenarios!

const fakeFetch = dep.fake(() => {
  if (fakeFetch.callCount === 1) {
    // return for first function call
  }
  // return for subsequent function calls
})
dep.register(fetch, fakeFetch)

Return different mocks depending on the input arguments:

There is no special function for this, but it's straight forward to write your own:

async function fakeFetch(url) {
  if (url.endsWith('/user')) {
    // return ...
  }
  // return ...
}

dep.register(fetch, fakeFetch)

Validate the input arguments

it('will get a random fact', () => {
  const fakeFetch = dep.fake()
  dep.register(fetch, fakeFetch)

  getVideo()
  
  expect(fakeFetch.firstCall.firstArg).toEqual('http://...')
})