@test-runner/tom
Version:
Define a Test Object Model
453 lines (410 loc) • 11 kB
JavaScript
import raceTimeout from 'race-timeout-anywhere'
import mixin from 'create-mixin'
import CompositeClass from 'composite-class'
import StateMachine from 'fsm-base'
import TestContext from './lib/test-context.js'
import { isPromise, isPlainObject, isString, isFunction } from 'typical'
/**
* @module test-object-model
*/
/**
* @param {string} [name] - The test name.
* @param {function} [testFn] - A function which will either succeed, reject or throw.
* @param {object} [options] - Test config.
* @param {number} [options.timeout] - A time limit for the test in ms.
* @param {number} [options.maxConcurrency] - The max concurrency that child tests will be able to run. For example, specifying `2` will allow child tests to run two at a time. Defaults to `10`.
* @param {boolean} [options.skip] - Skip this test.
* @param {boolean} [options.only] - Only run this test.
* @param {boolean} [options.before] - Run this test before its siblings.
* @param {boolean} [options.after] - Run this test after its siblings.
* @param {boolean} [options.todo] - Mark this test as incomplete.
* @param {boolean} [options.group] - Mark this test as a group.
* @alias module:test-object-model
*/
class Tom extends mixin(CompositeClass)(StateMachine) {
/**
* Test name
* @type {string}
*/
name = 'tom'
/**
* A function which will either succeed, reject or throw.
* @type {function}
*/
testFn
/**
* Position of this test within its parents children
* @type {number}
*/
index = 1
/**
* True if the test has ended.
* @type {boolean}
*/
ended = false
/**
* If the test passed, the value returned by the test function. If it failed, the exception thrown or rejection reason.
* @type {*}
*/
result
/**
* True if one or more different tests are marked as `only`.
* @type {boolean}
*/
disabledByOnly = false
/**
* The options set when creating the test.
*/
options
/**
* Test execution stats
* @namespace
*/
stats = {
/**
* Start time.
* @type {number}
*/
start: 0,
/**
* End time.
* @type {number}
*/
end: 0,
/**
* Test execution duration.
* @type {number}
*/
duration: 0,
finish: function (end) {
this.end = end
this.duration = this.end - this.start
}
}
/**
* The text execution context.
* @type {TextContext}
*/
context
constructor (name, testFn, options) {
if (name) {
if (isString(name)) {
if (isPlainObject(testFn)) {
options = testFn
testFn = undefined
}
} else if (isFunction(name)) {
options = testFn
testFn = name
name = ''
} else if (isPlainObject(name)) {
options = name
testFn = undefined
name = ''
}
} else {
if (isPlainObject(testFn)) {
options = testFn
testFn = undefined
}
}
/**
* Test state. Can be one of `pending`, `in-progress`, `skipped`, `ignored`, `todo`, `pass` or `fail`.
* @member {string} module:test-object-model#state
*/
super('pending', [
{ from: 'pending', to: 'in-progress' },
{ from: 'pending', to: 'skipped' },
{ from: 'pending', to: 'ignored' },
{ from: 'pending', to: 'todo' },
{ from: 'in-progress', to: 'pass' },
{ from: 'in-progress', to: 'fail' }
])
this.name = name || 'tom'
this.testFn = testFn
options = Object.assign({}, options)
options.maxConcurrency = options.maxConcurrency || 10
options.timeout = options.timeout || 10000
this.options = options
}
/**
* Returns `test`, `group` or `todo`.
* @returns {string}
*/
get type () {
if (this.options.group) {
return 'group'
} else if (this.options.todo) {
return 'todo'
} else {
if (this.testFn && !this.children.length) {
return 'test'
} else if (!this.testFn && this.children.length) {
return 'group'
} else {
return 'todo'
}
}
}
/**
* Returns `true` if this test was marked to be skipped by usage of `skip` or `only`.
* @returns {booolean}
*/
get toSkip () {
return this.disabledByOnly || this.options.skip
}
/**
* Returns the test name.
* @returns {string}
*/
toString () {
return this.name
}
/**
* Add a test group.
* @param {string} - Test name.
* @param {objects} - Config.
* @return {module:test-object-model}
*/
group (name, options = {}) {
options.group = true
return this.test(name, options)
}
/**
* Add a test.
* @param {string} - Test name.
* @param {function} - Test function.
* @param {objects} - Config.
* @return {module:test-object-model}
*/
test (name, testFn, options = {}) {
/* validate name */
for (const child of this) {
if (child.name === name) {
throw new Error('Duplicate name: ' + name)
}
}
const test = new this.constructor(name, testFn, options)
this.add(test)
test.index = this.children.length
test._disableNonOnlyTests()
return test
}
/**
* Add a skipped test
* @return {module:test-object-model}
*/
skip (name, testFn, options = {}) {
options.skip = true
return this.test(name, testFn, options)
}
/**
* Add an only test
* @return {module:test-object-model}
*/
only (name, testFn, options = {}) {
options.only = true
return this.test(name, testFn, options)
}
/**
* Add a test which must run and complete before the others.
* @return {module:test-object-model}
*/
before (name, testFn, options = {}) {
options.before = true
return this.test(name, testFn, options)
}
/**
* Add a test but don't run it and mark as incomplete.
* @return {module:test-object-model}
*/
todo (name, testFn, options = {}) {
options.todo = true
return this.test(name, testFn, options)
}
/**
* Add a test which must run and complete after the others.
* @return {module:test-object-model}
*/
after (name, testFn, options = {}) {
options.after = true
return this.test(name, testFn, options)
}
_onlyExists () {
return Array.from(this.root()).some(t => t.options.only)
}
_disableNonOnlyTests () {
if (this._onlyExists()) {
for (const test of this.root()) {
test.disabledByOnly = !test.options.only
}
}
}
setState (state, target, data) {
if (state === 'pass' || state === 'fail') {
this.ended = true
}
super.setState(state, target, data)
if (state === 'pass' || state === 'fail') {
this.emit('end')
}
}
/**
* Execute the stored test function. Return a promise that either resolves with the value returned by the test function or rejects.
* @returns {Promise}
* @fulfil {*}
*/
async run () {
if (this.options.todo) {
/**
* Test todo.
* @event module:test-object-model#todo
* @param test {TestObjectModel} - The test node.
*/
this.setState('todo', this)
} else {
if (this.testFn) {
if (this.toSkip) {
/**
* Test skipped.
* @event module:test-object-model#skipped
* @param test {TestObjectModel} - The test node.
*/
this.setState('skipped', this)
} else {
return this._runTestFn()
}
} else {
/**
* Test ignored.
* @event module:test-object-model#ignored
* @param test {TestObjectModel} - The test node.
*/
this.setState('ignored', this)
}
}
}
async _runTestFn () {
this.performance = await this._getPerformance()
/**
* Test in-progress.
* @event module:test-object-model#in-progress
* @param test {TestObjectModel} - The test node.
*/
this.setState('in-progress', this)
this.stats.start = this.performance.now()
try {
this.context = new TestContext({ name: this.name, index: this.index })
const testResult = this.testFn.call(this.context)
if (isPromise(testResult)) {
try {
const result = await Promise.race([testResult, raceTimeout(this.options.timeout)])
this._testPassed(result)
return result
} catch (err) {
return Promise.reject(this._testFailed(err))
}
} else {
this._testPassed(testResult)
return testResult
}
} catch (err) {
throw this._testFailed(err)
}
}
_testPassed (result) {
this.result = result
this.stats.finish(this.performance.now())
/**
* Test pass.
* @event module:test-object-model#pass
* @param test {TestObjectModel} - The test node.
* @param result {*} - The value returned by the test.
*/
this.setState('pass', this, result)
}
_testFailed (err) {
const testFailError = new TestFailError(this.name, err)
this.result = err
this.stats.finish(this.performance.now())
/**
* Test fail.
* @event module:test-object-model#fail
* @param test {TestObjectModel} - The test node.
* @param err {Error} - The exception thrown.
*/
this.setState('fail', this, testFailError)
return testFailError
}
/**
* Reset state
*/
reset (deep) {
if (deep) {
for (const tom of this) {
tom.reset()
}
} else {
this.index = 1
this.resetState()
this.disabledByOnly = false
}
}
async _getPerformance () {
if (typeof window === 'undefined') {
const { performance } = await import('perf_hooks')
return performance
} else {
return window.performance
}
}
/**
* Used in the @test-runner/core stats.
*/
getTestCount () {
return Array.from(this).filter(t => t.testFn).length
}
/**
* If more than one TOM instances are supplied, combine them into a common root.
* @param {Array.<Tom>} tests
* @param {string} [name]
* @return {Tom}
*/
static combine (tests, name, options) {
let test
if (tests.length > 1) {
test = new this(name, options)
for (const subTom of tests) {
this.validate(subTom)
test.add(subTom)
}
} else {
test = tests[0]
this.validate(test)
}
test._disableNonOnlyTests()
return test
}
/**
* Returns true if the input is a valid test.
* @param {module:test-object-model} tom - Input to test.
* @returns {boolean}
*/
static validate (tom = {}) {
const valid = ['name', 'testFn', 'index', 'ended'].every(prop => Object.keys(tom).includes(prop))
if (!valid) {
const err = new Error('Valid TOM required')
err.invalidTom = tom
throw err
}
}
}
class TestFailError extends Error {
constructor (name, cause) {
super(`Test failed [${name}]`)
/* Used to differentiate a test suite fail (expected) from a library bug (unexpected) */
this.isTestFail = true
this.cause = cause
}
}
export default Tom