diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..1130cc1 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "stage-2"] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb79dd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.idea diff --git a/package.json b/package.json new file mode 100644 index 0000000..2711b0c --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "acast-test-helpers", + "version": "1.0.0-rc.1", + "description": "", + "main": "dist/index.js", + "scripts": { + "preinstall": "babel src --out-dir dist", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "babel-cli": "6.8.0", + "babel-preset-es2015": "6.6.0", + "babel-preset-stage-2": "6.5.0" + }, + "dependencies": { + "jquery": "2.2.3" + } +} diff --git a/src/acceptance.js b/src/acceptance.js new file mode 100644 index 0000000..da7d8d1 --- /dev/null +++ b/src/acceptance.js @@ -0,0 +1,56 @@ +import $ from 'jquery'; +import { useRouterHistory } from 'react-router'; +import { createMemoryHistory } from 'history'; +import { unmountComponentAtNode } from 'react-dom'; +import { setupAsync, andThen, waitUntil } from './async'; + +let history; +let root; + +function setupApp(renderAppWithHistoryIntoElement) { + history = useRouterHistory(createMemoryHistory)({ queryKey: false }); + + root = document.createElement('div'); + document.body.appendChild(root); + + renderAppWithHistoryIntoElement(history, root); +} + +function teardownApp() { + unmountComponentAtNode(root); + document.body.removeChild(root); +} + +export function setupAndTeardownApp(renderAppWithHistoryIntoElement) { + setupAsync(); + + beforeEach('setup app', () => setupApp(renderAppWithHistoryIntoElement)); + + afterEach('teardown app', teardownApp); +} + +export function visit(route) { + andThen(() => { + history.push(route); + }); +} + +export function click(selector) { + andThen(() => { + const jqueryElement = $(selector); + expect(jqueryElement.length).to.equal(1, `Cannot click selector '${selector}'`); + const rawElementToClick = jqueryElement.get(0); + const clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent('click', true /* bubble */, true /* cancelable */); + rawElementToClick.dispatchEvent(clickEvent); + }); +} + +export function waitUntilExists(selector, pollInterval = 100) { + waitUntil(() => { + const selected = $(selector); + return selected.length ? selected : false; + }, pollInterval); +} + +export const find = $; \ No newline at end of file diff --git a/src/async.js b/src/async.js new file mode 100644 index 0000000..970a1b9 --- /dev/null +++ b/src/async.js @@ -0,0 +1,40 @@ +let testPromise = null; + +function setupAsync() { + beforeEach('create test promise', () => { + testPromise = Promise.resolve(); + }); + + afterEach('check test promise for errors', () => { + let previousTestPromise = testPromise; + testPromise = null; + return previousTestPromise; + }); +} + +function andThen(doThis) { + if (!testPromise) { + throw new Error('You cannot use andThen() unless you call setupAsync() at the root of the appropriate describe()!'); + } + + testPromise = testPromise.then(doThis); +} + +function resolveWhenPredicateReturnsTruthy(predicate, resolve, pollInterval) { + const returnValue = predicate(); + if (!!returnValue) { + resolve(returnValue); + } else { + setTimeout(() => { + resolveWhenPredicateReturnsTruthy(predicate, resolve, pollInterval); + }, pollInterval); + } +} + +function waitUntil(thisReturnsTruthy, pollInterval = 100) { + andThen(() => new Promise((resolve) => { + resolveWhenPredicateReturnsTruthy(thisReturnsTruthy, resolve, pollInterval); + })); +} + +export { setupAsync, andThen, waitUntil }; diff --git a/src/fetch.js b/src/fetch.js new file mode 100644 index 0000000..478c5cb --- /dev/null +++ b/src/fetch.js @@ -0,0 +1,93 @@ +let originalFetch; +let pathToPromisesMap; + +function createFakeFetch() { + return sinon.spy((path) => { + let resolve; + let reject; + + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + const promises = pathToPromisesMap[path] || []; + + promises.push({ resolve, reject, promise }); + + pathToPromisesMap[path] = promises; + return promise; + }); +} + +function pathIsAwaitingResolution(path) { + return path in pathToPromisesMap && pathToPromisesMap[path].length; +} + +function pathIsNotAwaitingResolution(path) { + return !pathIsAwaitingResolution(path); +} + +function getFormattedPathsAwaitingResolution() { + let result = []; + for (let key in pathToPromisesMap) { + pathToPromisesMap[key].forEach(() => { + result.push(`'${key}'`); + }); + } + return result.join(', '); +} + +function notSetUp() { + return !pathToPromisesMap; +} + +function throwIfNotSetUp() { + if (notSetUp()) { + throw new Error( + 'fetchRespond has to be called after setupFakeFetch() and before teardownFakeFetch()' + ); + } +} + +function throwIfPathIsNotAwaitingResolution(path) { + if (pathIsNotAwaitingResolution(path)) { + throw new Error( + `Could not find '${path}' among the fetched paths: [${getFormattedPathsAwaitingResolution()}]` + ); + } +} + +export function setupFakeFetch() { + pathToPromisesMap = {}; + originalFetch = window.fetch; + + window.fetch = createFakeFetch(); +} + +export function teardownFakeFetch() { + window.fetch = originalFetch; + pathToPromisesMap = null; +} + + +export function fetchRespond(path) { + throwIfNotSetUp(); + throwIfPathIsNotAwaitingResolution(path); + + const { resolve, reject, promise } = pathToPromisesMap[path].shift(); + + return { + resolveWith: (returnValue) => { + resolve({ + json() { + return returnValue; + }, + }); + return promise.then().then(); + }, + rejectWith: (error) => { + reject(error); + }, + }; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..6ce76ef --- /dev/null +++ b/src/index.js @@ -0,0 +1,9 @@ +import * as acceptance from './acceptance'; +import * as async from './async'; +import * as fetch from './fetch'; + +module.exports = { + ...acceptance, + ...async, + ...fetch +}; diff --git a/tests/helpers-test.js b/tests/helpers-test.js new file mode 100644 index 0000000..2c2ed68 --- /dev/null +++ b/tests/helpers-test.js @@ -0,0 +1,318 @@ +import { setupAsync, andThen, waitUntil } from '../helpers/async'; +import { waitUntilExists } from '../helpers/acceptance'; +import { setupFakeFetch, teardownFakeFetch, fetchRespond } from '../helpers/fetch'; + +describe('helpers', () => { + describe('andThen', () => { + it('cannot be called without having called setupAsync()', () => { + expect(() => { + andThen(); + }).to.throw('You cannot use andThen() unless you call setupAsync() at the root of the appropriate describe()!'); + }); + }); + + describe('waitUntil', () => { + setupAsync(); + it('resolves when predicate returns true', () => { + let value = false; + + setTimeout(() => { + value = true; + }, 200); + + waitUntil(() => value); + + andThen(() => { + expect(value).to.be.true(); + }); + }); + + it('resolves when predicate returns truthy string', () => { + let value = false; + + setTimeout(() => { + value = 'some string'; + }, 200); + + waitUntil(() => value); + }); + + it('resolves with the truthy value', () => { + waitUntil(() => 'the value, yo'); + + andThen((parameter) => { + expect(parameter).to.equal('the value, yo'); + }); + }); + + it('polls at an interval passed in milliseconds', (done) => { + let value = false; + let didComplete = false; + + setTimeout(() => { + value = true; + }, 300); + + setTimeout(() => { + value = false; + }, 700); + + waitUntil(() => value, 1000); + + andThen(() => { + didComplete = true; + }); + + setTimeout(() => { + expect(didComplete).to.be.false(); + value = true; + done(); + }, 1500); + }); + + it('makes first poll immediately', () => { + let value = true; + + setTimeout(() => { + value = false; + }, 100); + + const pollInterval = 1000; + waitUntil(() => value, pollInterval); + + andThen(() => { + expect(value).to.be.true(); + }); + }); + }); + + describe('waitUntilExists', () => { + setupAsync(); + + it('it resolves with a jquery object of the selector when it exists', () => { + setTimeout(() => { + const label = document.createElement('label'); + label.innerHTML = 'foobar'; + document.body.appendChild(label); + }, 1000); + + waitUntilExists('label:contains("foobar")'); + + andThen((label) => { + expect(label.text()).to.equal('foobar'); + }); + }); + }); + + describe('fake fetch', () => { + mocha.setup({ globals: ['fetch'] }); + + describe('tear down', () => { + it('restores whatever fetch was before setup', () => { + window.fetch = 'foobar'; + setupFakeFetch(); + teardownFakeFetch(); + expect(fetch).to.equal('foobar'); + }); + + it('forgets stored fetch calls', () => { + setupFakeFetch(); + fetch('foobar'); + teardownFakeFetch(); + expect(() => { + fetchRespond('foobar'); + }).to.throw(); + }); + }); + + describe('set up', () => { + afterEach(() => { + teardownFakeFetch(); + }); + + it('replaces window.fetch with callable function', () => { + window.fetch = 'something not callable'; + setupFakeFetch(); + fetch('/api/whatever'); + }); + }); + + describe('usage', () => { + beforeEach(setupFakeFetch); + afterEach(teardownFakeFetch); + + it('can expect fetch not to have been called', () => { + expect(fetch).not.to.have.been.called(); + }); + + it('can expect fetch to have been called', () => { + fetch('/api/foobar'); + expect(fetch).to.have.been.called(); + }); + + it('can expect path to have been fetched', () => { + const path = '/api/foobar'; + fetch(path); + expect(fetch).to.have.been.calledWith(path); + }); + + it('can expect path not to have been fetched', () => { + fetch('/api/foobar'); + expect(fetch).not.to.have.been.calledWith('/other/path'); + }); + + it('fetch returns promise', () => { + const result = fetch('foobar'); + expect(result).to.be.an.instanceOf(Promise); + }); + + describe('fetchRespond', () => { + it('can reply to fetch with json', (done) => { + const callback = sinon.spy(); + fetch('/api/foobar') + .then(response => response.json()) + .then(callback).then(() => { + expect(callback).to.have.been.calledOnce().and.to.have.been.calledWith({ foo: 'bar' }); + }) + .then(done); + + fetchRespond('/api/foobar').resolveWith({ foo: 'bar' }); + }); + + it('returns a promise that can be used for testing chains', (done) => { + const callback = sinon.spy(); + fetch('path').then(response => response.json()).then(callback); + + fetchRespond('path').resolveWith({ key: 'value' }).then(() => { + expect(callback).to.have.been.calledWith({ key: 'value' }); + }).then(done).catch(done); + }); + + it('returns a promise that can be used for testing longer chains', (done) => { + const callback = sinon.spy(); + fetch('path').then(response => response.json()).then(json => json.key).then(callback); + + fetchRespond('path').resolveWith({ key: 'value' }).then(() => { + expect(callback).to.have.been.calledWith('value'); + }).then(done).catch(done); + }); + + it('leaves other promises untouched when resolving fetch with json', (done) => { + const callback = sinon.spy(); + + fetch('/otherPath').then(callback).catch(callback); + + fetch('/api/foobar').then(() => { + expect(callback).not.to.have.been.called(); + done(); + }); + + fetchRespond('/api/foobar').resolveWith({ foo: 'bar' }); + }); + + it('can resolve other promise than the last one', (done) => { + const firstCallback = sinon.spy(); + const secondCallback = sinon.spy(); + + fetch('/firstPath').then(firstCallback).then(done); + + fetch('/second/path').then(secondCallback).then(() => done('Error: this should not be called')); + + fetchRespond('/firstPath').resolveWith({ foo: 'bar' }); + }); + + it('can reply to fetch with other json', (done) => { + const callback = sinon.spy(); + fetch('/somepath') + .then(response => response.json()) + .then(callback).then(() => { + expect(callback).to.have.been.calledOnce(); + expect(callback).to.have.been.calledWith('some response'); + }) + .then(done); + + fetchRespond('/somepath').resolveWith('some response'); + }); + + it('can reject fetch with error', (done) => { + const callback = sinon.spy(); + fetch('/somepath') + .catch(callback) + .then(() => { + expect(callback).to.have.been.calledOnce().and.to.have.been.calledWith('some error'); + }) + .then(done); + + fetchRespond('/somepath').rejectWith('some error'); + }); + + it('throws if called with uncalled path', () => { + expect(() => { + fetchRespond('/path/that/was/never/called'); + }).to.throw("Could not find '/path/that/was/never/called' among the fetched paths: []"); + + expect(() => { + fetchRespond('other path'); + }).to.throw("Could not find 'other path' among the fetched paths: []"); + }); + + it('throws if called with path that has already been responded to as many times as it was fetched', () => { + fetch('path'); + fetchRespond('path'); + expect(() => { + fetchRespond('path'); + }).to.throw("Could not find 'path' among the fetched paths: []"); + }); + + it('throws if called after teardown', () => { + teardownFakeFetch(); + expect(() => { + fetchRespond('/path/that/was/never/called'); + }).to.throw( + 'fetchRespond has to be called after setupFakeFetch() and before teardownFakeFetch()' + ); + }); + + it('lists called paths in error when called with unmatched path', () => { + fetch('/somepath'); + fetch('otherpath'); + fetch('/third/path'); + + expect(() => { + fetchRespond('/path/that/was/never/called'); + }).to.throw("Could not find '/path/that/was/never/called' among " + + "the fetched paths: ['/somepath', 'otherpath', '/third/path']"); + }); + + it('lists same path multiple times in error when called with unmatched path', () => { + fetch('/same'); + fetch('/same'); + fetch('/same'); + + expect(() => { + fetchRespond('/other'); + }).to.throw("Could not find '/other' among " + + "the fetched paths: ['/same', '/same', '/same']"); + }); + + it('resolves multiple calls to same path in the order they were called', (done) => { + const firstCallback = sinon.spy(); + const secondCallback = sinon.spy(); + + fetch('/same/path').then(response => firstCallback(response.json())); + fetch('/same/path').then(response => secondCallback(response.json())); + + Promise.all([ + fetchRespond('/same/path').resolveWith({ order: 'first' }), + fetchRespond('/same/path').resolveWith({ order: 'second' })] + ).then(() => { + expect(firstCallback).to.have.been.calledOnce().and.to.have.been.calledWith({ order: 'first' }); + expect(secondCallback).to.have.been.calledOnce().and.to.have.been.calledWith({ order: 'second' });; + + expect(secondCallback).to.have.been.calledAfter(firstCallback); + }).then(done).catch(done); + }); + }); + }); + }); +});