summaryrefslogtreecommitdiff
path: root/preact/test-utils
diff options
context:
space:
mode:
Diffstat (limited to 'preact/test-utils')
-rw-r--r--preact/test-utils/package.json19
-rw-r--r--preact/test-utils/src/index.d.ts3
-rw-r--r--preact/test-utils/src/index.js117
-rw-r--r--preact/test-utils/test/shared/act.test.js479
-rw-r--r--preact/test-utils/test/shared/rerender.test.js59
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>');
+ });
+});