[tests] Move test suite into same repo

This commit is contained in:
Aleck Greenham
2017-04-23 20:22:19 +01:00
parent fac0efa9d0
commit 9308994db2
148 changed files with 11251 additions and 1 deletions

5
tests/lib/RunStatus.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
RUNNING: 'running',
OK: 'success',
ERR: 'error',
};

251
tests/lib/TestDSL.js Normal file
View File

@@ -0,0 +1,251 @@
/**
* Class that provides DSL for declaratively defining tests. Provides a declarative
* interface for {@link TestSuiteDefinition} and only reveals methods that are part
* of the test definition DSL.
*/
class TestDSL {
/**
* Create a new instance of TestDSL
* @param {TestSuiteDefinition} testSuiteDefinition - Class to delegate the heavy lifting
* of satisfying the DSL, to.
* @param {Object} firebase - Object containing native and web firebase instances
* @param {Object} firebase.native - Native firebase instance
* @para {Object} firebase.web - Web firebase instance
*/
constructor(testSuiteDefinition, firebase) {
this._testSuiteDefinition = testSuiteDefinition;
this.firebase = firebase;
this.after = this.after.bind(this);
this.afterEach = this.afterEach.bind(this);
this.before = this.before.bind(this);
this.beforeEach = this.beforeEach.bind(this);
this.describe = this.describe.bind(this);
/** Alias for {@link TestDSL#describe } */
this.context = this.describe;
this.fdescribe = this.fdescribe.bind(this);
/** Alias for {@link TestDSL#fdescribe } */
this.fcontext = this.fdescribe;
this.xdescribe = this.xdescribe.bind(this);
/** Alias for {@link TestDSL#xdescribe } */
this.xcontext = this.xdescribe;
this.tryCatch = this.tryCatch.bind(this);
this.it = this.it.bind(this);
this.xit = this.xit.bind(this);
this.fit = this.fit.bind(this);
}
/**
* Add a function as a before hook to the current test context
* @param {Object=} options - Options object
* @param {Number=10000} options.timeout - Number of milliseconds before callback times
* out
* @param {Function} callback - Function to add as before hook to current test context
*/
before(options, callback = undefined) {
const _options = callback ? options : {};
const _callback = callback || options;
this._testSuiteDefinition.addBeforeHook(_callback, _options);
}
/**
* Add a function as a before each hook to the current test context
* @param {Object=} options - Options object
* @param {Number=10000} options.timeout - Number of milliseconds before callback times
* out
* @param {Function} callback - Function to add as before each hook to current test
* context
*/
beforeEach(options, callback = undefined) {
const _options = callback ? options : {};
const _callback = callback || options;
this._testSuiteDefinition.addBeforeEachHook(_callback, _options);
}
/**
* Add a function as a after each hook to the current test context
* @param {Object=} options - Options object
* @param {Number=10000} options.timeout - Number of milliseconds before callback times
* out
* @param {Function} callback - Function to add as after each hook to current test context
*/
afterEach(options, callback = undefined) {
const _options = callback ? options : {};
const _callback = callback || options;
this._testSuiteDefinition.addAfterEachHook(_callback, _options);
}
/**
* Add a function as a after hook to the current test context
* @param {Object=} options - Options object
* @param {Number=10000} options.timeout - Number of milliseconds before callback times
* out
* @param {Function} callback - Function to add as after hook to current test context
*/
after(options, callback = undefined) {
const _options = callback ? options : {};
const _callback = callback || options;
this._testSuiteDefinition.addAfterHook(_callback, _options);
}
/**
* Starts a new test context
* @param {String} name - name of new test context
* @param {ContextOptions} options - options for context
* @param {Function} [testDefinitions={}] - function that defines further test contexts and
* tests using the test DSL
*/
describe(name, options, testDefinitions) {
let _testDefinitions;
let _options;
if (testDefinitions) {
_testDefinitions = testDefinitions;
_options = options;
} else {
_testDefinitions = options;
_options = {};
}
this._testSuiteDefinition.pushTestContext(name, _options);
_testDefinitions();
this._testSuiteDefinition.popTestContext();
}
/**
* Starts a new pending test context. Tests in a pending test context are not
* run when the test suite is executed. They also appear greyed out.
* @param {String} name - name of new test context
* @param {ContextOptions} [options={}] - options for context
* @param {Function} testDefinitions - function that defines further test contexts and
* tests using the test DSL
*/
xdescribe(name, options, testDefinitions = undefined) {
let _options = {};
let _testDefinitions;
if (typeof options === 'function') {
_options = { pending: true };
_testDefinitions = options;
} else {
Object.assign(_options, options, { pending: true });
_testDefinitions = testDefinitions;
}
this.describe(name, _options, _testDefinitions);
}
/**
* Starts a new focused test context. Tests in a focused test context are the only
* ones that appear and are run when the test suite is executed.
* @param {String} name - name of new test context
* @param {ContextOptions} [options={}] - options for context
* @param {Function} testDefinitions - function that defines further test contexts and
* tests using the test DSL
*/
fdescribe(name, options, testDefinitions = undefined) {
let _options = {};
let _testDefinitions;
if (typeof options === 'function') {
_options = { focus: true };
_testDefinitions = options;
} else {
Object.assign(_options, options, { focus: true });
_testDefinitions = testDefinitions;
}
this.describe(name, _options, _testDefinitions);
}
/**
* Defines a new test.
* @param {String} description - Brief description of what the test expects
* @param {TestOptions} options - Options of whether test should be focused or pending
* @param {Function} testFunction - Body of the test containing setup and assertions
*/
it(description, options, testFunction = undefined) {
this._testSuiteDefinition.addTest(description, options, testFunction);
}
/**
* Defines a new pending test. Pending tests are not run when the test suite is
* executed. They also appear greyed out.
* @param {String} description - Brief description of what the test expects
* @param {ContextOptions} [options={}] - Options of whether test should be focused or pending
* @param {Function} testFunction - Body of the test containing setup and assertions
*/
xit(description, options, testFunction = undefined) {
let _options = {};
let _testFunction;
if (typeof options === 'function') {
_options = { pending: true };
_testFunction = options;
} else {
Object.assign(_options, options, { pending: true });
_testFunction = testFunction;
}
this.it(description, _options, _testFunction);
}
/**
* Defines a new focused test. Focused tests are the only
* ones that appear and are run when the test suite is executed.
* @param {String} description - Brief description of what the test expects
* @param {ContextOptions} [options={}] - Options of whether test should be focused or pending
* @param {Function} testFunction - Body of the test containing setup and assertions
*/
fit(description, options, testFunction = undefined) {
let _options = {};
let _testFunction;
if (typeof options === 'function') {
_options = { focus: true };
_testFunction = options;
} else {
Object.assign(_options, options, { focus: true });
_testFunction = testFunction;
}
this.it(description, _options, _testFunction);
}
/**
* Tries evaluating a function and calls a reject callback if it throws an error
* @param {Function} callback - Function to evaluate
* @param {Function} reject - Function to call if callback throws an error
* @returns {function(...[*])} a function that will catch any errors thrown by callback,
* passing them to reject instead.
*/
tryCatch(callback, reject) {
return (...args) => {
try {
callback(...args);
} catch (error) {
reject(error);
}
};
}
}
/**
* Log a test DSL error to the console.
* @param {String} error - Message to included in message logged to the console
*/
function testDSLError(error) {
console.error(`ReactNativeFirebaseTests.TestDSLError: ${error}`);
console.error('This test was ignored.');
}
export default TestDSL;

386
tests/lib/TestRun.js Normal file
View File

@@ -0,0 +1,386 @@
import Promise from 'bluebird';
import RunStatus from './RunStatus';
const EVENTS = {
TEST_SUITE_STATUS: 'TEST_SUITE_STATUS',
TEST_STATUS: 'TEST_STATUS',
};
/**
* Class that encapsulates synchronously running a suite's tests.
*/
class TestRun {
/**
* The number of tests that have been executed so far
* @type {number}
*/
completedTests = 0;
/**
* Creates a new TestRun
* @param {TestSuite} testSuite - Test suite that tests belong to
* @param {Test[]} tests - List of test to run
* @param {TestSuiteDefinition} testDefinitions - Definition of tests and contexts
*/
constructor(testSuite, tests, testDefinitions) {
this.testSuite = testSuite;
this.tests = tests;
this.rootContextId = testDefinitions.rootTestContextId;
this.testContexts = tests.reduce((memo, test) => {
const testContextId = test.testContextId;
this._recursivelyAddContextsTo(memo, testContextId, testDefinitions.testContexts);
memo[testContextId].tests.unshift(test);
return memo;
}, {});
this.listeners = {
[EVENTS.TEST_STATUS]: [],
[EVENTS.TEST_SUITE_STATUS]: [],
};
}
/**
* Registers a listener for a change event
* @param {String} action - one of the actions in EVENTS
* @param {Function} callback - Callback that accepts event object
*/
onChange(action, callback) {
this.listeners[action].push(callback);
}
/**
* Walks up a context tree, copying test contexts from a source object to a target one.
* Used for ensuring all of a test's parent contexts are added to the target object.
* @param {Object<Number,TestContext>} target - Object to put test contexts in
* @param {Number} id - Id of current context to add to target
* @param {Object<Number,TestContext>} source - Object to get complete list of test contexts
* from.
* @param {Number} childContextId - id of child of current context
* @private
*/
_recursivelyAddContextsTo(target, id, source, childContextId = null) {
const testContext = source[id];
if (!target[id]) {
// eslint-disable-next-line no-param-reassign
target[id] = {
...testContext,
tests: [],
childContextIds: {},
};
}
if (childContextId) {
// eslint-disable-next-line no-param-reassign
target[id].childContextIds[childContextId] = true;
}
const parentContextId = testContext.parentContextId;
if (parentContextId) {
this._recursivelyAddContextsTo(target, parentContextId, source, id);
}
}
_updateStatus(action, values) {
const listeners = this.listeners[action];
listeners.forEach(listener => listener(values));
}
/**
* Execute the tests TestRun was initialised with.
* @returns {Promise.<void>} Resolves once all the tests in the test suite have
* completed running.
*/
async execute() {
const store = this.testSuite.reduxStore;
if (!store) {
testRuntimeError(`Failed to run ${this.testSuite.name} tests as no Redux store has been provided`);
}
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
suiteId: this.testSuite.id,
status: RunStatus.RUNNING,
progress: 0,
time: 0,
});
// Start timing
this.runStartTime = Date.now();
const rootContext = this.testContexts[this.rootContextId];
if (rootContext) {
await this._runTestsInContext(rootContext);
const errors = this.tests.filter(test => test.status === RunStatus.ERR);
if (errors.length) {
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
suiteId: this.testSuite.id,
status: RunStatus.ERR,
progress: 100,
time: Date.now() - this.runStartTime,
message: `${errors.length} test${errors.length > 1 ? 's' : ''} has error(s).`,
});
} else {
this._updateStatus(EVENTS.TEST_SUITE_STATUS, ({
suiteId: this.testSuite.id,
status: RunStatus.OK,
progress: 100,
time: Date.now() - this.runStartTime,
message: '',
}));
}
}
}
/**
* Recursively enter a test context and run its before, beforeEach hooks where
* appropriate; execute the test and then run afterEach and after hooks where
* appropriate.
* @param {TestContext} testContext - context to run hooks for
* @param {Function[][]} beforeEachHooks - stack of beforeEach hooks defined
* in parent contexts that should be run beforeEach test in child contexts
* @param {Function[][]} afterEachHooks - stack of afterEach hooks defined
* in parent contexts that should be run afterEach test in child contexts
* @returns {Promise.<void>} Resolves once all tests and their hooks have run
* @private
*/
async _runTestsInContext(testContext, beforeEachHooks = [], afterEachHooks = []) {
const beforeHookRan = await this._runContextHooks(testContext, 'before');
if (beforeHookRan) {
beforeEachHooks.push(testContext.beforeEachHooks || []);
afterEachHooks.unshift(testContext.afterEachHooks || []);
await this._runTests(testContext, testContext.tests, flatten(beforeEachHooks), flatten(afterEachHooks));
await Promise.each(Object.keys(testContext.childContextIds), (childContextId) => {
const childContext = this.testContexts[childContextId];
return this._runTestsInContext(childContext, beforeEachHooks, afterEachHooks);
});
beforeEachHooks.pop();
afterEachHooks.shift();
await this._runContextHooks(testContext, 'after');
}
}
/**
* Synchronously run hooks in context's (before|after) hooks, starting from the first
* hook
* @param {TestContext} testContext - context containing hooks
* @param {('before'|'after')} hookName - name of hooks to run callbacks for
* @returns {Promise.<*>} Resolves when last hook in list has been executed
* @private
*/
async _runContextHooks(testContext, hookName) {
const hooks = testContext[`${hookName}Hooks`] || [];
return this._runHookChain(null, Date.now(), testContext, hookName, hooks);
}
_runHookChain(test, testStart, testContext, hookName, hooks) {
return Promise.each(hooks, async (hook) => {
const error = await this._safelyRunFunction(hook.callback, hook.timeout, `${hookName} hook`);
if (error) {
const errorPrefix = `Error occurred in "${testContext.name}" ${hookName} Hook: `;
if (test) {
this._reportTestError(test, error, Date.now() - testStart, errorPrefix);
} else {
this._reportAllTestsAsFailed(testContext, error, testStart, errorPrefix);
}
throw new Error();
}
}).then(() => true).catch(() => false);
}
_reportAllTestsAsFailed(testContext, error, testStart, errorPrefix) {
testContext.tests.forEach((test) => {
this._reportTestError(test, error, Date.now() - testStart, errorPrefix);
});
testContext.childContextIds.forEach((contextId) => {
this._reportAllTestsAsFailed(this.testContext[contextId], error, testStart, errorPrefix);
});
}
/**
* Synchronously run a list of tests
* @param {TestContext} testContext - Test context to run beforeEach and AfterEach hooks
* for
* @param {Test[]} tests - List of tests to run
* @param {Function[]} beforeEachHooks - list of functions to run before each test
* @param {Function[]} afterEachHooks - list of functions to run after each test
* @returns {Promise.<void>} - Resolves once all tests and their afterEach hooks have
* been run
* @private
*/
async _runTests(testContext, tests, beforeEachHooks, afterEachHooks) {
return Promise.each(tests, async (test) => {
this._updateStatus(EVENTS.TEST_STATUS, {
testId: test.id,
status: RunStatus.RUNNING,
time: 0,
message: '',
});
const testStart = Date.now();
const beforeEachRan = await this._runHookChain(test, testStart, testContext, 'beforeEach', beforeEachHooks);
if (beforeEachRan) {
const error = await this._safelyRunFunction(test.func.bind(null, [test, this.testSuite.reduxStore.getState()]), test.timeout, 'Test');
// Update test status
if (error) {
this._reportTestError(test, error, Date.now() - testStart);
} else {
// eslint-disable-next-line no-param-reassign
test.status = RunStatus.OK;
this._updateStatus(EVENTS.TEST_STATUS, {
testId: test.id,
status: RunStatus.OK,
time: Date.now() - testStart,
message: '',
});
}
// Update suite progress
this.completedTests += 1;
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
suiteId: this.testSuite.id,
status: RunStatus.RUNNING,
progress: (this.completedTests / this.tests.length) * 100,
time: Date.now() - this.runStartTime,
message: '',
});
await this._runHookChain(test, testStart, testContext, 'afterEach', afterEachHooks);
}
})
.catch((error) => {
this._updateStatus(EVENTS.TEST_SUITE_STATUS, {
suiteId: this.testSuite.id,
status: RunStatus.ERR,
time: Date.now() - this.runStartTime,
message: `Test suite failed: ${error.message}`,
stackTrace: error.stack,
});
});
}
_reportTestError(test, error, time, errorPrefix = '') {
// eslint-disable-next-line no-param-reassign
test.status = RunStatus.ERR;
this._updateStatus(EVENTS.TEST_STATUS, {
testId: test.id,
status: RunStatus.ERR,
time,
message: `${errorPrefix}${error.message ? `${error.name}: ${error.message}` : error}`,
stackTrace: error.stack,
});
}
async _safelyRunFunction(func, timeOutDuration, description) {
const syncResultOrPromise = tryCatcher(func);
if (syncResultOrPromise.error) {
// Synchronous Error
return syncResultOrPromise.error;
}
// Asynchronous Error
return promiseToCallBack(syncResultOrPromise.value, timeOutDuration, description);
}
}
/**
* Try catch to object
* @returns {{}}
* @private
*/
function tryCatcher(func) {
const result = {};
try {
result.value = func();
} catch (e) {
result.error = e;
}
return result;
}
/**
* Make a promise callback-able to trap errors
* @param promise
* @private
*/
function promiseToCallBack(promise, timeoutDuration, description) {
let returnValue = null;
try {
returnValue = Promise.resolve(promise)
.then(() => {
return null;
}, (error) => {
return Promise.resolve(error);
})
.timeout(timeoutDuration, `${description} took longer than ${timeoutDuration}ms. This can be extended with the timeout option.`)
.catch((error) => {
return Promise.resolve(error);
});
} catch (error) {
returnValue = Promise.resolve(error);
}
return returnValue;
}
/**
* Flatten a two dimensional array to a single dimensional array
* @param {*[]} list - two dimensional array
* @returns {*[]} One-dimensional array
*/
function flatten(list) {
return list.reduce((memo, contextHooks) => {
return memo.concat(contextHooks);
}, []);
}
/**
* Log a runtime error to the console
* @param {String} error - Message to log to the console
*/
function testRuntimeError(error) {
console.error(`ReactNativeFirebaseTests.TestRuntimeError: ${error}`);
}
export default TestRun;

148
tests/lib/TestSuite.js Normal file
View File

@@ -0,0 +1,148 @@
import 'should';
import 'should-sinon';
import TestSuiteDefinition from './TestSuiteDefinition';
import TestRun from './TestRun';
/**
* Incrementing counter to assign each test suite a globally unique id. Should be
* accessed only through assignTestSuiteId
* @type {number} Counter that maintains globally unique id and increments each time
* a new id is assigned
*/
let testSuiteCounter = 0;
/**
* Increment the testSuiteCounter and return the new value. Used
* for assigning a new globally unique id to a test suite.
* @returns {number} globally unique id assigned to each test suite
*/
function assignTestSuiteId() {
testSuiteCounter += 1;
return testSuiteCounter;
}
/**
* Class that provides imperative interface for constructing and running a test suite.
* Used for instantiating a new test suite, adding tests to it and then running all or
* a subset of those tests.
*
* @example
* // Creates a new test suite
* const testSuit = new TestSuite('Feature Group A', 'Feature a, b and c');
*/
class TestSuite {
/**
* Creates a new test suite.
* @param {String} name - The name of the test suite
* @param {String} description - A short description of the test suite
* @param {Object} firebase - Object containing native and web firebase instances
* @param {Object} firebase.native - Native firebase instance
* @para {Object} firebase.web - Web firebase instance
*/
constructor(name, description, firebase) {
this.id = assignTestSuiteId();
this.name = name;
this.description = description;
this.reduxStore = null;
this.testDefinitions = new TestSuiteDefinition(this, firebase);
}
/**
* @typedef {Function} TestDefinitionFunction
* @param {TestDSL} testDSL - class instance that defines the testing DSL that can
* be used in defining tests
*/
/**
* Adds tests defined in a function to the test suite
* @param {TestDefinitionFunction} testDefinition - A function that defines one or
* more test suites using the test DSL.
* @example
* // Adding tests
* const testDefinition = function({ describe, it }) {
* describe('Some context', () => {
* it('then does something', () => {
* // Test assertions here
* })
* })
* testSuite.addTests(testDefinition);
*/
addTests(testDefinition) {
testDefinition(this.testDefinitions.DSL);
}
/**
* @typedef {Object} ReduxStore
* @property {Function} getState - Returns the current state of the store
* @property {Function} dispatch - Dispatches a new action to update to store
*/
/**
* Sets the redux store assigned to the test suite
* @param {ReduxStore} store - The redux store to add to the test suite
* @param {Function} testSuiteAction - Function that accepts an object of
* event values and returns another that is suitable to dispatch to the
* redux store. Responsible for handling events when the test suite's status
* has changed.
* @param {Function} testAction - Function that accepts an object of
* event values and returns another that is suitable to dispatch to the
* redux store. Responsible for handling events when a test's status
* has changed.
*/
setStore(store, testSuiteAction, testAction) {
this.reduxStore = store;
this.suiteChangHandler = testSuiteAction;
this.testChangHandler = testAction;
}
/**
* Run all the tests matching an array of ids. If the array is not provided, run all
* test in the test suite.
* @param {number[]=} testIds - array of ids for tests to run
* @throws {RangeError} testIds must correspond with tests in the test suite
* @example
* // Running all tests in the test suite
* testSuite.run();
* @example
* // Run only tests with id 1 and 2
* testSuite.run([1, 2]);
*/
async run(testIds = undefined) {
const testsToRun = (() => {
if (testIds) {
return testIds.map((id) => {
const test = this.testDefinitions.tests[id];
if (!test) {
throw new RangeError(`ReactNativeFirebaseTests.TestRunError: Test with id ${id} not found in test suite ${this.name}`);
}
return test;
});
}
return Object.values(this.testDefinitions.tests);
})();
const testRun = new TestRun(this, testsToRun.reverse(), this.testDefinitions);
testRun.onChange('TEST_SUITE_STATUS', (values) => {
if (this.suiteChangHandler) {
this.suiteChangHandler(values);
}
});
testRun.onChange('TEST_STATUS', (values) => {
if (this.testChangHandler) {
this.testChangHandler(values);
}
});
await testRun.execute();
}
}
export default TestSuite;

View File

@@ -0,0 +1,360 @@
import TestDSL from './TestDSL';
/**
* Incrementing counter to assign each test context a globally unique id.
* @type {number} Counter that maintains globally unique id and increments each time
* a new id is assigned
*/
let testContextCounter = 0;
/**
* Incrementing counter to assign each test a globally unique id.
* @type {number} Counter that maintains globally unique id and increments each time
* a new id is assigned
*/
let testCounter = 0;
/**
* Increment the testCounter and return the new value. Used
* for assigning a new globally unique id to a test.
* @returns {number} globally unique id assigned to each test
*/
function assignTestId() {
testCounter += 1;
return testCounter;
}
/**
* Increment the testContextCounter and return the new value. Used
* for assigning a new globally unique id to a test context.
* @returns {number} globally unique id assigned to each test context
*/
function assignContextId() {
testContextCounter += 1;
return testContextCounter;
}
/**
* Enum for operators that can be used when combining test properties with their
* parents at definition time.
* @readonly
* @enum {String} ContextOperator
*/
const CONTEXT_OPERATORS = {
/** Perform OR of test value with context chain values **/
OR: 'OR',
};
/**
* Class that provides imperative interface for defining tests. When defining tests
* the declarative interface for this class, {@link TestDSL} should be use instead.
*/
class TestSuiteDefinition {
/**
* Creates a new TestSuiteDefinition
* @param {TestSuite} testSuite - The {@link TestSuite} instance for which to
* define tests for.
* @param {Object} firebase - Object containing native and web firebase instances
* @param {Object} firebase.native - Native firebase instance
* @para {Object} firebase.web - Web firebase instance
*/
constructor(testSuite, firebase) {
this.testSuite = testSuite;
this.tests = {};
this.pendingTestIds = {};
this.focusedTestIds = {};
this.testContexts = {};
this.rootTestContextId = assignContextId();
this.rootTestContext = this._initialiseContext(this.rootTestContextId, {
name: '',
focus: false,
pending: false,
parentContextId: null,
});
this.currentTestContext = this.rootTestContext;
this._testDSL = new TestDSL(this, firebase);
}
/**
* Get the instance of {@link TestDSL} used for declaratively defining tests
* @returns {TestDSL} The TestDSL used for defining tests
*/
get DSL() {
return this._testDSL;
}
/**
* Add a function as a before hook to the current test context
* @param {Function} callback - Function to add as before hook to current test context
*/
addBeforeHook(callback, options = {}) {
this._addHook('before', callback, options);
}
/**
* Add a function as a before each hook to the current test context
* @param {Function} callback - Function to add as before each hook to current test
* context
*/
addBeforeEachHook(callback, options = {}) {
this._addHook('beforeEach', callback, options);
}
/**
* Add a function as a after each hook to the current test context
* @param {Function} callback - Function to add as after each hook to current test context
*/
addAfterEachHook(callback, options = {}) {
this._addHook('afterEach', callback, options);
}
/**
* Add a function as a after hook to the current test context
* @param {Function} callback - Function to add as after hook to current test context
*/
addAfterHook(callback, options = {}) {
this._addHook('after', callback, options);
}
/**
* Add a function to the list of hooks matching hookName, for the current test context
* @param {('before'|'beforeEach'|'afterEach'|'after')} hookName - The name of the hook to add the function to
* @param {Function} callback - Function to add as a hook
* @param {Object=} options - Hook configuration options
* @private
*/
_addHook(hookName, callback, options = {}) {
const hookAttribute = `${hookName}Hooks`;
if (callback && typeof callback === 'function') {
this.currentTestContext[hookAttribute] = this.currentTestContext[hookAttribute] || [];
this.currentTestContext[hookAttribute].push({
callback,
timeout: options.timeout || 5000,
});
} else {
testDefinitionError(`non-function value ${callback} passed to ${hookName} for '${this.currentTestContext.name}'`);
}
}
/**
* @typedef {Object} ContextOptions
* @property {Boolean} [focused=undefined] - whether context is focused or not.
* @property {Boolean} [pending=undefined] - whether context is pending or not.
*/
/**
* @typedef {Object} TestOptions
* @extends ContextOptions
* @property {Number} [timeout=5000] - Number of milliseconds before test times out
*/
/**
* Push a test context onto the context stack, making it the new current test context
* @param {String} name - The name of the new context
* @param {ContextOptions} options - Options for new context
*/
pushTestContext(name, options = {}) {
const testContextId = assignContextId();
const parentContext = this.currentTestContext;
this.currentTestContext = this._initialiseContext(testContextId, Object.assign({ name, parentContextId: parentContext.id }, options));
}
/**
* Pop test context off the context stack, making the previous context the new
* current context.
*/
popTestContext() {
const parentContextId = this.currentTestContext.parentContextId;
this.currentTestContext = this.testContexts[parentContextId];
}
/**
* Add a test to the current test context
* @param {String} description - The new test's description
* @param {ContextOptions} options - The options for the new test
* @param {Function} testFunction - The function that comprises the test's body
*/
addTest(description, options, testFunction = undefined) {
let _testFunction;
let _options;
if (testFunction) {
_testFunction = testFunction;
_options = options;
} else {
_testFunction = options;
_options = {};
}
if (_testFunction && typeof _testFunction === 'function') {
// Create test
const testId = assignTestId();
this._createTest(testId, {
testContextId: this.currentTestContext.id,
testSuiteId: this.testSuite.id,
description: this._testDescriptionContextPrefix(this.currentTestContext) + description,
func: _testFunction,
timeout: _options.timeout || 5000,
});
// Add tests to context
this.currentTestContext.testIds.push(testId);
if (_options.focus || this.currentTestContext.focus) {
this.focusedTestIds[testId] = true;
}
if (_options.pending || this.currentTestContext.pending) {
this.pendingTestIds[testId] = true;
}
} else {
testDefinitionError(`Invalid test function for "${description}".`);
}
}
/**
* Get the prefix to prepend to a test to fully describe it. Any context that is
* nested 2 or more deep, i.e. non the root context nor a child of the root context,
* has its name recursively prepended to all tests in that context or contexts it
* contains. This allows tests to be easily displayed in a LinkedList during viewing
* and reporting the test suite.
* @param {Object} contextProperties - Properties of current context
* @param {Number} contextProperties.id - Id of context
* @param {String} contextProperties.name - Name of context
* @param {Number} contextProperties.parentContextId - Id of context's parent
* @param {String} [suffix=''] - Accumulation of context prefixes so far. Starts empty
* and collects context prefixes as it recursively calls itself to iterate up the
* context tree.
* @returns {String} Prefix to be prepended to current accumulative string of context
* names
* @private
*/
_testDescriptionContextPrefix({ id, name, parentContextId }, suffix = '') {
if (id === this.rootTestContextId || parentContextId === this.rootTestContextId) {
return suffix;
}
return this._testDescriptionContextPrefix(this.testContexts[parentContextId], `${name} ${suffix}`);
}
/**
* @typedef {Object} TestContext
* @property {Number} id - Globally unique id
* @property {String} name - Short description of context
* @property {Boolean} [focus=false] - Whether context is focused
* @property {Boolean} [pending=false] - Whether context is pending
* @property {Number} [parentContextId=undefined] - Id of context that contains the current one
* @property {Number[]} testIds - List of ids of tests to be run in current context
* @property {Number} testSuiteId - Id of test suite test context is apart of
*/
/**
* Create a context from options provided
* @param {Number} testContextId - Id to assign to new context once it's created
* @param {Object} options - options to use to create the context
* @param {String} options.name - Name of context to create
* @param {Boolean} options.focus - Whether context is focused or not
* @param {Boolean} options.pending - Whether context is pending or not
* @param {Number} [options.parentContextId=undefined] - Id of context's parent
* @returns {TestContext} New test context once it has been initialised
* @private
*/
_initialiseContext(testContextId, { name, focus, pending, parentContextId }) {
const existingContext = this.testContexts[testContextId];
if (existingContext) {
return existingContext;
}
const parentContext = this.testContexts[parentContextId];
const newTestContext = {
id: testContextId,
name,
focus: this._incorporateParentValue(parentContext, 'focus', focus, CONTEXT_OPERATORS.OR),
pending: this._incorporateParentValue(parentContext, 'pending', pending, CONTEXT_OPERATORS.OR),
parentContextId,
testIds: [],
testSuiteId: this.testSuite.id,
};
this.testContexts[testContextId] = newTestContext;
return newTestContext;
}
/**
* Recursively use an operator to consolidate a test's value with that of its test
* context chain.
* @param {TestContext} parentContext - Parent context to examine for its value
* @param {String} attributeName - name of the attribute to use from parent
* @param {*} value - Value of current context or test to use as one operand with
* the parent context's value
* @param {('OR')} operator - Operator to use to consolidate current value and
* parent context's value
* @returns {*} Consolidated value, encorporating context parents' values
* @private
*/
_incorporateParentValue(parentContext, attributeName, value, operator) {
if (!parentContext) {
return value;
}
switch (operator) {
case CONTEXT_OPERATORS.OR:
return parentContext[attributeName] || value;
default:
throw new Error(`Unknown context operator ${operator}`);
}
}
/**
* Create a new test from the options provided and add it to the suite
* @param {Number} testId - Unique id to give to the test
* @param {Object} testAttributes - attributes to create the test with
* @param {Number} testAttributes.testContextId - Id of context test belongs to
* @param {String} testAttributes.description - Short description of the test
* @param {Function} testAttributes.func - Function that comprises the body of the test
* @param {Number} testAttributes.testSuiteId - Id of test suite test belongs to
* @param {Number} testAttributes.timeout - Number of milliseconds before test times out
* @returns {Test} New test matching provided options
* @private
*/
_createTest(testId, { testContextId, description, func, testSuiteId, timeout }) {
const newTest = {
id: testId,
testContextId,
description,
func,
testSuiteId,
status: null,
message: null,
time: 0,
timeout,
};
this.tests[testId] = newTest;
return newTest;
}
}
/**
* Log test definition error to the console with a message indicating the test
* definition was skipped.
* @param {String} error - Error message to include in message logged to the console
*/
function testDefinitionError(error) {
console.error(`ReactNativeFirebaseTests.TestDefinitionError: ${error}`);
console.error('This test was ignored.');
}
export default TestSuiteDefinition;