Last active
October 21, 2016 14:25
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'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