",
8 | "keywords": [
9 | "testing",
10 | "preact",
11 | "hooks",
12 | "unit",
13 | "integration"
14 | ],
15 | "homepage": "https://github.com/testing-library/preact-hooks-testing-library#readme",
16 | "repository": {
17 | "type": "git",
18 | "url": "https://github.com/testing-library/preact-hooks-testing-library"
19 | },
20 | "scripts": {
21 | "prepare": "npm run build",
22 | "prebuild": "npm run cleanup; npm t",
23 | "cleanup": "rimraf ./lib",
24 | "build": "tsc",
25 | "test": "jest"
26 | },
27 | "devDependencies": {
28 | "@testing-library/preact": "^2.0.0",
29 | "@types/jest": "^25.2.2",
30 | "jest": "^25",
31 | "preact": "^10.4.8",
32 | "rimraf": "^3.0.2",
33 | "ts-jest": "^25.5.1",
34 | "typescript": "^3.9.2"
35 | },
36 | "peerDependencies": {
37 | "@testing-library/preact": "^2.0.0",
38 | "preact": "^10.4.8"
39 | },
40 | "dependencies": {}
41 | }
42 |
--------------------------------------------------------------------------------
/pure.js:
--------------------------------------------------------------------------------
1 | // makes it so people can import from '@testing-library/react-hooks/pure'
2 | module.exports = require("./lib/pure");
3 |
--------------------------------------------------------------------------------
/src/TestComponent.tsx:
--------------------------------------------------------------------------------
1 | import { Callback } from "./_types";
2 |
3 | export interface TestComponentProps {
4 | callback: Callback
;
5 | hookProps?: P;
6 | children: (value: R) => void;
7 | onError: (error: Error) => void;
8 | }
9 |
10 | const TestComponent =
({
11 | callback,
12 | hookProps,
13 | children,
14 | onError,
15 | }: TestComponentProps
) => {
16 | try {
17 | const val = callback(hookProps);
18 | children(val);
19 | } catch (err) {
20 | if (err.then) {
21 | throw err;
22 | } else {
23 | onError(err);
24 | }
25 | }
26 |
27 | return null;
28 | };
29 |
30 | export const Fallback = () => null;
31 |
32 | export default TestComponent;
33 |
--------------------------------------------------------------------------------
/src/_types.ts:
--------------------------------------------------------------------------------
1 | import { ComponentType } from "preact";
2 |
3 | export type Callback
= (props?: P) => R;
4 |
5 | export type ResolverType = () => void;
6 |
--------------------------------------------------------------------------------
/src/asyncUtils.ts:
--------------------------------------------------------------------------------
1 | import { act } from "@testing-library/preact";
2 | import { ResolverType } from "./_types";
3 |
4 | export interface TimeoutOptions {
5 | timeout?: number;
6 | interval?: number;
7 | suppressErrors?: boolean;
8 | }
9 |
10 | class TimeoutError extends Error {
11 | constructor(utilName: string, { timeout }: TimeoutOptions) {
12 | super(`Timed out in ${utilName} after ${timeout}ms.`);
13 | }
14 |
15 | timeout = true;
16 | }
17 |
18 | function resolveAfter(ms: number) {
19 | return new Promise((resolve) => {
20 | setTimeout(resolve, ms);
21 | });
22 | }
23 |
24 | let hasWarnedDeprecatedWait = false;
25 |
26 | function asyncUtils(addResolver: (resolver: ResolverType) => void) {
27 | let nextUpdatePromise: Promise | void;
28 |
29 | async function waitForNextUpdate(options: TimeoutOptions = { timeout: 0 }) {
30 | if (!nextUpdatePromise) {
31 | nextUpdatePromise = new Promise((resolve, reject) => {
32 | const timeoutId =
33 | options.timeout! > 0
34 | ? setTimeout(() => {
35 | reject(new TimeoutError("waitForNextUpdate", options));
36 | }, options.timeout)
37 | : null;
38 |
39 | addResolver(() => {
40 | if (timeoutId) {
41 | clearTimeout(timeoutId);
42 | }
43 | nextUpdatePromise = undefined;
44 | resolve();
45 | });
46 | });
47 | }
48 | await nextUpdatePromise;
49 | }
50 |
51 | async function waitFor(
52 | callback: () => any,
53 | { interval, timeout, suppressErrors = true }: TimeoutOptions = {}
54 | ) {
55 | const checkResult = () => {
56 | try {
57 | const callbackResult = callback();
58 | return callbackResult || callbackResult === undefined;
59 | } catch (err) {
60 | if (!suppressErrors) {
61 | throw err;
62 | }
63 | }
64 | };
65 |
66 | const waitForResult = async () => {
67 | const initialTimeout = timeout;
68 |
69 | while (true) {
70 | const startTime = Date.now();
71 | try {
72 | const nextCheck = interval
73 | ? Promise.race([
74 | waitForNextUpdate({ timeout }),
75 | resolveAfter(interval),
76 | ])
77 | : waitForNextUpdate({ timeout });
78 |
79 | await nextCheck;
80 |
81 | if (checkResult()) {
82 | return;
83 | }
84 | } catch (err) {
85 | if (err.timeout) {
86 | throw new TimeoutError("waitFor", { timeout: initialTimeout });
87 | }
88 | throw err;
89 | }
90 | timeout! -= Date.now() - startTime;
91 | }
92 | };
93 |
94 | if (!checkResult()) {
95 | await waitForResult();
96 | }
97 | }
98 |
99 | async function waitForValueToChange(
100 | selector: () => any,
101 | options: TimeoutOptions = { timeout: 0 }
102 | ) {
103 | const initialValue = selector();
104 | try {
105 | await waitFor(() => selector() !== initialValue, {
106 | suppressErrors: false,
107 | ...options,
108 | });
109 | } catch (err) {
110 | if (err.timeout) {
111 | throw new TimeoutError("waitForValueToChange", options);
112 | }
113 | throw err;
114 | }
115 | }
116 |
117 | async function wait(
118 | callback: () => any,
119 | options: TimeoutOptions = { timeout: 0, suppressErrors: true }
120 | ) {
121 | if (!hasWarnedDeprecatedWait) {
122 | hasWarnedDeprecatedWait = true;
123 | console.warn(
124 | "`wait` has been deprecated. Use `waitFor` instead: https://react-hooks-testing-library.com/reference/api#waitfor."
125 | );
126 | }
127 | try {
128 | await waitFor(callback, options);
129 | } catch (err) {
130 | if (err.timeout) {
131 | throw new TimeoutError("wait", { timeout: options.timeout });
132 | }
133 | throw err;
134 | }
135 | }
136 |
137 | return {
138 | wait,
139 | waitFor,
140 | waitForNextUpdate,
141 | waitForValueToChange,
142 | };
143 | }
144 |
145 | export default asyncUtils;
146 |
--------------------------------------------------------------------------------
/src/cleanup.ts:
--------------------------------------------------------------------------------
1 | import flushMicroTasks from "./flush-microtasks";
2 |
3 | type CleanupCallback = () => void;
4 |
5 | let cleanupCallbacks: Set = new Set();
6 |
7 | export async function cleanup() {
8 | await flushMicroTasks();
9 | cleanupCallbacks.forEach((cb) => cb());
10 | cleanupCallbacks.clear();
11 | }
12 |
13 | export function addCleanup(callback: CleanupCallback) {
14 | cleanupCallbacks.add(callback);
15 | }
16 |
17 | export function removeCleanup(callback: CleanupCallback) {
18 | cleanupCallbacks.delete(callback);
19 | }
20 |
--------------------------------------------------------------------------------
/src/flush-microtasks.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | // the part of this file that we need tested is definitely being run
3 | // and the part that is not cannot easily have useful tests written
4 | // anyway. So we're just going to ignore coverage for this file
5 | /**
6 | * copied from React's enqueueTask.js
7 | * copied again from React Testing Library's flush-microtasks.js
8 | */
9 |
10 | let didWarnAboutMessageChannel = false;
11 | let enqueueTask;
12 | try {
13 | // read require off the module object to get around the bundlers.
14 | // we don't want them to detect a require and bundle a Node polyfill.
15 | const requireString = `require${Math.random()}`.slice(0, 7);
16 | const nodeRequire = module && module[requireString];
17 | // assuming we're in node, let's try to get node's
18 | // version of setImmediate, bypassing fake timers if any.
19 | enqueueTask = nodeRequire("timers").setImmediate;
20 | } catch (_err) {
21 | // we're in a browser
22 | // we can't use regular timers because they may still be faked
23 | // so we try MessageChannel+postMessage instead
24 | enqueueTask = (callback) => {
25 | const supportsMessageChannel = typeof MessageChannel === "function";
26 | if (supportsMessageChannel) {
27 | const channel = new MessageChannel();
28 | channel.port1.onmessage = callback;
29 | channel.port2.postMessage(undefined);
30 | } else if (didWarnAboutMessageChannel === false) {
31 | didWarnAboutMessageChannel = true;
32 |
33 | // eslint-disable-next-line no-console
34 | console.error(
35 | "This browser does not have a MessageChannel implementation, " +
36 | "so enqueuing tasks via await act(async () => ...) will fail. " +
37 | "Please file an issue at https://github.com/facebook/react/issues " +
38 | "if you encounter this warning."
39 | );
40 | }
41 | };
42 | }
43 |
44 | export default function flushMicroTasks() {
45 | return new Promise((resolve) => enqueueTask(resolve));
46 | }
47 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /* globals afterEach */
2 | import { cleanup } from "./pure";
3 |
4 | // @ts-ignore
5 | if (typeof afterEach === "function" && !process.env.PHTL_SKIP_AUTO_CLEANUP) {
6 | // @ts-ignore
7 | afterEach(async () => {
8 | await cleanup();
9 | });
10 | }
11 |
12 | export * from "./pure";
13 |
--------------------------------------------------------------------------------
/src/pure.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from "./renderHook";
2 | import { act } from "@testing-library/preact";
3 | import { cleanup } from "./cleanup";
4 |
5 | export { renderHook, act, cleanup };
6 |
--------------------------------------------------------------------------------
/src/renderHook.tsx:
--------------------------------------------------------------------------------
1 | import { h, ComponentType } from "preact";
2 | import { Suspense } from "preact/compat";
3 | import { render, act } from "@testing-library/preact";
4 |
5 | import { Callback } from "./_types";
6 | import resultContainer from "./resultContainer";
7 | import TestComponent, { Fallback } from "./TestComponent";
8 | import { removeCleanup, addCleanup } from "./cleanup";
9 | import asyncUtils from "./asyncUtils";
10 |
11 | export interface RenderHookOptions {
12 | initialProps?: P;
13 | wrapper?: ComponentType;
14 | }
15 |
16 | export function renderHook
(
17 | callback: Callback
,
18 | { initialProps, wrapper }: RenderHookOptions
= {}
19 | ) {
20 | const { result, setValue, setError, addResolver } = resultContainer();
21 |
22 | const hookProps = {
23 | current: initialProps,
24 | };
25 |
26 | const wrapUiIfNeeded = (innerElement: any) =>
27 | wrapper ? h(wrapper, hookProps.current!, innerElement) : innerElement;
28 |
29 | const TestHook = () =>
30 | wrapUiIfNeeded(
31 | }>
32 |
37 | {setValue}
38 |
39 |
40 | );
41 |
42 | const { unmount, rerender } = render();
43 |
44 | function rerenderHook(newProps = hookProps.current) {
45 | hookProps.current = newProps;
46 | act(() => {
47 | rerender();
48 | });
49 | }
50 |
51 | function unmountHook() {
52 | act(() => {
53 | removeCleanup(unmountHook);
54 | unmount();
55 | });
56 | }
57 |
58 | addCleanup(unmountHook);
59 |
60 | return {
61 | result,
62 | rerender: rerenderHook,
63 | unmount: unmountHook,
64 | ...asyncUtils(addResolver),
65 | };
66 | }
67 |
--------------------------------------------------------------------------------
/src/resultContainer.ts:
--------------------------------------------------------------------------------
1 | import { ResolverType } from "./_types";
2 |
3 | function resultContainer(initialValue?: R) {
4 | let value = initialValue;
5 | let error: Error;
6 | const resolvers: ResolverType[] = [];
7 |
8 | const result = {
9 | get current() {
10 | if (error) {
11 | throw error;
12 | }
13 | return value;
14 | },
15 | get error() {
16 | return error;
17 | },
18 | };
19 |
20 | function updateResult(val?: R, err?: Error) {
21 | value = val;
22 | error = err ? err : error;
23 | resolvers.splice(0, resolvers.length).forEach((resolve) => resolve());
24 | }
25 |
26 | return {
27 | result,
28 | setValue: (val: R) => updateResult(val),
29 | setError: (err: Error) => updateResult(undefined, err),
30 | addResolver: (resolver: ResolverType) => {
31 | resolvers.push(resolver);
32 | },
33 | };
34 | }
35 |
36 | export default resultContainer;
37 |
--------------------------------------------------------------------------------
/test/asyncHook.test.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from "preact/hooks";
2 | import { renderHook } from "../src";
3 |
4 | /**
5 | * Skipping for now as async utils are still a bit odd
6 | */
7 | describe("async hook tests", () => {
8 | const useSequence = (...values: any[]) => {
9 | const [first, ...otherValues] = values;
10 | const [value, setValue] = useState(first);
11 | const index = useRef(0);
12 |
13 | useEffect(() => {
14 | const interval = setInterval(() => {
15 | setValue(otherValues[index.current++]);
16 | if (index.current === otherValues.length) {
17 | clearInterval(interval);
18 | }
19 | }, 50);
20 | return () => {
21 | clearInterval(interval);
22 | };
23 | }, [...values]);
24 |
25 | return value;
26 | };
27 |
28 | test("should wait for next update", async () => {
29 | const { result, waitForNextUpdate } = renderHook(() =>
30 | useSequence("first", "second")
31 | );
32 |
33 | expect(result.current).toBe("first");
34 |
35 | await waitForNextUpdate();
36 |
37 | expect(result.current).toBe("second");
38 | });
39 |
40 | test("should wait for multiple updates", async () => {
41 | const { result, waitForNextUpdate } = renderHook(() =>
42 | useSequence("first", "second", "third")
43 | );
44 |
45 | expect(result.current).toBe("first");
46 |
47 | await waitForNextUpdate();
48 |
49 | expect(result.current).toBe("second");
50 |
51 | await waitForNextUpdate();
52 |
53 | expect(result.current).toBe("third");
54 | });
55 |
56 | test("should resolve all when updating", async () => {
57 | const { result, waitForNextUpdate } = renderHook(() =>
58 | useSequence("first", "second")
59 | );
60 |
61 | expect(result.current).toBe("first");
62 |
63 | await Promise.all([
64 | waitForNextUpdate(),
65 | waitForNextUpdate(),
66 | waitForNextUpdate(),
67 | ]);
68 |
69 | expect(result.current).toBe("second");
70 | });
71 |
72 | test("should reject if timeout exceeded when waiting for next update", async () => {
73 | const { result, waitForNextUpdate } = renderHook(() =>
74 | useSequence("first", "second")
75 | );
76 |
77 | expect(result.current).toBe("first");
78 |
79 | await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow(
80 | Error("Timed out in waitForNextUpdate after 10ms.")
81 | );
82 | });
83 |
84 | test("should wait for expectation to pass", async () => {
85 | const { result, waitFor } = renderHook(() =>
86 | useSequence("first", "second", "third")
87 | );
88 |
89 | expect(result.current).toBe("first");
90 |
91 | let complete = false;
92 | await waitFor(() => {
93 | expect(result.current).toBe("third");
94 | complete = true;
95 | });
96 | expect(complete).toBe(true);
97 | });
98 |
99 | test.only("should wait for arbitrary expectation to pass", async () => {
100 | const { waitFor } = renderHook(() => null);
101 |
102 | let actual = 0;
103 | let expected = 1;
104 |
105 | setTimeout(() => {
106 | actual = expected;
107 | }, 200);
108 |
109 | let complete = false;
110 | await waitFor(
111 | () => {
112 | expect(actual).toBe(expected);
113 | complete = true;
114 | },
115 | { interval: 100 }
116 | );
117 |
118 | expect(complete).toBe(true);
119 | });
120 |
121 | test("should not hang if expectation is already passing", async () => {
122 | const { result, waitFor } = renderHook(() =>
123 | useSequence("first", "second")
124 | );
125 |
126 | expect(result.current).toBe("first");
127 |
128 | let complete = false;
129 | await waitFor(() => {
130 | expect(result.current).toBe("first");
131 | complete = true;
132 | });
133 | expect(complete).toBe(true);
134 | });
135 |
136 | test("should reject if callback throws error", async () => {
137 | const { result, waitFor } = renderHook(() =>
138 | useSequence("first", "second", "third")
139 | );
140 |
141 | expect(result.current).toBe("first");
142 |
143 | await expect(
144 | waitFor(
145 | () => {
146 | if (result.current === "second") {
147 | throw new Error("Something Unexpected");
148 | }
149 | return result.current === "third";
150 | },
151 | {
152 | suppressErrors: false,
153 | }
154 | )
155 | ).rejects.toThrow(Error("Something Unexpected"));
156 | });
157 |
158 | test("should reject if callback immediately throws error", async () => {
159 | const { result, waitFor } = renderHook(() =>
160 | useSequence("first", "second", "third")
161 | );
162 |
163 | expect(result.current).toBe("first");
164 |
165 | await expect(
166 | waitFor(
167 | () => {
168 | throw new Error("Something Unexpected");
169 | },
170 | {
171 | suppressErrors: false,
172 | }
173 | )
174 | ).rejects.toThrow(Error("Something Unexpected"));
175 | });
176 |
177 | test("should wait for truthy value", async () => {
178 | const { result, waitFor } = renderHook(() =>
179 | useSequence("first", "second", "third")
180 | );
181 |
182 | expect(result.current).toBe("first");
183 |
184 | await waitFor(() => result.current === "third");
185 |
186 | expect(result.current).toBe("third");
187 | });
188 |
189 | test("should wait for arbitrary truthy value", async () => {
190 | const { waitFor } = renderHook(() => null);
191 |
192 | let actual = 0;
193 | let expected = 1;
194 |
195 | setTimeout(() => {
196 | actual = expected;
197 | }, 200);
198 |
199 | await waitFor(() => actual === 1, { interval: 100 });
200 |
201 | expect(actual).toBe(expected);
202 | });
203 |
204 | test("should reject if timeout exceeded when waiting for expectation to pass", async () => {
205 | const { result, waitFor } = renderHook(() =>
206 | useSequence("first", "second", "third")
207 | );
208 |
209 | expect(result.current).toBe("first");
210 |
211 | await expect(
212 | waitFor(
213 | () => {
214 | expect(result.current).toBe("third");
215 | },
216 | { timeout: 75 }
217 | )
218 | ).rejects.toThrow(Error("Timed out in waitFor after 75ms."));
219 | });
220 |
221 | test("should wait for value to change", async () => {
222 | const { result, waitForValueToChange } = renderHook(() =>
223 | useSequence("first", "second", "third")
224 | );
225 |
226 | expect(result.current).toBe("first");
227 |
228 | await waitForValueToChange(() => result.current === "third");
229 |
230 | expect(result.current).toBe("third");
231 | });
232 |
233 | test("should wait for arbitrary value to change", async () => {
234 | const { waitForValueToChange } = renderHook(() => null);
235 |
236 | let actual = 0;
237 | let expected = 1;
238 |
239 | setTimeout(() => {
240 | actual = expected;
241 | }, 200);
242 |
243 | await waitForValueToChange(() => actual, { interval: 100 });
244 |
245 | expect(actual).toBe(expected);
246 | });
247 |
248 | test("should reject if timeout exceeded when waiting for value to change", async () => {
249 | const { result, waitForValueToChange } = renderHook(() =>
250 | useSequence("first", "second", "third")
251 | );
252 |
253 | expect(result.current).toBe("first");
254 |
255 | await expect(
256 | waitForValueToChange(() => result.current === "third", {
257 | timeout: 75,
258 | })
259 | ).rejects.toThrow(Error("Timed out in waitForValueToChange after 75ms."));
260 | });
261 |
262 | test("should reject if selector throws error", async () => {
263 | const { result, waitForValueToChange } = renderHook(() =>
264 | useSequence("first", "second")
265 | );
266 |
267 | expect(result.current).toBe("first");
268 |
269 | await expect(
270 | waitForValueToChange(() => {
271 | if (result.current === "second") {
272 | throw new Error("Something Unexpected");
273 | }
274 | return result.current;
275 | })
276 | ).rejects.toThrow(Error("Something Unexpected"));
277 | });
278 |
279 | test("should not reject if selector throws error and suppress errors option is enabled", async () => {
280 | const { result, waitForValueToChange } = renderHook(() =>
281 | useSequence("first", "second", "third")
282 | );
283 |
284 | expect(result.current).toBe("first");
285 |
286 | await waitForValueToChange(
287 | () => {
288 | if (result.current === "second") {
289 | throw new Error("Something Unexpected");
290 | }
291 | return result.current === "third";
292 | },
293 | { suppressErrors: true }
294 | );
295 |
296 | expect(result.current).toBe("third");
297 | });
298 |
299 | test("should wait for expectation to pass (deprecated)", async () => {
300 | const { result, wait } = renderHook(() =>
301 | useSequence("first", "second", "third")
302 | );
303 |
304 | expect(result.current).toBe("first");
305 |
306 | let complete = false;
307 | await wait(() => {
308 | expect(result.current).toBe("third");
309 | complete = true;
310 | });
311 | expect(complete).toBe(true);
312 | });
313 |
314 | test("should not hang if expectation is already passing (deprecated)", async () => {
315 | const { result, wait } = renderHook(() => useSequence("first", "second"));
316 |
317 | expect(result.current).toBe("first");
318 |
319 | let complete = false;
320 | await wait(() => {
321 | expect(result.current).toBe("first");
322 | complete = true;
323 | });
324 | expect(complete).toBe(true);
325 | });
326 |
327 | test("should reject if callback throws error (deprecated)", async () => {
328 | const { result, wait } = renderHook(() =>
329 | useSequence("first", "second", "third")
330 | );
331 |
332 | expect(result.current).toBe("first");
333 |
334 | await expect(
335 | wait(
336 | () => {
337 | if (result.current === "second") {
338 | throw new Error("Something Unexpected");
339 | }
340 | return result.current === "third";
341 | },
342 | {
343 | suppressErrors: false,
344 | }
345 | )
346 | ).rejects.toThrow(Error("Something Unexpected"));
347 | });
348 |
349 | test("should reject if callback immediately throws error (deprecated)", async () => {
350 | const { result, wait } = renderHook(() =>
351 | useSequence("first", "second", "third")
352 | );
353 |
354 | expect(result.current).toBe("first");
355 |
356 | await expect(
357 | wait(
358 | () => {
359 | throw new Error("Something Unexpected");
360 | },
361 | {
362 | suppressErrors: false,
363 | }
364 | )
365 | ).rejects.toThrow(Error("Something Unexpected"));
366 | });
367 |
368 | test("should wait for truthy value (deprecated)", async () => {
369 | const { result, wait } = renderHook(() =>
370 | useSequence("first", "second", "third")
371 | );
372 |
373 | expect(result.current).toBe("first");
374 |
375 | await wait(() => result.current === "third");
376 |
377 | expect(result.current).toBe("third");
378 | });
379 |
380 | test("should reject if timeout exceeded when waiting for expectation to pass (deprecated)", async () => {
381 | const { result, wait } = renderHook(() =>
382 | useSequence("first", "second", "third")
383 | );
384 |
385 | expect(result.current).toBe("first");
386 |
387 | await expect(
388 | wait(
389 | () => {
390 | expect(result.current).toBe("third");
391 | },
392 | { timeout: 75 }
393 | )
394 | ).rejects.toThrow(Error("Timed out in wait after 75ms."));
395 | });
396 | });
397 |
--------------------------------------------------------------------------------
/test/autoCleanup.disabled.test.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "preact/hooks";
2 |
3 | // This verifies that if PHTL_SKIP_AUTO_CLEANUP is set
4 | // then we DON'T auto-wire up the afterEach for folks
5 | describe.skip("skip auto cleanup (disabled) tests", () => {
6 | let cleanupCalled = false;
7 | let renderHook: Function;
8 |
9 | beforeAll(async () => {
10 | process.env.PHTL_SKIP_AUTO_CLEANUP = "true";
11 | renderHook = (await import("../src")).renderHook;
12 | });
13 |
14 | test("first", () => {
15 | const hookWithCleanup = () => {
16 | useEffect(() => {
17 | return () => {
18 | cleanupCalled = true;
19 | };
20 | });
21 | };
22 | renderHook(() => hookWithCleanup());
23 | });
24 |
25 | test("second", () => {
26 | expect(cleanupCalled).toBe(false);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/test/autoCleanup.noAfterEach.test.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "preact/hooks";
2 |
3 | // This verifies that if PHTL_SKIP_AUTO_CLEANUP is set
4 | // then we DON'T auto-wire up the afterEach for folks
5 | describe("skip auto cleanup (no afterEach) tests", () => {
6 | let cleanupCalled = false;
7 | let renderHook: Function;
8 |
9 | beforeAll(async () => {
10 | // @ts-ignore
11 | afterEach = false;
12 | renderHook = (await import("../src")).renderHook;
13 | });
14 |
15 | test("first", () => {
16 | const hookWithCleanup = () => {
17 | useEffect(() => {
18 | return () => {
19 | cleanupCalled = true;
20 | };
21 | });
22 | };
23 | renderHook(() => hookWithCleanup());
24 | });
25 |
26 | test("second", () => {
27 | expect(cleanupCalled).toBe(false);
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/test/autoCleanup.test.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "preact/hooks";
2 | import { renderHook } from "../src";
3 |
4 | // This verifies that by importing RHTL in an
5 | // environment which supports afterEach (like Jest)
6 | // we'll get automatic cleanup between tests.
7 | describe("auto cleanup tests", () => {
8 | let cleanupCalled = false;
9 |
10 | test("first", () => {
11 | const hookWithCleanup = () => {
12 | useEffect(() => {
13 | return () => {
14 | cleanupCalled = true;
15 | };
16 | });
17 | };
18 | renderHook(() => hookWithCleanup());
19 | });
20 |
21 | test("second", () => {
22 | expect(cleanupCalled).toBe(true);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/test/cleanup.test.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "preact/hooks";
2 | import { renderHook, cleanup } from "../src";
3 |
4 | describe("cleanup tests", () => {
5 | test("should flush effects on cleanup", async () => {
6 | let cleanupCalled = false;
7 |
8 | const hookWithCleanup = () => {
9 | useEffect(() => {
10 | return () => {
11 | cleanupCalled = true;
12 | };
13 | });
14 | };
15 |
16 | renderHook(() => hookWithCleanup());
17 |
18 | await cleanup();
19 |
20 | expect(cleanupCalled).toBe(true);
21 | });
22 |
23 | test("should cleanup all rendered hooks", async () => {
24 | let cleanupCalled: boolean[] = [];
25 | const hookWithCleanup = (id: number) => {
26 | useEffect(() => {
27 | return () => {
28 | cleanupCalled[id] = true;
29 | };
30 | });
31 | };
32 |
33 | renderHook(() => hookWithCleanup(1));
34 | renderHook(() => hookWithCleanup(2));
35 |
36 | await cleanup();
37 |
38 | expect(cleanupCalled[1]).toBe(true);
39 | expect(cleanupCalled[2]).toBe(true);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/test/customHook.test.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from "preact/hooks";
2 | import { renderHook, act } from "../src";
3 |
4 | describe("Custom hooks", () => {
5 | describe("useCounter", () => {
6 | function useCounter() {
7 | const [count, setCount] = useState(0);
8 |
9 | const increment = useCallback(() => setCount(count + 1), [count]);
10 | const decrement = useCallback(() => setCount(count - 1), [count]);
11 |
12 | return { count, increment, decrement };
13 | }
14 |
15 | test("should increment counter", () => {
16 | const { result } = renderHook(() => useCounter());
17 |
18 | act(() => result.current?.increment());
19 |
20 | expect(result.current?.count).toBe(1);
21 | });
22 |
23 | test("should decrement counter", () => {
24 | const { result } = renderHook(() => useCounter());
25 |
26 | act(() => result.current?.decrement());
27 |
28 | expect(result.current?.count).toBe(-1);
29 | });
30 | });
31 |
32 | describe("return proper fasly values", () => {
33 | type Falsy = 0 | null | undefined | false | "";
34 |
35 | function useFalsy(value: Falsy) {
36 | return value;
37 | }
38 |
39 | test("`false`", () => {
40 | const { result } = renderHook(() => useFalsy(false));
41 |
42 | expect(result.current).toBe(false);
43 | });
44 |
45 | test("`0`", () => {
46 | const { result } = renderHook(() => useFalsy(0));
47 |
48 | expect(result.current).toBe(0);
49 | });
50 |
51 | test("`null`", () => {
52 | const { result } = renderHook(() => useFalsy(null));
53 |
54 | expect(result.current).toBe(null);
55 | });
56 |
57 | test("`''`", () => {
58 | const { result } = renderHook(() => useFalsy(""));
59 |
60 | expect(result.current).toBe("");
61 | });
62 |
63 | test("`undefined`", () => {
64 | const { result } = renderHook(() => useFalsy(undefined));
65 |
66 | expect(result.current).toBe(undefined);
67 | });
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/test/errorHook.test.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { useState, useEffect } from "preact/hooks";
3 | import { renderHook } from "../src";
4 |
5 | describe("error hook tests", () => {
6 | function useError(throwError) {
7 | if (throwError) {
8 | throw new Error("expected");
9 | }
10 | return true;
11 | }
12 |
13 | function useAsyncError(throwError) {
14 | const [value, setValue] = useState();
15 | useEffect(() => {
16 | const timeout = setTimeout(() => setValue(throwError), 100);
17 | return () => clearTimeout(timeout);
18 | }, [throwError]);
19 | return useError(value);
20 | }
21 |
22 | function useEffectError(throwError) {
23 | useEffect(() => {
24 | useError(throwError);
25 | }, []);
26 | return true;
27 | }
28 |
29 | describe("synchronous", () => {
30 | test("should raise error", () => {
31 | const { result } = renderHook(() => useError(true));
32 |
33 | expect(() => {
34 | expect(result.current).not.toBe(undefined);
35 | }).toThrow(Error("expected"));
36 | });
37 |
38 | test("should capture error", () => {
39 | const { result } = renderHook(() => useError(true));
40 |
41 | expect(result.error).toEqual(Error("expected"));
42 | });
43 |
44 | test("should not capture error", () => {
45 | const { result } = renderHook(() => useError(false));
46 |
47 | expect(result.current).not.toBe(undefined);
48 | expect(result.error).toBe(undefined);
49 | });
50 |
51 | test.skip("should reset error", () => {
52 | const { result, rerender } = renderHook(
53 | (throwError) => useError(throwError),
54 | {
55 | initialProps: true,
56 | }
57 | );
58 |
59 | expect(result.error).not.toBe(undefined);
60 |
61 | rerender(false);
62 |
63 | expect(result.current).not.toBe(undefined);
64 | expect(result.error).toBe(undefined);
65 | });
66 | });
67 |
68 | describe("asynchronous", () => {
69 | test("should raise async error", async () => {
70 | const { result, waitForNextUpdate } = renderHook(() =>
71 | useAsyncError(true)
72 | );
73 | await waitForNextUpdate();
74 |
75 | expect(() => {
76 | expect(result.current).not.toBe(undefined);
77 | }).toThrow(Error("expected"));
78 | });
79 |
80 | test("should capture async error", async () => {
81 | const { result, waitForNextUpdate } = renderHook(() =>
82 | useAsyncError(true)
83 | );
84 |
85 | await waitForNextUpdate();
86 |
87 | expect(result.error).toEqual(Error("expected"));
88 | });
89 |
90 | test("should not capture async error", async () => {
91 | const { result, waitForNextUpdate } = renderHook(() =>
92 | useAsyncError(false)
93 | );
94 |
95 | await waitForNextUpdate();
96 |
97 | expect(result.current).not.toBe(undefined);
98 | expect(result.error).toBe(undefined);
99 | });
100 |
101 | test.skip("should reset async error", async () => {
102 | const { result, waitForNextUpdate, rerender } = renderHook(
103 | (throwError) => useAsyncError(throwError),
104 | {
105 | initialProps: true,
106 | }
107 | );
108 |
109 | await waitForNextUpdate();
110 |
111 | expect(result.error).not.toBe(undefined);
112 |
113 | rerender(false);
114 |
115 | await waitForNextUpdate();
116 |
117 | expect(result.current).not.toBe(undefined);
118 | expect(result.error).toBe(undefined);
119 | });
120 | });
121 |
122 | /*
123 | These tests capture error cases that are not currently being caught successfully.
124 | Refer to https://github.com/testing-library/react-hooks-testing-library/issues/308
125 | for more details.
126 | */
127 | describe.skip("effect", () => {
128 | test("should raise effect error", () => {
129 | const { result } = renderHook(() => useEffectError(true));
130 |
131 | expect(() => {
132 | expect(result.current).not.toBe(undefined);
133 | }).toThrow(Error("expected"));
134 | });
135 |
136 | test("should capture effect error", () => {
137 | const { result } = renderHook(() => useEffectError(true));
138 | expect(result.error).toEqual(Error("expected"));
139 | });
140 |
141 | test("should not capture effect error", () => {
142 | const { result } = renderHook(() => useEffectError(false));
143 |
144 | expect(result.current).not.toBe(undefined);
145 | expect(result.error).toBe(undefined);
146 | });
147 |
148 | test("should reset effect error", () => {
149 | const { result, waitForNextUpdate, rerender } = renderHook(
150 | (throwError) => useEffectError(throwError),
151 | {
152 | initialProps: true,
153 | }
154 | );
155 |
156 | expect(result.error).not.toBe(undefined);
157 |
158 | rerender(false);
159 |
160 | expect(result.current).not.toBe(undefined);
161 | expect(result.error).toBe(undefined);
162 | });
163 | });
164 | });
165 |
--------------------------------------------------------------------------------
/test/suspenseHook.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from "../src";
2 |
3 | describe("suspense hook tests", () => {
4 | const cache: { value?: any } = {};
5 | const fetchName = (isSuccessful: boolean) => {
6 | if (!cache.value) {
7 | cache.value = new Promise((resolve, reject) => {
8 | setTimeout(() => {
9 | if (isSuccessful) {
10 | resolve("Bob");
11 | } else {
12 | reject(new Error("Failed to fetch name"));
13 | }
14 | }, 50);
15 | })
16 | .then((value) => (cache.value = value))
17 | .catch((e) => (cache.value = e));
18 | }
19 | return cache.value;
20 | };
21 |
22 | const useFetchName = (isSuccessful = true) => {
23 | const name = fetchName(isSuccessful);
24 | if (typeof name.then === "function" || name instanceof Error) {
25 | throw name;
26 | }
27 | return name;
28 | };
29 |
30 | beforeEach(() => {
31 | delete cache.value;
32 | });
33 |
34 | test("should allow rendering to be suspended", async () => {
35 | const { result, waitForNextUpdate } = renderHook(() => useFetchName(true));
36 |
37 | await waitForNextUpdate();
38 |
39 | expect(result.current).toBe("Bob");
40 | });
41 |
42 | test("should set error if suspense promise rejects", async () => {
43 | const { result, waitForNextUpdate } = renderHook(() => useFetchName(false));
44 |
45 | await waitForNextUpdate();
46 |
47 | expect(result.error).toEqual(new Error("Failed to fetch name"));
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/test/useContext.test.tsx:
--------------------------------------------------------------------------------
1 | import { h, createContext } from "preact";
2 | import { useContext } from "preact/hooks";
3 | import { renderHook } from "../src";
4 |
5 | describe("useContext tests", () => {
6 | test("should get default value from context", () => {
7 | const TestContext = createContext("foo");
8 |
9 | const { result } = renderHook(() => useContext(TestContext));
10 |
11 | const value = result.current;
12 |
13 | expect(value).toBe("foo");
14 | });
15 |
16 | test("should get value from context provider", () => {
17 | const TestContext = createContext("foo");
18 |
19 | const wrapper = ({ children }: any) => (
20 | {children}
21 | );
22 |
23 | const { result } = renderHook(() => useContext(TestContext), { wrapper });
24 |
25 | expect(result.current).toBe("bar");
26 | });
27 |
28 | test("should update mutated value in context", () => {
29 | const TestContext = createContext("foo");
30 |
31 | const value = { current: "bar" };
32 |
33 | const wrapper = ({ children }: any) => (
34 |
35 | {children}
36 |
37 | );
38 |
39 | const { result, rerender } = renderHook(() => useContext(TestContext), {
40 | wrapper,
41 | });
42 |
43 | value.current = "baz";
44 |
45 | rerender();
46 |
47 | expect(result.current).toBe("baz");
48 | });
49 |
50 | test("should update value in context when props are updated", () => {
51 | const TestContext = createContext("foo");
52 |
53 | const wrapper = ({ current, children }: any) => (
54 | {children}
55 | );
56 |
57 | const { result, rerender } = renderHook(() => useContext(TestContext), {
58 | wrapper,
59 | initialProps: {
60 | current: "bar",
61 | },
62 | });
63 |
64 | rerender({ current: "baz" });
65 |
66 | expect(result.current).toBe("baz");
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/test/useEffect.test.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useLayoutEffect } from "preact/hooks";
2 | import { renderHook } from "../src";
3 |
4 | describe("useEffect tests", () => {
5 | test("should handle useEffect hook", () => {
6 | const sideEffect: Record = { [1]: false, [2]: false };
7 |
8 | const { rerender, unmount } = renderHook(
9 | (props) => {
10 | const { id } = props || { id: 1 };
11 | useEffect(() => {
12 | sideEffect[id] = true;
13 | return () => {
14 | sideEffect[id] = false;
15 | };
16 | }, [id]);
17 | },
18 | { initialProps: { id: 1 } }
19 | );
20 |
21 | expect(sideEffect[1]).toBe(true);
22 | expect(sideEffect[2]).toBe(false);
23 |
24 | rerender({ id: 2 });
25 |
26 | expect(sideEffect[1]).toBe(false);
27 | expect(sideEffect[2]).toBe(true);
28 |
29 | unmount();
30 |
31 | expect(sideEffect[1]).toBe(false);
32 | expect(sideEffect[2]).toBe(false);
33 | });
34 |
35 | test("should handle useLayoutEffect hook", () => {
36 | const sideEffect: Record = { [1]: false, [2]: false };
37 |
38 | const { rerender, unmount } = renderHook(
39 | (props) => {
40 | const { id } = props || { id: 1 };
41 | useLayoutEffect(() => {
42 | sideEffect[id] = true;
43 | return () => {
44 | sideEffect[id] = false;
45 | };
46 | }, [id]);
47 | },
48 | { initialProps: { id: 1 } }
49 | );
50 |
51 | expect(sideEffect[1]).toBe(true);
52 | expect(sideEffect[2]).toBe(false);
53 |
54 | rerender({ id: 2 });
55 |
56 | expect(sideEffect[1]).toBe(false);
57 | expect(sideEffect[2]).toBe(true);
58 |
59 | unmount();
60 |
61 | expect(sideEffect[1]).toBe(false);
62 | expect(sideEffect[2]).toBe(false);
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/test/useMemo.test.ts:
--------------------------------------------------------------------------------
1 | import { useMemo, useCallback } from "preact/hooks";
2 | import { renderHook } from "../src";
3 |
4 | describe("useCallback tests", () => {
5 | test("should handle useMemo hook", () => {
6 | const { result, rerender } = renderHook(
7 | (props) => {
8 | const { value } = props || { value: 0 };
9 | return useMemo(() => ({ value }), [value]);
10 | },
11 | {
12 | initialProps: {
13 | value: 1,
14 | },
15 | }
16 | );
17 |
18 | const value1 = result.current;
19 |
20 | expect(value1).toEqual({ value: 1 });
21 |
22 | rerender();
23 |
24 | const value2 = result.current;
25 |
26 | expect(value2).toEqual({ value: 1 });
27 |
28 | expect(value2).toBe(value1);
29 |
30 | rerender({ value: 2 });
31 |
32 | const value3 = result.current;
33 |
34 | expect(value3).toEqual({ value: 2 });
35 |
36 | expect(value3).not.toBe(value1);
37 | });
38 |
39 | test("should handle useCallback hook", () => {
40 | const { result, rerender } = renderHook(
41 | (props) => {
42 | const { value } = props || { value: 0 };
43 | const callback = () => ({ value });
44 | return useCallback(callback, [value]);
45 | },
46 | { initialProps: { value: 1 } }
47 | );
48 |
49 | const callback1 = result.current;
50 |
51 | const callbackValue1 = callback1?.();
52 |
53 | expect(callbackValue1).toEqual({ value: 1 });
54 |
55 | const callback2 = result.current;
56 |
57 | const callbackValue2 = callback2?.();
58 |
59 | expect(callbackValue2).toEqual({ value: 1 });
60 |
61 | expect(callback2).toBe(callback1);
62 |
63 | rerender({ value: 2 });
64 |
65 | const callback3 = result.current;
66 |
67 | const callbackValue3 = callback3?.();
68 |
69 | expect(callbackValue3).toEqual({ value: 2 });
70 |
71 | expect(callback3).not.toBe(callback1);
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/test/useReducer.test.ts:
--------------------------------------------------------------------------------
1 | import { useReducer } from "preact/hooks";
2 | import { renderHook, act } from "../src";
3 |
4 | type Action = {
5 | type: "inc";
6 | };
7 |
8 | describe("useReducer tests", () => {
9 | test("should handle useReducer hook", () => {
10 | const reducer = (state: number, action: Action) =>
11 | action.type === "inc" ? state + 1 : state;
12 | const { result } = renderHook(() => useReducer(reducer, 0));
13 |
14 | const [initialState, dispatch] = result.current;
15 |
16 | expect(initialState).toBe(0);
17 |
18 | // TS thinks that dispatch could be a number
19 | // @ts-ignore
20 | act(() => dispatch({ type: "inc" }));
21 |
22 | const [state] = result.current;
23 |
24 | expect(state).toBe(1);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/test/useRef.test.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from "preact";
2 | import { useRef, useImperativeHandle } from "preact/hooks";
3 | import { renderHook } from "../src";
4 |
5 | describe("useHook tests", () => {
6 | test("should handle useRef hook", () => {
7 | const { result } = renderHook>(() =>
8 | useRef()
9 | );
10 |
11 | const refContainer = result.current;
12 |
13 | expect(Object.keys(refContainer as object)).toEqual(["current"]);
14 | expect(refContainer!.current).toBeUndefined();
15 | });
16 |
17 | test("should handle useImperativeHandle hook", () => {
18 | const { result } = renderHook(() => {
19 | const ref = useRef<{
20 | fakeImperativeMethod: () => boolean;
21 | }>();
22 | useImperativeHandle(ref, () => ({
23 | fakeImperativeMethod: () => true,
24 | }));
25 | return ref;
26 | });
27 |
28 | const refContainer = result.current;
29 |
30 | expect(refContainer?.current?.fakeImperativeMethod()).toBe(true);
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/test/useState.test.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "preact/hooks";
2 | import { renderHook, act } from "../src";
3 |
4 | describe("useState tests", () => {
5 | test("should use setState value", () => {
6 | const { result } = renderHook(() => useState("foo"));
7 |
8 | const [value] = result.current;
9 |
10 | expect(value).toBe("foo");
11 | });
12 |
13 | test("should update setState value using setter", () => {
14 | const { result } = renderHook(() => useState("foo"));
15 |
16 | const [_, setValue] = result.current;
17 |
18 | // TS thinks that dispatch could be a number
19 | // @ts-ignore
20 | act(() => setValue("bar"));
21 |
22 | const [value] = result.current;
23 |
24 | expect(value).toBe("bar");
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "jsx": "react",
11 | "jsxFactory": "h",
12 | "declaration": true,
13 | "outDir": "./lib",
14 | "strict": true,
15 | "esModuleInterop": true,
16 | "skipLibCheck": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "downlevelIteration": true
19 | },
20 | "include": [
21 | "src/*.ts",
22 | "src/*.tsx",
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/types.ts:
--------------------------------------------------------------------------------
1 | import { ComponentType } from "preact";
2 |
3 | export type Wrapper = (Component: ComponentType) => ComponentType;
4 |
5 | export type Callback = (props?: P) => R;
6 |
--------------------------------------------------------------------------------