Skip to content

Instantly share code, notes, and snippets.

@eiriklv
Last active October 21, 2016 14:25
Show Gist options
  • Save eiriklv/b6cc681447a0939365e1ba044fac15d7 to your computer and use it in GitHub Desktop.
Save eiriklv/b6cc681447a0939365e1ba044fac15d7 to your computer and use it in GitHub Desktop.
Wrapping effects to make non-deterministic/non-pure function testable and predictable (for fun and learning)
'use strict';
/**
* Dependencies
*/
const expect = require('expect');
/**
* Factory for creating an effects handling "runtime"
* that stores a retrievable effects log
*/
const createEffectsHandlerWithLog = function () {
const effectsRequested = [];
return {
execute(effectsDescription) {
effectsRequested.push(effectsDescription);
const { type, func, args } = effectsDescription;
switch (type) {
case '@@callSync':
return func(...args);
case '@@callAsync':
return func(...args);
default:
return undefined;
}
},
callSync(id, func, ...args) {
return {
type: '@@callSync',
id,
func,
args,
};
},
callAsync(id, func, ...args) {
return {
type: '@@callAsync',
id,
func,
args,
};
},
getEffectsLog() {
return effectsRequested.slice();
},
}
}
/**
* Factory for creating an effects handling "runtime"
* that stores a retrievable effects log and responds
* to effects by returning a pre-defined set of responses (mocks)
*
* NOTE: This is a runtime you would be using for testing
*/
const createEffectsHandlerWithMockedData = function (responses = []) {
const effectsRequested = [];
const effectsResponses = responses;
return {
execute(effectsDescription) {
effectsRequested.push(effectsDescription);
const { id, type } = effectsDescription;
switch (type) {
case '@@callSync':
return effectsResponses.find(({ id: effectId }) => effectId === id).value;
case '@@callAsync':
return Promise.resolve(effectsResponses.find(({ id: effectId }) => effectId === id).value);
default:
return undefined;
}
},
callSync(id, func, ...args) {
return {
type: '@@callSync',
id,
func,
args,
};
},
callAsync(id, func, ...args) {
return {
type: '@@callAsync',
id,
func,
args,
};
},
getEffectsLog() {
return effectsRequested.slice();
},
}
}
/**
* Example program / routine
*
* NOTE: The function is deliberately
* non-pure (full of side-effects)
* for the purpose of the example
*/
function programOneWithoutRuntime() {
return Promise.resolve().then(() => {
const a = 5;
const b = Math.random(); // side-effect
console.log(b); // side-effect
return Promise.resolve(val)
.then((val) => Math.ceil(val))
.then((val) => Math.sqrt(val)) // side-effect
.then((val) => Promise.resolve(val + val))
.catch((err) => console.log(err)) // side-effect
});
}
/**
* Helper function to wrap a value in a promise
*/
function unit(val) {
return Promise.resolve(val);
}
/**
* The same example program using the
* effects handling runtime as a dependency.
*
* NOTE: What we are doing here is wrapping
* every possible effect call in a function
* that creates a plain object description
* of the effect, and then in a function
* that takes effect descriptions and
* handles them according to its rules
*
* For every effect requested we'll attach
* a unique id - to be able to identify them later
*
* NOTE: In this example we're injecting the
* runtime as a dependency, but in a more
* practical example we would use it as
* an external dependency (import/require)
* and then mock this part out with proxyquire/inject-loader
* when testing, injecting the test version of the runtime
*/
function programOne({ callSync, callAsync, execute }) {
return Promise.resolve().then(() => {
const a = 5;
const b = execute(callSync('id', Math.random)); // side-effect
execute(callSync('id2', console.log, b)); // side-effect
return execute(callAsync('id3', unit, b))
.then((val) => execute(callSync('id4', Math.ceil, val)))
.then((val) => execute(callSync('id5', Math.sqrt, val))) // side-effect
.then((val) => execute(callAsync('id6', unit, val + val)))
.catch((err) => execute(callSync('id7', console.log, err))) // side-effect
});
}
/**
* Create an effects handler runtime with logging
*/
const effectsWithLog = createEffectsHandlerWithLog();
/**
* Run the program and print
* the log of all effect descriptions
*/
programOne(effectsWithLog) //
.then(() => {
console.log(effectsWithLog.getEffectsLog());
/**
* Will print something like
*
*[ { type: '@@callSync',
* id: 'id',
* func: [Function: random],
* args: [] },
* { type: '@@callSync',
* id: 'id2',
* func: [Function: bound ],
* args: [ 0.33253517413487743 ] },
* { type: '@@callAsync',
* id: 'id3',
* func: [Function: unit],
* args: [ 0.33253517413487743 ] },
* { type: '@@callSync',
* id: 'id4',
* func: [Function: ceil],
* args: [ 0.33253517413487743 ] },
* { type: '@@callSync',
* id: 'id5',
* func: [Function: sqrt],
* args: [ 1 ] },
* { type: '@@callAsync',
* id: 'id6',
* func: [Function: unit],
* args: [ 2 ] } ]
*/
});
/**
* Example test case for the program
*/
function testCase() {
/**
* These are the effects (indentified by id) we
* expect that our function will request, and
* the corresponding values we will return as a response
*/
const mockedEffectsResponses = [{
id: 'id',
value: 8.5,
}, {
id: 'id2',
value: undefined,
}, {
id: 'id3',
value: 8.5,
}, {
id: 'id4',
value: 9,
}, {
id: 'id5',
value: 3,
}, {
id: 'id6',
value: 6,
}];
/**
* Create an effects handler runtime where you can
* inject predefined responses to effects
*
* NOTE: This means that we can essentially make
* our non-deterministic function deterministic
*/
const effectsWithMockedData = createEffectsHandlerWithMockedData(mockedEffectsResponses);
/**
* Run the program with our our mocked effects handler
*
* NOTE: We're basically running the function without
* executing any of the effects (like Math.random),
* but instead just handling descriptions of them
*/
return programOne(effectsWithMockedData)
.then(() => {
/**
* Pull out the requested effects and their
*/
const effectsDescriptions = effectsWithMockedData.getEffectsLog();
/**
* Assert that the effects have been called in
* correct order and with the expected arguments
*/
expect(effectsDescriptions).toEqual([{
type: '@@callSync',
id: 'id',
func: Math.random,
args: []
},
{
type: '@@callSync',
id: 'id2',
func: console.log,
args: [8.5]
},
{
type: '@@callAsync',
id: 'id3',
func: unit,
args: [8.5]
},
{
type: '@@callSync',
id: 'id4',
func: Math.ceil,
args: [8.5]
},
{
type: '@@callSync',
id: 'id5',
func: Math.sqrt,
args: [9]
},
{
type: '@@callAsync',
id: 'id6',
func: unit,
args: [6]
}]);
});
}
/**
* Run the test case
*/
testCase()
.then(() => console.log('Test passed'))
.catch((err) => console.error('Test failed:', err));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment