1.0.2 • Published 6 years ago

babel-plugin-introscope v1.0.2

Weekly downloads
2
License
MIT
Repository
github
Last release
6 years ago

Introscope

A reflection / introspection tool for unit testing ES modules.

// any-module.js
// @introscope-config "enable": true
// ...the rest of the code...

// any-module.test.js
import anyModuleScope from './any-module';

test('privateFunction', () => {
    const scope = anyModuleScope({
        PRIVATE_CONSTANT: 123
    });
    expect(scope.privateFunction()).toBe(scope.ANOTHER_PRIVATE_CONSTANT);
});

Description

TL;DR; no need to export all the functions/constants of your module just for make it testable, Introscope makes it automatically.

Introscope is (mostly) a babel plugin which allows a unit test code look inside an ES module without rewriting the code of the module just for making it testable. Introscope does it by transpiling the module source to a function which exports 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 and mockable.

Handy integrations with popular unit testing tools and nice tricks like Proxy based wrappers/spies to come soon.

Usage

Install the babel plugin first:

yarn add --dev babel-plugin-introscope
# or
npm install --save-dev babel-plugin-introscope

Add it to the project's babel configuration (most likely .babelrc):

{
    "plugins": ["introscope"]
}

and use it in tests:

import scopeFactory from './tested-module';

// or

const scopeFactory = require('./tested-module');

Just in case, this plugin do anything only if NODE_ENV equals to 'test'.

Introscope supports all the new ES features (if not, create an issue 🙏), so if your babel configuration supports some new fancy syntax, Introscope should too.

Example

What Introscope does is it wraps a whole module code in a function that accepts one argument scope object and returns all variables, functions and classes defined in the module as properties of the scope object. Here is a little example. Code 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-config "enable": true, "ignore": ["Error"]

gets transpiled to code like this:

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

module.exports = function(_scope = {}) {
    _scope.httpGet = httpGet;

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

    const getTodos = (_scope.getTodos = (0, _scope.httpGet)('/todos').then(
        (0, _scope.ensureOkStatus)
    ));
    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 apiScopeFactory 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', () => {
        // creates a new unaltered scope
        const scope = apiScopeFactory();

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

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

describe('getTodos', () => {
    it('calls httpGet() and ensureOkStatus()', async () => {
        // creates a new unaltered scope
        const scope = apiScopeFactory();
        // mock the local module functions
        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();
    });
});

Limitation

As far as Introscope requires a magic comment @introscope-config "enable": true in every module which gets tested it's currently impossible to require introscoped modules from withing introscoped modules. The better solution is in progress for Jest.

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;

Nice tricks

Wrap all possible IDs in a test-plan like proxy, mock imported side effects and then just run each function with different input and record how proxies been called and what returned.

Notes

Based on this documentation and source code: