diff options
Diffstat (limited to 'preact/test-utils')
-rw-r--r-- | preact/test-utils/package.json | 19 | ||||
-rw-r--r-- | preact/test-utils/src/index.d.ts | 3 | ||||
-rw-r--r-- | preact/test-utils/src/index.js | 117 | ||||
-rw-r--r-- | preact/test-utils/test/shared/act.test.js | 479 | ||||
-rw-r--r-- | preact/test-utils/test/shared/rerender.test.js | 59 |
5 files changed, 677 insertions, 0 deletions
diff --git a/preact/test-utils/package.json b/preact/test-utils/package.json new file mode 100644 index 0000000..b7da0b7 --- /dev/null +++ b/preact/test-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "test-utils", + "amdName": "preactTestUtils", + "version": "0.1.0", + "private": true, + "description": "Test-utils for Preact", + "main": "dist/testUtils.js", + "module": "dist/testUtils.module.js", + "umd:main": "dist/testUtils.umd.js", + "source": "src/index.js", + "license": "MIT", + "types": "src/index.d.ts", + "peerDependencies": { + "preact": "^10.0.0" + }, + "mangle": { + "regex": "^_" + } +} diff --git a/preact/test-utils/src/index.d.ts b/preact/test-utils/src/index.d.ts new file mode 100644 index 0000000..d786e73 --- /dev/null +++ b/preact/test-utils/src/index.d.ts @@ -0,0 +1,3 @@ +export function setupRerender(): () => void; +export function act(callback: () => void | Promise<void>): Promise<void>; +export function teardown(): void; diff --git a/preact/test-utils/src/index.js b/preact/test-utils/src/index.js new file mode 100644 index 0000000..0883d7e --- /dev/null +++ b/preact/test-utils/src/index.js @@ -0,0 +1,117 @@ +import { options } from 'preact'; + +/** + * Setup a rerender function that will drain the queue of pending renders + * @returns {() => void} + */ +export function setupRerender() { + options.__test__previousDebounce = options.debounceRendering; + options.debounceRendering = cb => (options.__test__drainQueue = cb); + return () => options.__test__drainQueue && options.__test__drainQueue(); +} + +const isThenable = value => value != null && typeof value.then == 'function'; + +/** Depth of nested calls to `act`. */ +let actDepth = 0; + +/** + * Run a test function, and flush all effects and rerenders after invoking it. + * + * Returns a Promise which resolves "immediately" if the callback is + * synchronous or when the callback's result resolves if it is asynchronous. + * + * @param {() => void|Promise<void>} cb The function under test. This may be sync or async. + * @return {Promise<void>} + */ +export function act(cb) { + if (++actDepth > 1) { + // If calls to `act` are nested, a flush happens only when the + // outermost call returns. In the inner call, we just execute the + // callback and return since the infrastructure for flushing has already + // been set up. + // + // If an exception occurs, the outermost `act` will handle cleanup. + const result = cb(); + if (isThenable(result)) { + return result.then(() => { + --actDepth; + }); + } + --actDepth; + return Promise.resolve(); + } + + const previousRequestAnimationFrame = options.requestAnimationFrame; + const rerender = setupRerender(); + + /** @type {() => void} */ + let flush, toFlush; + + // Override requestAnimationFrame so we can flush pending hooks. + options.requestAnimationFrame = fc => (flush = fc); + + const finish = () => { + try { + rerender(); + while (flush) { + toFlush = flush; + flush = null; + + toFlush(); + rerender(); + } + teardown(); + } catch (e) { + if (!err) { + err = e; + } + } + + options.requestAnimationFrame = previousRequestAnimationFrame; + --actDepth; + }; + + let err; + let result; + + try { + result = cb(); + } catch (e) { + err = e; + } + + if (isThenable(result)) { + return result.then(finish, err => { + finish(); + throw err; + }); + } + + // nb. If the callback is synchronous, effects must be flushed before + // `act` returns, so that the caller does not have to await the result, + // even though React recommends this. + finish(); + if (err) { + throw err; + } + return Promise.resolve(); +} + +/** + * Teardown test environment and reset preact's internal state + */ +export function teardown() { + if (options.__test__drainQueue) { + // Flush any pending updates leftover by test + options.__test__drainQueue(); + delete options.__test__drainQueue; + } + + if (typeof options.__test__previousDebounce != 'undefined') { + options.debounceRendering = options.__test__previousDebounce; + delete options.__test__previousDebounce; + } else { + options.debounceRendering = undefined; + } +} diff --git a/preact/test-utils/test/shared/act.test.js b/preact/test-utils/test/shared/act.test.js new file mode 100644 index 0000000..7769b5b --- /dev/null +++ b/preact/test-utils/test/shared/act.test.js @@ -0,0 +1,479 @@ +import { options, createElement, render } from 'preact'; +import { useEffect, useReducer, useState } from 'preact/hooks'; +import { act } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +// IE11 doesn't support `new Event()` +function createEvent(name) { + if (typeof Event == 'function') return new Event(name); + + const event = document.createEvent('Event'); + event.initEvent(name, true, true); + return event; +} + +describe('act', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + options.debounceRendering = undefined; + }); + + it('should reset options after act finishes', () => { + expect(options.requestAnimationFrame).to.equal(undefined); + act(() => null); + expect(options.requestAnimationFrame).to.equal(undefined); + }); + + it('should flush pending effects', () => { + let spy = sinon.spy(); + function StateContainer() { + useEffect(spy); + return <div />; + } + act(() => render(<StateContainer />, scratch)); + expect(spy).to.be.calledOnce; + }); + + it('should flush pending and initial effects', () => { + const spy = sinon.spy(); + function StateContainer() { + const [count, setCount] = useState(0); + useEffect(() => spy(), [count]); + return ( + <div> + <p>Count: {count}</p> + <button onClick={() => setCount(c => c + 11)} /> + </div> + ); + } + + act(() => render(<StateContainer />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.textContent).to.include('Count: 0'); + act(() => { + const button = scratch.querySelector('button'); + button.click(); + expect(spy).to.be.calledOnce; + expect(scratch.textContent).to.include('Count: 0'); + }); + expect(spy).to.be.calledTwice; + expect(scratch.textContent).to.include('Count: 1'); + }); + + it('should flush series of hooks', () => { + const spy = sinon.spy(); + const spy2 = sinon.spy(); + function StateContainer() { + const [count, setCount] = useState(0); + useEffect(() => { + spy(); + if (count === 1) { + setCount(() => 2); + } + }, [count]); + useEffect(() => { + if (count === 2) { + spy2(); + setCount(() => 4); + return () => setCount(() => 3); + } + }, [count]); + return ( + <div> + <p>Count: {count}</p> + <button onClick={() => setCount(c => c + 1)} /> + </div> + ); + } + act(() => render(<StateContainer />, scratch)); + expect(spy).to.be.calledOnce; + expect(scratch.textContent).to.include('Count: 0'); + act(() => { + const button = scratch.querySelector('button'); + button.click(); + }); + expect(spy.callCount).to.equal(5); + expect(spy2).to.be.calledOnce; + expect(scratch.textContent).to.include('Count: 3'); + }); + + it('should drain the queue of hooks', () => { + const spy = sinon.spy(); + function StateContainer() { + const [count, setCount] = useState(0); + useEffect(() => spy()); + return ( + <div> + <p>Count: {count}</p> + <button onClick={() => setCount(c => c + 11)} /> + </div> + ); + } + + render(<StateContainer />, scratch); + expect(scratch.textContent).to.include('Count: 0'); + act(() => { + const button = scratch.querySelector('button'); + button.click(); + expect(scratch.textContent).to.include('Count: 0'); + }); + expect(scratch.textContent).to.include('Count: 1'); + }); + + it('should restore options.requestAnimationFrame', () => { + const spy = sinon.spy(); + + options.requestAnimationFrame = spy; + act(() => null); + + expect(options.requestAnimationFrame).to.equal(spy); + expect(spy).to.not.be.called; + }); + + it('should restore options.debounceRendering', () => { + const spy = sinon.spy(); + + options.debounceRendering = spy; + act(() => null); + + expect(options.debounceRendering).to.equal(spy); + expect(spy).to.not.be.called; + }); + + it('should restore options.debounceRendering when it was undefined before', () => { + act(() => null); + expect(options.debounceRendering).to.equal(undefined); + }); + + it('should flush state updates if there are pending state updates before `act` call', () => { + function CounterButton() { + const [count, setCount] = useState(0); + const increment = () => setCount(count => count + 1); + return <button onClick={increment}>{count}</button>; + } + + render(<CounterButton />, scratch); + const button = scratch.querySelector('button'); + + // Click button. This will schedule an update which is deferred, as is + // normal for Preact, since it happens outside an `act` call. + button.dispatchEvent(createEvent('click')); + + expect(button.textContent).to.equal('0'); + + act(() => { + // Click button a second time. This will schedule a second update. + button.dispatchEvent(createEvent('click')); + }); + // All state updates should be applied synchronously after the `act` + // callback has run but before `act` returns. + expect(button.textContent).to.equal('2'); + }); + + it('should flush effects if there are pending effects before `act` call', () => { + function Counter() { + const [count, setCount] = useState(0); + useEffect(() => { + setCount(count => count + 1); + }, []); + return <div>{count}</div>; + } + + // Render a component which schedules an effect outside of an `act` + // call. This will be scheduled to execute after the next paint as usual. + render(<Counter />, scratch); + expect(scratch.firstChild.textContent).to.equal('0'); + + // Render a component inside an `act` call, this effect should be + // executed synchronously before `act` returns. + act(() => { + render(<div />, scratch); + render(<Counter />, scratch); + }); + expect(scratch.firstChild.textContent).to.equal('1'); + }); + + it('returns a Promise if invoked with a sync callback', () => { + const result = act(() => {}); + expect(result.then).to.be.a('function'); + return result; + }); + + it('returns a Promise if invoked with an async callback', () => { + const result = act(async () => {}); + expect(result.then).to.be.a('function'); + return result; + }); + + it('should await "thenable" result of callback before flushing', async () => { + const events = []; + + function TestComponent() { + useEffect(() => { + events.push('flushed effect'); + }, []); + events.push('scheduled effect'); + return <div>Test</div>; + } + + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + + events.push('began test'); + const acted = act(async () => { + events.push('began act callback'); + await delay(1); + render(<TestComponent />, scratch); + events.push('end act callback'); + }); + events.push('act returned'); + await acted; + events.push('act result resolved'); + + expect(events).to.deep.equal([ + 'began test', + 'began act callback', + 'act returned', + 'scheduled effect', + 'end act callback', + 'flushed effect', + 'act result resolved' + ]); + }); + + context('when `act` calls are nested', () => { + it('should invoke nested sync callback and return a Promise', () => { + let innerResult; + const spy = sinon.stub(); + + act(() => { + innerResult = act(spy); + }); + + expect(spy).to.be.calledOnce; + expect(innerResult.then).to.be.a('function'); + }); + + it('should invoke nested async callback and return a Promise', async () => { + const events = []; + + await act(async () => { + events.push('began outer act callback'); + await act(async () => { + events.push('began inner act callback'); + await Promise.resolve(); + events.push('end inner act callback'); + }); + events.push('end outer act callback'); + }); + events.push('act finished'); + + expect(events).to.deep.equal([ + 'began outer act callback', + 'began inner act callback', + 'end inner act callback', + 'end outer act callback', + 'act finished' + ]); + }); + + it('should only flush effects when outer `act` call returns', () => { + let counter = 0; + + function Widget() { + useEffect(() => { + ++counter; + }); + const [, forceUpdate] = useReducer(x => x + 1, 0); + return <button onClick={forceUpdate}>test</button>; + } + + act(() => { + render(<Widget />, scratch); + const button = scratch.querySelector('button'); + expect(counter).to.equal(0); + + act(() => { + button.dispatchEvent(createEvent('click')); + }); + + // Effect triggered by inner `act` call should not have been + // flushed yet. + expect(counter).to.equal(0); + }); + + // Effects triggered by inner `act` call should now have been + // flushed. + expect(counter).to.equal(2); + }); + + it('should only flush updates when outer `act` call returns', () => { + function Button() { + const [count, setCount] = useState(0); + const increment = () => setCount(count => count + 1); + return <button onClick={increment}>{count}</button>; + } + + render(<Button />, scratch); + const button = scratch.querySelector('button'); + expect(button.textContent).to.equal('0'); + + act(() => { + act(() => { + button.dispatchEvent(createEvent('click')); + }); + + // Update triggered by inner `act` call should not have been + // flushed yet. + expect(button.textContent).to.equal('0'); + }); + + // Updates from outer and inner `act` calls should now have been + // flushed. + expect(button.textContent).to.equal('1'); + }); + }); + + describe('when `act` callback throws an exception', () => { + function BrokenWidget() { + throw new Error('BrokenWidget is broken'); + } + + let effectCount; + + function WorkingWidget() { + const [count, setCount] = useState(0); + + useEffect(() => { + ++effectCount; + }, []); + + if (count === 0) { + setCount(1); + } + + return <div>{count}</div>; + } + + beforeEach(() => { + effectCount = 0; + }); + + const renderBroken = () => { + act(() => { + render(<BrokenWidget />, scratch); + }); + }; + + const renderWorking = () => { + act(() => { + render(<WorkingWidget />, scratch); + }); + }; + + const tryRenderBroken = () => { + try { + renderBroken(); + } catch (e) {} + }; + + describe('synchronously', () => { + it('should rethrow the exception', () => { + expect(renderBroken).to.throw('BrokenWidget is broken'); + }); + + it('should not affect state updates in future renders', () => { + tryRenderBroken(); + renderWorking(); + expect(scratch.textContent).to.equal('1'); + }); + + it('should not affect effects in future renders', () => { + tryRenderBroken(); + renderWorking(); + expect(effectCount).to.equal(1); + }); + }); + + describe('asynchronously', () => { + const renderBrokenAsync = async () => { + await act(async () => { + render(<BrokenWidget />, scratch); + }); + }; + + it('should rethrow the exception', async () => { + let err; + try { + await renderBrokenAsync(); + } catch (e) { + err = e; + } + expect(err.message).to.equal('BrokenWidget is broken'); + }); + + it('should not affect state updates in future renders', async () => { + try { + await renderBrokenAsync(); + } catch (e) {} + + renderWorking(); + expect(scratch.textContent).to.equal('1'); + }); + + it('should not affect effects in future renders', async () => { + try { + await renderBrokenAsync(); + } catch (e) {} + + renderWorking(); + expect(effectCount).to.equal(1); + }); + }); + + describe('in an effect', () => { + function BrokenEffect() { + useEffect(() => { + throw new Error('BrokenEffect effect'); + }, []); + return null; + } + + const renderBrokenEffect = () => { + act(() => { + render(<BrokenEffect />, scratch); + }); + }; + + it('should rethrow the exception', () => { + expect(renderBrokenEffect).to.throw('BrokenEffect effect'); + }); + + it('should not affect state updates in future renders', () => { + try { + renderBrokenEffect(); + } catch (e) {} + + renderWorking(); + expect(scratch.textContent).to.equal('1'); + }); + + it('should not affect effects in future renders', () => { + try { + renderBrokenEffect(); + } catch (e) {} + + renderWorking(); + expect(effectCount).to.equal(1); + }); + }); + }); +}); diff --git a/preact/test-utils/test/shared/rerender.test.js b/preact/test-utils/test/shared/rerender.test.js new file mode 100644 index 0000000..a2e4335 --- /dev/null +++ b/preact/test-utils/test/shared/rerender.test.js @@ -0,0 +1,59 @@ +import { options, createElement, render, Component } from 'preact'; +import { teardown, setupRerender } from 'preact/test-utils'; + +/** @jsx createElement */ + +describe('setupRerender & teardown', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = document.createElement('div'); + }); + + it('should restore previous debounce', () => { + let spy = (options.debounceRendering = sinon.spy()); + + setupRerender(); + teardown(); + + expect(options.debounceRendering).to.equal(spy); + }); + + it('teardown should flush the queue', () => { + /** @type {() => void} */ + let increment; + class Counter extends Component { + constructor(props) { + super(props); + + this.state = { count: 0 }; + increment = () => this.setState({ count: this.state.count + 1 }); + } + + render() { + return <div>{this.state.count}</div>; + } + } + + sinon.spy(Counter.prototype, 'render'); + + // Setup rerender + setupRerender(); + + // Initial render + render(<Counter />, scratch); + expect(Counter.prototype.render).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal('<div>0</div>'); + + // queue rerender + increment(); + expect(Counter.prototype.render).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal('<div>0</div>'); + + // Pretend test forgot to call rerender. Teardown should do that + teardown(); + expect(Counter.prototype.render).to.have.been.calledTwice; + expect(scratch.innerHTML).to.equal('<div>1</div>'); + }); +}); |