├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── pure.js ├── src ├── TestComponent.tsx ├── _types.ts ├── asyncUtils.ts ├── cleanup.ts ├── flush-microtasks.ts ├── index.ts ├── pure.ts ├── renderHook.tsx └── resultContainer.ts ├── test ├── asyncHook.test.ts ├── autoCleanup.disabled.test.ts ├── autoCleanup.noAfterEach.test.ts ├── autoCleanup.test.ts ├── cleanup.test.ts ├── customHook.test.ts ├── errorHook.test.ts ├── suspenseHook.test.ts ├── useContext.test.tsx ├── useEffect.test.ts ├── useMemo.test.ts ├── useReducer.test.ts ├── useRef.test.ts └── useState.test.ts ├── tsconfig.json └── types.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .pnp.* 117 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright 2020 trivago, N.V. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # preact-hooks-testing-library 2 | 3 | [![Discord](https://img.shields.io/discord/723559267868737556.svg?color=7389D8&labelColor=6A7EC2&logo=discord&logoColor=ffffff&style=flat-square)](https://discord.gg/testing-library) 4 | 5 | preact port of the the [@testing-library/react-hooks](https://github.com/testing-library/react-hooks-testing-library) library. 6 | 7 | ## Why not `@testing-library/react-hooks`? 8 | 9 | Currently, due to the use of `react-test-renderer`, the react hooks testing library most likely will never be compatible with preact. 10 | 11 | ## Why not another library? 12 | 13 | At the time of writing, a library did not exist to test preact hooks. 14 | 15 | ## When to use this library 16 | 17 | 1. You're writing a library with one or more custom hooks that are not directly tied to a component 18 | 2. You have a complex hook that is difficult to test through component interactions 19 | 20 | ## When not to use this library 21 | 22 | 1. Your hook is defined alongside a component and is only used there 23 | 2. Your hook is easy to test by just testing the components using it 24 | 25 | ## Installation 26 | 27 | Install with your favorite package manager 28 | 29 | ``` 30 | yarn add -D @testing-library/preact-hooks 31 | OR 32 | npm install --save-dev @testing-library/preact-hooks 33 | ``` 34 | 35 | ## Example #1: Basic 36 | --- 37 | 38 | ### `useCounter.ts` 39 | 40 | ```typescript 41 | import { useState, useCallback } from 'preact/hooks'; 42 | 43 | const useCounter = () => { 44 | const [count, setCount] = useState(0); 45 | 46 | const increment = useCallback(() => setCount(c => c + 1)); 47 | 48 | return { 49 | count, 50 | increment 51 | } 52 | } 53 | 54 | export default useCounter; 55 | ``` 56 | 57 | ### `useCounter.test.ts` 58 | 59 | ```typescript 60 | import { renderHook, act } from '@testing-library/preact-hooks'; 61 | import useCounter from './useCounter'; 62 | 63 | test('should increment counter', () => { 64 | const { result } = renderHook(() => useCounter()); 65 | 66 | act(() => { 67 | result.current.increment(); 68 | }); 69 | 70 | expect(result.current.count).toBe(1); 71 | }); 72 | 73 | ``` 74 | 75 | ## Example #2: Wrapped Components 76 | 77 | Sometimes, hooks may need access to values or functionality outside of itself that are provided by a context provider or some other HOC. 78 | 79 | ```typescript jsx 80 | import { createContext } from 'preact' 81 | import { useState, useCallback, useContext } from 'preact/hooks' 82 | 83 | const CounterStepContext = createContext(1) 84 | export const CounterStepProvider = ({ step, children }) => ( 85 | {children} 86 | ) 87 | export function useCounter(initialValue = 0) { 88 | const [count, setCount] = useState(initialValue) 89 | const step = useContext(CounterStepContext) 90 | const increment = useCallback(() => setCount((x) => x + step), [step]) 91 | const reset = useCallback(() => setCount(initialValue), [initialValue]) 92 | return { count, increment, reset } 93 | } 94 | 95 | ``` 96 | 97 | In our test, we simply use CoounterStepProvider as the wrapper when rendering the hook: 98 | 99 | ```typescript 100 | import { renderHook, act } from '@testing-library/preact-hooks' 101 | import { CounterStepProvider, useCounter } from './counter' 102 | 103 | test('should use custom step when incrementing', () => { 104 | const wrapper = ({ children }) => {children} 105 | const { result } = renderHook(() => useCounter(), { wrapper }) 106 | act(() => { 107 | result.current.increment() 108 | }) 109 | expect(result.current.count).toBe(2) 110 | }) 111 | ``` 112 | 113 | ### TODO 114 | 115 | - [ ] remove `@ts-nocheck` flag from tests 116 | - [ ] fix disabled auto clean up tests 117 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@testing-library/preact-hooks", 3 | "description": "Simple and complete React hooks testing utilities that encourage good testing practices.", 4 | "version": "1.1.0", 5 | "main": "lib/index.js", 6 | "license": "MIT", 7 | "author": "Carson McKinstry ", 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 | --------------------------------------------------------------------------------