All Projects → zalando-incubator → Introscope

zalando-incubator / Introscope

Licence: mit
Automated mocking and spying tool for delightful ES6 modules testing

Programming Languages

javascript
184084 projects - #8 most used programming language

Introscope

A babel plugin and a set of tools for delightful unit testing of modern ES6 modules. It allows you to override imports, locals, globals and built-ins (like Date or Math) independently for each unit test by instrumenting your ES6 modules on the fly.

Scope example

scope example

inc.js

const ONE = 1; // notice, not exported
export const inc = a => a + ONE;

// @introscope "enable": true

inc.test.js

import { introscope } from './inc';

test('inc', () => {
    const scope = introscope();

    expect(scope.inc(1)).toBe(2);
    scope.ONE = 100; // just for lulz
    expect(scope.inc(1)).toBe(101);
});

Effects example

effects example

abc.js

const a = () => {};
const b = () => {};
const c = () => {};

export const abc = () => {
    a(1);
    b(2);
    c(3);
};

// @introscope "enable": true

abc.test.js

import { effectsLogger, SPY } from 'introscope/logger';
import { introscope } from './abc';

const loggedScope = effectsLogger(introscope);

test('abc', () => {
    const { scope, effects } = loggedScope({
        a: SPY,
        b: SPY,
        c: SPY,
    });

    scope.abc();

    expect(effects()).toMatchSnapshot();
});

Recorder example

recorder example

tempfile.js

const now = () => Date.now();
const rand = () => Math.random();

export const tempfile = () => `/var/tmp/${now()}-${rand()}`;

// @introscope "enable": true

tempfile.test.js

import { effectsLogger, RECORD } from 'introscope/logger';
import { introscope } from './tempfile';

const recordedScope = effectsLogger(introscope);

test('tempfile', () => {
    const { scope, recorder } = recordedScope({
        now: RECORD,
        rand: RECORD,
    });

    expect(scope.tempfile()).toMatchSnapshot();

    recorder.save();
});

tempfile.test.js.record

[['now', 1539533182792], ['rand', 0.20456280736087873]];

What so special?

Intoscope is yet another mocking tool, but with much higher level of control, isolation and performance:

  • easily test any stateful module: on every run you get a fresh module scope;
  • test faster with a fresh module in each test: no need to reset mocks, spies, logs, etc;
  • faster module loading: remove or mock any heavy import on the fly;
  • intercept any top level variable definition: crucial for higher order functions;
  • spy or mock with any tool: introscope() returns a plain JS object;
  • easy to use: optimized for Jest and provides well fitting tooling;
  • type safe: full support for Flow in your tests;
  • simple to hack: just compose the factory function with your plugin.

See what Introscope does with code in playground.

Known issues

Support for TypeScript using Babel 7 is planned.

Please, see a short ☺️ list here: issues labeled as bug

Longer description

TL;DR; no need to export all the functions/variables of your module just to make it testable, Introscope does it automatically by changing the module source on the fly in testing environment.

Introscope is (mostly) a babel plugin which allows a unit test code look inside an ES module without rewriting the code of the module. Introscope does it by transpiling the module source to a factory function which exposes the full internal scope of a module on the fly. This helps separate how the actual application consumes the module via it's exported API and how it gets tested using Introscope with all functions/variables visible, mockable and spy-able.

It has handy integration with Jest (tested with Jest v24.5.0 and Babel v7.4.0) and Proxy based robust spies. Support for more popular unit testing tools to come soon.

Usage

Installation from scratch looks like the following.

First, install the Jest and Babel powered test environment together with Introscope:

yarn add -D jest babel-jest @babel/core @babel/preset-env introscope
# or
npm install -D jest babel-jest @babel/core @babel/preset-env introscope

Second, edit .babelrc like this:

{
    "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]],
    "plugins": ["introscope/babel-plugin"]
}

The perameters to the @babel/preset-env preset are needed to make async/await syntax work and are not relevant to Introscope, it's just to make the modern JS code running.

Third, add this magic comment to the end of the module (or beginning, or anywhere you like) you are going to test:

// @introscope "enable": true

There is a way to avoid adding the magic comment, but it's fairly unstable and works only for older versions of Jest. If you badly hate adding little cure magic comments to your modules, please, help Introscope with making Jest team to get #6282 merged.

Done! You're ready to run some test (if you have any 😅):

yarn jest
# or
npm run env -- jest

Start using Introscope in tests:

import { introscope } from './tested-module';

// or using common modules
const { introscope } = require('./tested-module');

For safety reasons this plugin does nothing in non test environments, e.g. in production or development environment it's a no-op. Jest sets NODE_ENV to 'test' automatically. Please, see your favirite test runner docs for more.

Introscope supports all the new ES features including type annotations (if not, create an issue 🙏). That means, if Babel supports some new fancy syntax, Introscope should do too.

Detailed example

What Introscope does is just wraping a whole module code in a function that accepts one object argument scope and returns it with all module internals (variables, functions, classes and imports) exposed as properties. Here is a little example. Introscope takes module like this:

// api.js
import httpGet from 'some-http-library';

const ensureOkStatus = response => {
    if (response.status !== 200) {
        throw new Error('Non OK status');
    }
    return response;
};

export const getTodos = httpGet('/todos').then(ensureOkStatus);

// @introscope "enable": true

and transpiles it's code on the fly to this (comments added manually):

// api.js
import httpGet from 'some-http-library';

// wrapps all the module source in a single "factory" function
export const introscope = function(_scope = {}) {
    // assigns all imports to a `scope` object
    _scope.httpGet = httpGet;

    // also assigns all locals to the `scope` object
    const ensureOkStatus = (_scope.ensureOkStatus = response => {
        if (response.status !== 200) {
            // built-ins are ignored by default (as `Error` here),
            // but can be configured to be also transpiled
            throw new Error('Non OK status');
        }
        return response;
    });

    // all the accesses to locals get transpiled
    // to property accesses on the `scope` object
    const getTodos = (_scope.getTodos = (0, _scope.httpGet)('/todos').then(
        (0, _scope.ensureOkStatus),
    ));

    // return the new frehly created module scope
    return _scope;
};

You can play with the transpilation in this AST explorer example.

The resulting code you can then import in your Babel powered test environment and examine like this:

// api.spec.js

import { introscope as apiScope } from './api.js';
// Introscope exports a factory function for module scope,
// it creates a new module scope on each call,
// so that it's easier to test the code of a module
// with different mocks and spies.

describe('ensureOkStatus', () => {
    it('throws on non 200 status', () => {
        // apiScope() creates a new unaltered scope
        const { ensureOkStatus } = apiScope();

        expect(() => {
            ensureOkStatus({ status: 500 });
        }).toThrowError('Non OK status');
    });
    it('passes response 200 status', () => {
        // apiScope() creates a new unaltered scope
        const { ensureOkStatus } = apiScope();

        expect(ensureOkStatus({ status: 200 })).toBe(okResponse);
    });
});

describe('getTodos', () => {
    it('calls httpGet() and ensureOkStatus()', async () => {
        // here we save scope to a variable to tweak it
        const scope = apiScope();
        // mock the local module functions
        // this part can be vastly automated, see Effects Logger below
        scope.httpGet = jest.fn(() => Promise.resolve());
        scope.ensureOkStatus = jest.fn();

        // call with altered environment
        await scope.getTodos();
        expect(scope.httpGet).toBeCalled();
        expect(scope.ensureOkStatus).toBeCalled();
    });
});

Effects Logger

This module saves 90% of time you spend writing boiler plate code in tests.

Effects Logger is a nice helping tool which utilises the power of module scope introspection for side effects logging and DI mocking. It reduces the repetitive code in tests by auto mocking simple side effects and logging inputs and outputs of the tested function with support of a nicely looking custom Jest Snapshot serializer.

Example:

// todo.js
const log = (...args) => console.log(...args);
let count = 0;
const newTodo = (id, title) => {
    log('new todo created', id);
    return {
        id,
        title,
    };
};
const addTodo = (title, cb) => {
    cb(newTodo(++count, title));
};
// @introscope "enable": true

// todo.spec.js
import { introscope } from './increment.js';
import { effectsLogger, SPY, KEEP } from 'introscope/logger';

// decorate introscope with effectsLogger
const effectsScope = effectsLogger(introscope);

describe('todos', () => {
    it('addTodo', () => {
        const {
            scope: { addTodo },
            effects,
            m,
        } = effectsScope({
            newTodo: SPY,
            addTodo: KEEP,
        });

        // `m.cb()` creates and spies on a mock function with name `cb`
        addTodo('start use Introscope :)', m.cb());

        expect(effects()).toMatchSnapshot();
        /*
        EffectsLog [
          module.count =
            1,
          newTodo(
            1,
            "start use Introscope :)",
          ),
          log(
            "new todo created",
            1,
          ),
          cb(
            "new todo created",
            {
                id: 1,
                title: "start use Introscope :)",
            },
          ),
        ]
        */
    });
});

How does it work? It iterates over all the symbols (functions, locals, globals) in the scope returned by introscope() and for each function creates an empty mock. With symbols marked with KEEP it does nothing and for symbols marked as SPY it wraps them (there is also a RECORD type which plays returned values back, in beta now). All the mocks write to the same side effects log (plain array, btw) wchi then can be inspected manually or, better, sent to Jest's expect().matchSnaphot(). There is a custom serializer available to make log snapshots more readable.

Usage with React

JSX syntax is supported natively. No need for any additional configuration.

Usage with Flow

Configure Babel

For Introscope to work correctly it needs Flow type annotaions to be stripped, as we normally do to run code in node. To do so just put syntax-flow and transform-flow-strip-types plugins before introscope/babel-plugin:

{
    "plugins": [
        "syntax-flow",
        "transform-flow-strip-types",
        "introscope/babel-plugin"
    ]
}

Type safe tests

Firstly, if you just want to shut up Flow and it's ok for you to have any type in tests, then just export introscope from the tested module like this:

export { introscope } from 'introscope';

The function introscope has type {[string]: any} => {[string]: any}, so a scope created from this function will give type any for any property.

And in case you prefer strict type checking, here is an example on how to make flow getting the correct type for the introscope export:

import { scope } from 'introscope';
export const introscope = scope({
    constantA,
    functionB,
    // other identifiers of your module
});

If your project ignores node_modules with config like this:

[ignore]
.*/node_modules/.*

flow check will error out with such message:

Error--------------example.js:15:23
Cannot resolve module introscope.

there are two solutions:

  1. use flow-typed
yarn add -D flow-typed
yarn flow-typed install [email protected]
  1. just add this line to .flowconfig [libs] section:
[libs]
node_modules/introscope/flow-typed

Usage with other frameworks

To disable appending ?introscope to introscope imports add this babel plugin option: instrumentImports: false.

Magic comments

It's a very familiar concept from Flow, ESLint, etc.

Introscope can be configured using babel plugin config and / or magic comments. Here is the example of a magic comment:

// @introscope "enable": true, "removeImports": true

It's just a comment with leading @introscope substring followed by a JSON object body (without wrapping curly braces). Here is a list of avalable configuration options:

  • enable = true | false: per file enable / disable transpilation; if enable equals false Introscope will only parse magic comments and stop, so it's quite a good tool for performance optimisation on super large files;
  • removeImports = true | false: instucts introscope to remove all import diretives though keeping the local scope variables for the imports so a test can mock them;
  • ignore = [id1, id2, id3...]: a list of IDs (functions, variables, imports) introscope should not touch; this means if there was a local constant variable with name foo and the magic comment has ignore: ['foo'] than Introscope will not transform this variable to a scope property and the test could not change or mock the value; this is default for such globals like Date, Math, Array as testers normally do not care of those, but can be overritten with - prefix: // @introscope "ignore": ["-Date"], this will remove Date from ignore list and make it avalable for mocking/spying.

Babel plugin options

  • disable = true | false: disables plugin completely, useful in complex .babelrc.js configurations to make sure Introscope does not alter a build for some very specific environment;

FAQ

Is performance good?

Yes. The babel plugin does use only one additional traverse. All the variables look up logic is done by Babel parser for free at compile time.

TODOs

Imported values in curried functions

Currently, any call to a curried function during the initial call to the module scope factory will remember values from the imports. It's still possible to overcome this by providing an initial value to the scope argument with a getter for the desired module import. To be fixed by tooling in introscope package, not in the babel plugin.

Example:

import toString from 'lib';

const fmap = fn => x => x.map(fn);
// listToStrings remembers `toString` in `fmap` closure
const listToStrings = fmap(toString);

Importing live binding

Can be in principal supported using a getter on the scope object combined with a closure returning the current value of a live binding. To be implemented once the overall design of unit testing with Introscope becomes clear.

Example:

import { ticksCounter, tick } from 'date';
console.log(ticksCounter); // 0
tick();
console.log(ticksCounter); // 1

Module purity

Implement per module import removal to allow preventing any possible unneeded side effects.

Example:

import 'crazyDropDatabaseModule';

Or even worse:

import map from 'lodash';
// map() just maps here
import 'weird-monkey-patch';
// map launches missiles here

Support any test runner environment

Example:

To support simple require-from-a-file semantics the transformToFile function will transpile ./module to ./module-introscoped-3123123 and return the latter.

import { transformToFile } from 'introscope';
const moduleScopeFactory = require(transformToFile('./module'));

Or even simpler (but not easier):

import { readFileSync } from 'fs';
import { transform } from 'introscope';
const _module = {};
new Function('module', transform(readFileSync('./module')))(_module);
const moduleScopeFactory = _module.exports;

Learn from these masters:

https://github.com/babel/babel/blob/6.x/packages/babel-plugin-transform-es2015-modules-commonjs/src/index.js https://github.com/speedskater/babel-plugin-rewire/blob/master/src/babel-plugin-rewire.js

Prior art

  • Built-in per file mocking in Jest.
  • File based per module mocking for node modules: rewire.
  • Babel plugin which does closely as Introscope by changing the module variables in-place instead of creating a factory function: babel-plugin-rewire.
  • Mock modules in RequireJS: requirejs-mock.

Changelog

1.7.1

  • Remove gifs from the npm module 🤦‍♂️

1.7.0

  • Require the magic comment by default

1.4.2

  • Require stripping Flow types for stability
  • Support JSX

1.4.1

  • Add a full support spying on globals;
  • Test dynamic scopes with getters and setters for even more crazy testing superpowers;
  • Add global to default ignores for less surprises.

1.4.0

  • Add default action to Action Logger and set it to KEEP by default. This helps to just spy on default functions and values by default, and go crazy with setting default to mock only if needed.

  • Fix Flow object property being treated by Babel as an identifier reference leading to parasite global variables.

1.3.1

Removed effects export with a wrapper object to reduce module namespace pollution.

1.3.1

Refactor Spies and auto Mocks in Effects Logger.

1.2.2

Add license.

1.2.1

Fix the AST Exporer example.

1.2.0

Add more default ignores and systax to remove ignores.

1.1.0

Added Effects Logger to automate module side effects tracking.

Note that the project description data, including the texts, logos, images, and/or trademarks, for each open source project belongs to its rightful owner. If you wish to add or remove any projects, please contact us at [email protected].