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.
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);
});
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();
});
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]];
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.
Support for TypeScript using Babel 7 is planned.
Please, see a short
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.
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.
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();
});
});
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.
JSX syntax is supported natively. No need for any additional configuration.
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"
]
}
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:
- use flow-typed
yarn add -D flow-typed
yarn flow-typed install [email protected]
- just add this line to
.flowconfig
[libs]
section:
[libs]
node_modules/introscope/flow-typed
To disable appending ?introscope
to introscope imports add this babel plugin option: instrumentImports: false
.
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; ifenable
equalsfalse
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 namefoo
and the magic comment hasignore: ['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 likeDate
,Math
,Array
as testers normally do not care of those, but can be overritten with-
prefix:// @introscope "ignore": ["-Date"]
, this will removeDate
from ignore list and make it avalable for mocking/spying.
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;
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.
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);
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
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
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;
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
- 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.
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.