thunk-test
Version:
Modular testing for JavaScript
704 lines (640 loc) • 18 kB
JavaScript
const noop = function () {}
const always = value => function getter() { return value }
const isArray = Array.isArray
const objectAssign = Object.assign
const isPromise = value => value != null && typeof value.then == 'function'
const callPropUnary = (value, property, arg0) => value[property](arg0)
const callPropBinary = (value, property, arg0, arg1) => value[property](arg0, arg1)
const tapSync = func => function tapping(...args) {
func(...args)
return args[0]
}
const funcConcat = (
funcA, funcB,
) => function pipedFunction(...args) {
const intermediate = funcA(...args)
return isPromise(intermediate)
? intermediate.then(funcB)
: funcB(intermediate)
}
const thunkifyArgs = (func, args) => function thunk() {
return func(...args)
}
const thunkify1 = (func, arg0) => function thunk() {
return func(arg0)
}
const thunkify2 = (func, arg0, arg1) => function thunk() {
return func(arg0, arg1)
}
const thunkify3 = (func, arg0, arg1, arg2) => function thunk() {
return func(arg0, arg1, arg2)
}
const thunkify4 = (func, arg0, arg1, arg2, arg3) => function thunk() {
return func(arg0, arg1, arg2, arg3)
}
const __ = Symbol.for('placeholder')
const curry1 = (func, arg0) => arg0 == __
? _arg0 => curry1(func, _arg0)
: func(arg0)
// argument resolver for curry2
const curry2ResolveArg0 = (
baseFunc, arg1,
) => function arg0Resolver(arg0) {
return baseFunc(arg0, arg1)
}
// argument resolver for curry2
const curry2ResolveArg1 = (
baseFunc, arg0,
) => function arg1Resolver(arg1) {
return baseFunc(arg0, arg1)
}
const curry2 = function (baseFunc, arg0, arg1) {
return arg0 == __
? curry2ResolveArg0(baseFunc, arg1)
: curry2ResolveArg1(baseFunc, arg0)
}
// argument resolver for curry4
const curry4ResolveArg0 = (
baseFunc, arg1, arg2, arg3,
) => function arg0Resolver(arg0) {
return baseFunc(arg0, arg1, arg2, arg3)
}
// argument resolver for curry4
const curry4ResolveArg1 = (
baseFunc, arg0, arg2, arg3,
) => function arg1Resolver(arg1) {
return baseFunc(arg0, arg1, arg2, arg3)
}
// argument resolver for curry4
const curry4ResolveArg2 = (
baseFunc, arg0, arg1, arg3,
) => function arg2Resolver(arg2) {
return baseFunc(arg0, arg1, arg2, arg3)
}
// argument resolver for curry4
const curry4ResolveArg3 = (
baseFunc, arg0, arg1, arg2,
) => function arg3Resolver(arg3) {
return baseFunc(arg0, arg1, arg2, arg3)
}
const curry4 = function (baseFunc, arg0, arg1, arg2, arg3) {
if (arg0 == __) {
return curry4ResolveArg0(baseFunc, arg1, arg2, arg3)
}
if (arg1 == __) {
return curry4ResolveArg1(baseFunc, arg0, arg2, arg3)
}
if (arg2 == __) {
return curry4ResolveArg2(baseFunc, arg0, arg1, arg3)
}
return curry4ResolveArg3(baseFunc, arg0, arg1, arg2)
}
const promiseAll = Promise.all.bind(Promise)
const inspect = function (value, depth = 1) {
const inspectDeeper = item => inspect(item, depth + 1)
if (Array.isArray(value)) {
return `[${value.map(inspectDeeper).join(', ')}]`
}
if (ArrayBuffer.isView(value)) {
return `${value.constructor.name} [${value.join(', ')}]`
}
if (typeof value == 'function') {
return value.toString()
}
if (typeof value == 'string') {
return depth == 0 ? value : `'${value}'`
}
if (value == null) {
return `${value}`
}
if (value.constructor == Set) {
if (value.size == 0) {
return 'Set {}'
}
let result = 'Set { '
const resultValues = []
for (const item of value) {
resultValues.push(inspectDeeper(item))
}
result += resultValues.join(', ')
result += ' }'
return result
}
if (value.constructor == Map) {
if (value.size == 0) {
return 'Map {}'
}
let result = 'Map { '
const entries = []
for (const [key, item] of value) {
entries.push(`${inspectDeeper(key)} => ${inspectDeeper(item)}`)
}
result += entries.join(', ')
result += ' }'
return result
}
if (value.constructor == Object) {
if (Object.keys(value).length == 0) {
return '{}'
}
let result = '{ '
const entries = []
for (const key in value) {
entries.push(`${key}: ${inspectDeeper(value[key])}`)
}
result += entries.join(', ')
result += ' }'
return result
}
if (value instanceof Error) {
return `${value.name}: ${value.message}`
}
return `${value}`
}
/**
* @name log
*
* @synopsis
* ```coffeescript [specscript]
* log(args ...any) -> ()
* ```
*/
const log = function (...args) {
console.log(...args)
}
/**
* @name AssertionError
*
* @synopsis
* ```coffeescript [specscript]
* AssertionError(message string) -> error AssertionError
* ```
*
* @note
* replace removes first stack item
*/
const AssertionError = function (message) {
const error = Error(message)
error.name = 'AssertionError'
error.stack = error.stack.replace(/\n\s+at[\s\S]+?\n/, '\n')
return error
}
const objectKeysLength = object => {
let numKeys = 0
for (const _ in object) {
numKeys += 1
}
return numKeys
}
const symbolIterator = Symbol.iterator
const sameValueZero = function (left, right) {
return left === right || (left !== left && right !== right);
}
const areIteratorsDeepEqual = function (leftIterator, rightIterator) {
let leftIteration = leftIterator.next(),
rightIteration = rightIterator.next()
if (leftIteration.done != rightIteration.done) {
return false
}
while (!leftIteration.done) {
if (!isDeepEqual(leftIteration.value, rightIteration.value)) {
return false
}
leftIteration = leftIterator.next()
rightIteration = rightIterator.next()
}
return rightIteration.done
}
const areObjectsDeepEqual = function (leftObject, rightObject) {
const leftKeysLength = objectKeysLength(leftObject),
rightKeysLength = objectKeysLength(rightObject)
if (leftKeysLength != rightKeysLength) {
return false
}
for (const key in leftObject) {
if (!isDeepEqual(leftObject[key], rightObject[key])) {
return false
}
}
return true
}
const areArraysDeepEqual = function (leftArray, rightArray) {
const length = leftArray.length
if (rightArray.length != length) {
return false
}
let index = -1
while (++index < length) {
if (!isDeepEqual(leftArray[index], rightArray[index])) {
return false
}
}
return true
}
const isDeepEqual = function (left, right) {
const isLeftArray = isArray(left),
isRightArray = isArray(right)
if (isLeftArray || isRightArray) {
return isLeftArray && isRightArray
&& areArraysDeepEqual(left, right)
}
if (left == null || right == null) {
return sameValueZero(left, right)
}
const isLeftString = typeof left == 'string' || left.constructor == String,
isRightString = typeof right == 'string' || right.constructor == String
if (isLeftString || isRightString) {
return sameValueZero(left, right)
}
const isLeftIterable = typeof left[symbolIterator] == 'function',
isRightIterable = typeof right[symbolIterator] == 'function'
if (isLeftIterable || isRightIterable) {
return isLeftIterable && isRightIterable
&& areIteratorsDeepEqual(left[symbolIterator](), right[symbolIterator]())
}
const isLeftObject = left.constructor == Object,
isRightObject = right.constructor == Object
if (isLeftObject || isRightObject) {
return isLeftObject && isRightObject
&& areObjectsDeepEqual(left, right)
}
return sameValueZero(left, right)
}
/**
* @name assertEqual
*
* @synopsis
* ```coffeescript [specscript]
* assertEqual(expect any, actual any) -> boolean
* ```
*/
const assertEqual = function (expect, actual) {
if (!isDeepEqual(expect, actual)) {
log('expect', inspect(expect))
log('actual', inspect(actual))
throw AssertionError('not equal')
}
}
/**
* @name argsInspect
*
* @synopsis
* ```coffeescript [specscript]
* argsInspect(args Array) -> argsRepresentation string
* ```
*/
const argsInspect = args => `${args.map(curry1(inspect, __)).join(', ')}`
/**
* @name funcInspect
*
* @synopsis
* ```coffeescript [specscript]
* funcInspect(args Array) -> funcRepresentation string
* ```
*/
const funcInspect = func => func.toString()
/**
* @name funcSignature
*
* @synopsis
* ```coffeescript [specscript]
* funcSignature(func function, args Array) -> funcRepresentation string
* ```
*/
const funcSignature = (func, args) => func.name === ''
? func.toString()
: `${func.name}(${argsInspect(args)})`
/**
* @name errorInspect
*
* @synopsis
* ```coffeescript [specscript]
* Error = { name: string, message: string }
*
* errorInspect(error Error) -> funcRepresentation string
* ```
*/
const errorInspect = error => `${error.name}('${error.message}')`
/**
* @name errorAssertEqual
*
* @synopsis
* ```coffeescript [specscript]
* Error = { name: string, message: string }
*
* errorAssertEqual(expect Error, actual Error)
* ```
*/
const errorAssertEqual = function (expect, actual) {
if (actual.name != expect.name) {
log()
log('-- expect:', expect.name)
log('-- actual:', actual.name)
throw AssertionError('error names are different')
} else if (actual.message != expect.message) {
log()
log('-- expect:', expect.message)
log('-- actual:', actual.message)
throw AssertionError('error messages are different')
}
}
/**
* @name throwAssertionError
*
* @synopsis
* ```coffeescript [specscript]
* throwAssertionError(message string) -> ()
* ```
*/
const throwAssertionError = message => {
throw AssertionError(message)
}
/**
* @name assertThrows
*
* @synopsis
* ```coffeescript [specscript]
* assertThrows(func ()=>any, expectedError Error) -> ()
* ```
*/
const assertThrows = function (func, expectedError) {
try {
const execution = func()
if (isPromise(execution)) {
return execution.then(funcConcat(
thunkify1(throwAssertionError, 'did not throw'),
curry2(errorAssertEqual, expectedError, __)))
}
} catch (error) {
errorAssertEqual(expectedError, error)
return undefined
}
throw AssertionError('did not throw')
}
/**
* @name assertThrowsCallback
*
* @synopsis
* ```coffeescript [specscript]
* assertThrowsCallback(func ()=>Promise|any, args Array, callback (Error, ...any)=>())
* ```
*/
const assertThrowsCallback = function (func, args, callback) {
try {
const execution = func(...args)
if (isPromise(execution)) {
return execution.then(thunkify1(throwAssertionError, 'did not throw'))
}
} catch (error) {
const execution = callback(error, ...args)
if (isPromise(execution)) {
return execution.then(funcConcat(
tapSync(thunkify1(log, ` ✓ ${funcSignature(func, args)} throws ${funcInspect(callback)}`)),
noop))
}
log(` ✓ ${funcSignature(func, args)} throws ${funcInspect(callback)}`)
return undefined
}
throw AssertionError('did not throw')
}
/**
* @name thunkTestExecAsync
*
* @synopsis
* ```coffeescript [specscript]
* Thunk = ()=>any
*
* thunkTestExecAsync(operations Array<Thunk>, operationsIndex number) -> Promise<void>
* ```
*/
const thunkTestExecAsync = async function (
operations, operationsIndex,
) {
const operationsLength = operations.length
while (++operationsIndex < operationsLength) {
let execution = operations[operationsIndex]()
if (isPromise(execution)) {
execution = await execution
}
if (typeof execution == 'function') {
const cleanup = execution()
if (isPromise(cleanup)) {
await cleanup
}
}
}
}
/**
* @name thunkTestExec
*
* @synopsis
* ```coffeescript [specscript]
* Thunk = ()=>any
*
* thunkTestExecAsync(operations Array<Thunk>, operationsIndex number) -> ()|Promise<void>
* ```
*/
const thunkTestExec = function (operations) {
const operationsLength = operations.length
let operationsIndex = -1
while (++operationsIndex < operationsLength) {
const execution = operations[operationsIndex]()
if (isPromise(execution)) {
return execution.then(funcConcat(
tapSync(res => typeof res == 'function' && res()),
thunkify2(thunkTestExecAsync, operations, operationsIndex),
))
} else if (typeof execution == 'function') {
const cleanup = execution()
if (isPromise(cleanup)) {
return cleanup.then(
thunkify2(thunkTestExecAsync, operations, operationsIndex),
)
}
}
}
return undefined
}
/**
* @name Test
*
* @synopsis
* ```coffeescript [specscript]
* test = ()=>() {
* before: function=>this,
* after: function=>this,
* beforeEach: function=>this,
* afterEach: function=>this,
* case: (...args, expectedResult|function=>(disposer ()=>())|())=>this,
* throws: (...args, expectedError|function=>(disposer ()=>())|())=>this,
* }
*
* Test(story string, func function) -> test
*
* Test(func function) -> test
* ```
*
* @description
* Modular testing for JavaScript.
*/
const arrayFlatMap = function (array, flatMapper) {
const arrayLength = array.length,
result = []
let arrayIndex = -1
while (++arrayIndex < arrayLength) {
result.push(...flatMapper(array[arrayIndex]))
}
return result
}
const Test = function (...funcs) {
if (this == null || this.constructor != Test) {
return new Test(...funcs)
}
let story = null
if (typeof funcs[0] == 'string') {
story = funcs.shift()
}
const operations = [],
preprocessing = [],
postprocessing = [],
microPreprocessing = [],
microPostprocessing = []
return objectAssign(function thunkTest() {
if (story != null) {
log('--', story)
}
let cursor = null
cursor = thunkTestExec(preprocessing)
if (isPromise(cursor)) {
return cursor.then(funcConcat(
thunkify1(thunkTestExec, arrayFlatMap(
operations,
operation => [...microPreprocessing, operation, ...microPostprocessing])),
thunkify1(thunkTestExec, postprocessing),
))
}
cursor = thunkTestExec(arrayFlatMap(
operations,
operation => [...microPreprocessing, operation, ...microPostprocessing]))
if (isPromise(cursor)) {
return cursor.then(thunkify1(thunkTestExec, postprocessing))
}
cursor = thunkTestExec(postprocessing)
if (isPromise(cursor)) {
return cursor.then(noop)
}
return undefined
}, {
before(callback) {
preprocessing.push(thunkify3(callPropUnary, callback, 'call', this))
return this
},
after(callback) {
postprocessing.push(thunkify3(callPropUnary, callback, 'call', this))
return this
},
beforeEach(callback) {
microPreprocessing.push(thunkify3(callPropUnary, callback, 'call', this))
return this
},
afterEach(callback) {
microPostprocessing.push(thunkify3(callPropUnary, callback, 'call', this))
return this
},
case(...args) {
const expect = args.pop(),
boundArgs = args.map(arg => typeof arg == 'function' ? arg.bind(this) : arg)
if (typeof expect == 'function') {
for (const func of funcs) {
operations.push([
thunkify4(callPropBinary, func, 'apply', this, boundArgs),
curry4(callPropBinary, expect, 'call', this, __),
tapSync(thunkify1(log, ` ✓ ${funcSignature(func, boundArgs)} |> ${funcInspect(expect)}`)),
].reduce(funcConcat))
}
} else {
for (const func of funcs) {
operations.push([
thunkify4(callPropBinary, func, 'apply', this, boundArgs),
curry2(assertEqual, expect, __),
tapSync(thunkify1(log, ` ✓ ${funcSignature(func, boundArgs)} -> ${inspect(expect)}`)),
].reduce(funcConcat))
}
}
return this
},
throws(...args) {
const expect = args.pop(),
boundArgs = args.map(arg => typeof arg == 'function' ? arg.bind(this) : arg)
if (typeof expect == 'function') {
for (const func of funcs) {
operations.push(function tryCatching() {
try {
const execution = func(...boundArgs)
if (isPromise(execution)) {
return execution.then(thunkify1(throwAssertionError, 'did not throw'))
}
} catch (error) {
const execution = expect(error, ...boundArgs)
if (isPromise(execution)) {
return execution.then(funcConcat(
tapSync(thunkify1(log, ` ✓ ${funcSignature(func, boundArgs)} throws; ${funcInspect(expect)}`)),
noop))
}
log(` ✓ ${funcSignature(func, boundArgs)} throws; ${funcInspect(expect)}`)
return undefined
}
throw AssertionError('did not throw')
})
}
} else {
for (const func of funcs) {
operations.push(funcConcat(
thunkify2(
assertThrows,
thunkify4(callPropBinary, func, 'apply', this, boundArgs),
expect),
tapSync(thunkify1(log, ` ✓ ${funcSignature(func, boundArgs)} throws ${errorInspect(expect)}`)),
))
}
}
return this
},
})
}
/**
* @name testAllAsync
*
* @synopsis
* ```coffeescript [specscript]
* testAllAsync(tests Array<Test>, index number) -> combinedTest ()=>Promise<>
* ```
*/
const testAllAsync = async function (tests, index) {
const length = tests.length
while (++index < length) {
await tests[index]()
}
}
/**
* @name Test.all
*
* @synopsis
* ```coffeescript [specscript]
* Test.all(tests Array<Test>) -> combinedTest ()=>{}
* ```
*/
Test.all = function TestAll(tests) {
return function testAll() {
const length = tests.length
let index = -1
while (++index < length) {
const execution = tests[index]()
if (isPromise(execution)) {
return execution.then(thunkify2(testAllAsync, tests, index))
}
}
return undefined
}
}
module.exports = Test