Skip to content

RamIdeas/mockingbird

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Mockingbird

Easily mock internal references

This babel plugin was written to lessen the effort to write code just so it is testable.

It, hopefully, let's you write your js modules more naturally while keeping 100% coverage a possibilty.

Getting Started

Install it from npm:

npm install babel-plugin-mockingbird

Add it to your babelrc only for test environments:

const TEST_CONFIG = {
    presets: [
        // ...
    ],
    plugins: [
        // ...
        'babel-plugin-mockingbird',
        // ...
    ],
};

modules.exports = function() {
    if (process.env.NODE_ENV === 'test') return TEST_CONFIG;

    return CONFIG;
};

Reasoning:

Consider this module:

// calculate.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;

export default function calculate(a, b, operator) {
    switch (operator) {
        case '+':
            return add(a, b);
        case '-':
            return subtract(a, b);
        case '*':
            return multiply(a, b);
        case '/':
            return divide(a, b);
    }
}

In this contrived example, you could write tests for just calculate but, imagining a more complex example, you would ideally write unit-tests for all 5 functions. The "non-ideal" examples below demonstrate the possible avenues devs take to reach this goal.

Non-ideal option 1:

Just export the internal functions for testing, but calculate's tests will still depend on them. It's hard to You can't mock them since calculate holds a reference to them.

Non-ideal option 2:

Pull the internal functions into another module and mock that. This can deter devs from abstacting logic from large functions into smaller functions and adds cognitive overhead to anyone reading the code as the logic is no longer co-located. Testing is rather convoluted as you have to reset the module cache, mock the function and then import the module with Node's require.

// calculate.js
import add from './add';
import subtract from './subtract';
import multiply from './multiply';
import divide from './divide';

export default function calculate(a, b, operator) {
    switch (operator) {
        case '+':
            return add(a, b);
        case '-':
            return subtract(a, b);
        case '*':
            return multiply(a, b);
        case '/':
            return divide(a, b);
    }
}
// add.js
export default (a, b) => a + b;
// subtract.js
export default (a, b) => a - b;
// multiply.js
export default (a, b) => a * b;
// divide.js
export default (a, b) => a / b;
// calculate.spec.js
test('add method is called', () => {
    jest.resetModules();
    const addMock = jest.fn();
    jest.mock('add', addMock);
    const calculate = require('./calculate').default;

    calculate(1, 2, '+');

    expect(addMock).toHaveBeenCalledWith(1, 2);

    jest.unmock('add');
});

Non-ideal option 3:

Add these functions to an object which can be mutated during tests. This just adds unnecessary complexity to your code -- implementation and tests.

// calculate.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;

export default function calculate(a, b, operator) {
    switch (operator) {
        case '+':
            return internalMethods.add(a, b);
        case '-':
            return internalMethods.subtract(a, b);
        case '*':
            return internalMethods.multiply(a, b);
        case '/':
            return internalMethods.divide(a, b);
    }
}

export const internalMethods = {
    add,
    subtract,
    multiply,
    divide,
};
// calculate.spec.js
import calculate, { internalMethods } from './calculate';

const originalMethods = { ...internalMethods };

afterEach(() => {
    Object.assign(internalMethods, originalMethods);
});

test('add method is called', () => {
    internalMethods.add = jest.fn();

    calculate(1, 2, '+');

    expect(internalMethods.add).toHaveBeenCalledWith(1, 2);
});

Non-ideal option 4:

Inject the functions via optional params. This also adds unnecessary complexity to your code, but allows cleaner test code, BUT leaks the ability to modify your functions behaviour in actual usage.

// calculate.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;

const _ = { add, subtract, multiply, divide };

export default function calculate(a, b, operator, { add: _.add, subtract: _.subtract, multiply: _.multiply, divide: _.divide }) {
  switch (operator) {
    case "+":
      return add(a, b);
    case "-":
      return subtract(a, b);
    case "*":
      return multiply(a, b);
    case "/":
      return divide(a, b);
  }
}
// calculate.spec.js
import calculate, { internalMethods } from './calculate';

test('add method is called', () => {
    const add = jest.fn();

    calculate(1, 2, '+', { add });

    expect(add).toHaveBeenCalledWith(1, 2);
});

Option using Mockingbird:

// calculate.ts
export declare const mockingbird;

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

export default function calculate(a, b, operator) {
    switch (operator) {
        case '+':
            return add(a, b);
        case '-':
            return subtract(a, b);
        case '*':
            return multiply(a, b);
        case '/':
            return divide(a, b);
    }
}
// calculate.spec.js
import calculate, { add, subtract, multiply, divide, mockingbird } from './calculate';

test('add method is called', () => {
    mockingbird.mock('add', jest.fn());

    calculate(1, 2, '+');

    expect(add).toHaveBeenCalledWith(1, 2);
});

Opt In (off by default)

You have to opt-in for each file to get transpiled with babel-plugin-mockingbird. These are the possible opt-in statements:

  • TypeScript (removed by the typescript preset if this plugin is not used)
    • export declare const mockingbird: Mockingbird;
    • export declare let mockingbird: Mockingbird;
    • export declare var mockingbird: Mockingbird;
    • export declare const mockingbird;
    • export declare let mockingbird;
    • export declare var mockingbird;
  • JavaScript (left in place when this plugin is not used but won't cause any issues)
    • export let mockingbird;
    • export var mockingbird;

How it works

In a nutshell, this plugin:

  1. Changes all top-level const declarations to let
  2. Assigns the Mockingbird object to the opt-in declaration

Note: There shouldn't be any concern around (1), as let/const have the same semantics other than reassignment which will be caught by Babel during regular builds

API

Coming soon. You can look at the TypeScript definitions for the available methods in the meanwhile.

Remember to call mockingbird.unmockAll() in your afterEach to avoid mocks within one test affecting the next.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published