diff --git a/custom/index.js b/custom/index.js new file mode 100644 index 00000000..11d50e45 --- /dev/null +++ b/custom/index.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/custom' +module.exports = require('../lib/custom') diff --git a/custom/pure.js b/custom/pure.js new file mode 100644 index 00000000..c98aee61 --- /dev/null +++ b/custom/pure.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/custom/pure' +module.exports = require('../lib/custom/pure') diff --git a/dom/index.js b/dom/index.js new file mode 100644 index 00000000..5b8693bf --- /dev/null +++ b/dom/index.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/dom' +module.exports = require('../lib/dom') diff --git a/dom/pure.js b/dom/pure.js new file mode 100644 index 00000000..c6e171cc --- /dev/null +++ b/dom/pure.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/dom/pure' +module.exports = require('../lib/dom/pure') diff --git a/native/index.js b/native/index.js new file mode 100644 index 00000000..bc2242a6 --- /dev/null +++ b/native/index.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/native' +module.exports = require('../lib/native') diff --git a/native/pure.js b/native/pure.js new file mode 100644 index 00000000..10dc143f --- /dev/null +++ b/native/pure.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/native/pure' +module.exports = require('../lib/native/pure') diff --git a/package.json b/package.json index 6fe490d9..5bf2527a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "files": [ "lib", "src", + "dom", + "native", + "server", "pure.js", "dont-cleanup-after-each.js" ], @@ -61,12 +64,25 @@ "prettier-eslint": "11.0.0", "prettier-eslint-cli": "5.0.0", "react": "16.13.1", + "react-dom": "^16.13.1", "react-test-renderer": "16.13.1" }, "peerDependencies": { "react": ">=16.9.0", + "react-dom": ">=16.9.0", "react-test-renderer": ">=16.9.0" }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + }, "jest": { "collectCoverage": true, "coverageDirectory": "./coverage/", diff --git a/server/index.js b/server/index.js new file mode 100644 index 00000000..21b80f69 --- /dev/null +++ b/server/index.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/server' +module.exports = require('../lib/server') diff --git a/server/pure.js b/server/pure.js new file mode 100644 index 00000000..a64c1790 --- /dev/null +++ b/server/pure.js @@ -0,0 +1,2 @@ +// makes it so people can import from '@testing-library/react-hooks/server/pure' +module.exports = require('../lib/server/pure') diff --git a/src/asyncUtils.js b/src/core/async-utils.js similarity index 96% rename from src/asyncUtils.js rename to src/core/async-utils.js index c3cf7ab9..ab673d76 100644 --- a/src/asyncUtils.js +++ b/src/core/async-utils.js @@ -1,12 +1,10 @@ -import { act } from 'react-test-renderer' - function createTimeoutError(utilName, { timeout }) { const timeoutError = new Error(`Timed out in ${utilName} after ${timeout}ms.`) timeoutError.timeout = true return timeoutError } -function asyncUtils(addResolver) { +function asyncUtils(act, addResolver) { let nextUpdatePromise = null const waitForNextUpdate = async (options = {}) => { diff --git a/src/cleanup.js b/src/core/cleanup.js similarity index 62% rename from src/cleanup.js rename to src/core/cleanup.js index c240b5e1..2ed4a75a 100644 --- a/src/cleanup.js +++ b/src/core/cleanup.js @@ -16,4 +16,13 @@ function removeCleanup(callback) { cleanupCallbacks = cleanupCallbacks.filter((cb) => cb !== callback) } +cleanup.autoRegister = function () { + // Automatically registers cleanup in supported testing frameworks + if (typeof afterEach === 'function' && !process.env.RHTL_SKIP_AUTO_CLEANUP) { + afterEach(async () => { + await cleanup() + }) + } +} + export { cleanup, addCleanup, removeCleanup } diff --git a/src/flush-microtasks.js b/src/core/flush-microtasks.js similarity index 100% rename from src/flush-microtasks.js rename to src/core/flush-microtasks.js diff --git a/src/core/index.js b/src/core/index.js new file mode 100644 index 00000000..114e5448 --- /dev/null +++ b/src/core/index.js @@ -0,0 +1,91 @@ +import asyncUtils from './async-utils' +import { cleanup, addCleanup, removeCleanup } from './cleanup' + +function TestHook({ callback, setValue, setError, ...props }) { + try { + setValue(callback(props)) + } catch (err) { + if (err.then) { + throw err + } else { + setError(err) + } + } + return null +} + +function resultContainer() { + let value = null + let error = null + const resolvers = [] + + const result = { + get current() { + if (error) { + throw error + } + return value + }, + get error() { + return error + } + } + + const updateResult = (val, err) => { + value = val + error = err + resolvers.splice(0, resolvers.length).forEach((resolve) => resolve()) + } + + return { + result, + addResolver: (resolver) => { + resolvers.push(resolver) + }, + setValue: (val) => updateResult(val), + setError: (err) => updateResult(undefined, err) + } +} + +function defaultWrapper({ children }) { + return children +} + +function createRenderHook(createRenderer) { + return function renderHook(callback, { initialProps, wrapper = defaultWrapper } = {}) { + const { result, setValue, setError, addResolver } = resultContainer() + const hookProps = { current: initialProps } + const props = { callback, setValue, setError } + const options = { initialProps, wrapper } + + const { render, rerender, unmount, act, ...rendererUtils } = createRenderer( + TestHook, + props, + options + ) + + render(hookProps.current) + + function rerenderHook(newProps = hookProps.current) { + hookProps.current = newProps + rerender(hookProps.current) + } + + function unmountHook() { + removeCleanup(unmountHook) + unmount() + } + + addCleanup(unmountHook) + + return { + result, + rerender: rerenderHook, + unmount: unmountHook, + ...asyncUtils(act, addResolver), + ...rendererUtils + } + } +} + +export { createRenderHook, cleanup } diff --git a/src/custom/index.js b/src/custom/index.js new file mode 100644 index 00000000..ca4f465a --- /dev/null +++ b/src/custom/index.js @@ -0,0 +1,5 @@ +import { createCustomRenderer, cleanup } from './pure' + +cleanup.autoRegister() + +export { createCustomRenderer, cleanup } diff --git a/src/custom/pure.js b/src/custom/pure.js new file mode 100644 index 00000000..d6010705 --- /dev/null +++ b/src/custom/pure.js @@ -0,0 +1,8 @@ +import { createRenderHook, cleanup } from '../core' + +function createCustomRenderer(createRenderer) { + const renderHook = createRenderHook(createRenderer) + return { renderHook } +} + +export { createCustomRenderer, cleanup } diff --git a/src/dom/index.js b/src/dom/index.js new file mode 100644 index 00000000..d18e042c --- /dev/null +++ b/src/dom/index.js @@ -0,0 +1,5 @@ +import { renderHook, act, cleanup } from './pure' + +cleanup.autoRegister() + +export { renderHook, act, cleanup } diff --git a/src/dom/pure.js b/src/dom/pure.js new file mode 100644 index 00000000..9d6689d4 --- /dev/null +++ b/src/dom/pure.js @@ -0,0 +1,44 @@ +import React, { Suspense } from 'react' +import ReactDOM from 'react-dom' +import { act } from 'react-dom/test-utils' +import { createRenderHook, cleanup } from '../core' + +function Fallback() { + return null +} + +function createRenderer(TestHook, testHookProps, { wrapper: Wrapper }) { + const container = document.createElement('div') + + const toRender = (props) => + console.log(props) || ( + + + + ) + + return { + render(props) { + document.body.appendChild(container) + act(() => { + ReactDOM.render(}>{toRender(props)}, container) + }) + }, + rerender(props) { + act(() => { + ReactDOM.render(}>{toRender(props)}, container) + }) + }, + unmount() { + act(() => { + ReactDOM.unmountComponentAtNode(container) + }) + document.body.removeChild(container) + }, + act + } +} + +const renderHook = createRenderHook(createRenderer) + +export { renderHook, act, cleanup } diff --git a/src/index.js b/src/index.js index c1abc074..d18e042c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,5 @@ -import { cleanup } from './pure' +import { renderHook, act, cleanup } from './pure' -// Automatically registers cleanup in supported testing frameworks -if (typeof afterEach === 'function' && !process.env.RHTL_SKIP_AUTO_CLEANUP) { - afterEach(async () => { - await cleanup() - }) -} +cleanup.autoRegister() -export * from './pure' +export { renderHook, act, cleanup } diff --git a/src/native/index.js b/src/native/index.js new file mode 100644 index 00000000..d18e042c --- /dev/null +++ b/src/native/index.js @@ -0,0 +1,5 @@ +import { renderHook, act, cleanup } from './pure' + +cleanup.autoRegister() + +export { renderHook, act, cleanup } diff --git a/src/native/pure.js b/src/native/pure.js new file mode 100644 index 00000000..c57cad5d --- /dev/null +++ b/src/native/pure.js @@ -0,0 +1,39 @@ +import React, { Suspense } from 'react' +import { act, create } from 'react-test-renderer' +import { createRenderHook, cleanup } from '../core' + +function Fallback() { + return null +} +function createRenderer(TestHook, testHookProps, { wrapper: Wrapper }) { + let container + + const toRender = (props) => ( + + + + ) + + return { + render(props) { + act(() => { + container = create(}>{toRender(props)}) + }) + }, + rerender(props) { + act(() => { + container.update(}>{toRender(props)}) + }) + }, + unmount() { + act(() => { + container.unmount() + }) + }, + act + } +} + +const renderHook = createRenderHook(createRenderer) + +export { renderHook, act, cleanup } diff --git a/src/pure.js b/src/pure.js index 3b4a475d..34450820 100644 --- a/src/pure.js +++ b/src/pure.js @@ -1,102 +1,22 @@ -import React, { Suspense } from 'react' -import { act, create } from 'react-test-renderer' -import asyncUtils from './asyncUtils' -import { cleanup, addCleanup, removeCleanup } from './cleanup' - -function TestHook({ callback, hookProps, onError, children }) { +function hasDependency(name) { try { - children(callback(hookProps)) - } catch (err) { - if (err.then) { - throw err - } else { - onError(err) - } + require(name) + return true + } catch { + return false } - return null -} - -function Fallback() { - return null } -function resultContainer() { - let value = null - let error = null - const resolvers = [] - - const result = { - get current() { - if (error) { - throw error - } - return value - }, - get error() { - return error - } - } - - const updateResult = (val, err) => { - value = val - error = err - resolvers.splice(0, resolvers.length).forEach((resolve) => resolve()) - } - - return { - result, - addResolver: (resolver) => { - resolvers.push(resolver) - }, - setValue: (val) => updateResult(val), - setError: (err) => updateResult(undefined, err) - } -} +const autoDetectableRenderers = [ + { required: 'react-dom', renderer: './dom/pure' }, + { required: 'react-test-renderer', renderer: './native/pure' } +] -function renderHook(callback, { initialProps, wrapper } = {}) { - const { result, setValue, setError, addResolver } = resultContainer() - const hookProps = { current: initialProps } +const validRenderers = autoDetectableRenderers.filter(({ required }) => hasDependency(required)) - const wrapUiIfNeeded = (innerElement) => - wrapper ? React.createElement(wrapper, hookProps.current, innerElement) : innerElement - - const toRender = () => - wrapUiIfNeeded( - }> - - {setValue} - - - ) - - let testRenderer - act(() => { - testRenderer = create(toRender()) - }) - const { unmount, update } = testRenderer - - function rerenderHook(newProps = hookProps.current) { - hookProps.current = newProps - act(() => { - update(toRender()) - }) - } - - function unmountHook() { - act(() => { - removeCleanup(unmountHook) - unmount() - }) - } - - addCleanup(unmountHook) - - return { - result, - rerender: rerenderHook, - unmount: unmountHook, - ...asyncUtils(addResolver) - } +if (validRenderers.length === 0) { + const options = autoDetectableRenderers.map(({ option }) => ` - ${option}`).join('\n') + throw new Error(`Could not auto-detect a React renderer. Options are:\n${options}`) } -export { renderHook, cleanup, act } +module.exports = require(validRenderers[0].renderer) diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 00000000..d18e042c --- /dev/null +++ b/src/server/index.js @@ -0,0 +1,5 @@ +import { renderHook, act, cleanup } from './pure' + +cleanup.autoRegister() + +export { renderHook, act, cleanup } diff --git a/src/server/pure.js b/src/server/pure.js new file mode 100644 index 00000000..07fed150 --- /dev/null +++ b/src/server/pure.js @@ -0,0 +1,70 @@ +import React from 'react' +import ReactDOMServer from 'react-dom/server' +import ReactDOM from 'react-dom' +import { act as baseAct } from 'react-dom/test-utils' +import { createRenderHook, cleanup } from '../core' + +let act + +function createRenderer(TestHook, testHookProps, { wrapper: Wrapper }) { + const container = document.createElement('div') + + const toRender = (props) => ( + + + + ) + + let renderProps + let hydrated = false + + act = function act(...args) { + if (!hydrated) { + throw new Error('You must hydrate the component before you can act') + } + return baseAct(...args) + } + + return { + render(props) { + renderProps = props + baseAct(() => { + const serverOutput = ReactDOMServer.renderToString(toRender(props)) + container.innerHTML = serverOutput + }) + }, + hydrate() { + if (hydrated) { + throw new Error('The component can only be hydrated once') + } + if (!hydrated) { + document.body.appendChild(container) + baseAct(() => { + ReactDOM.hydrate(toRender(renderProps), container) + }) + hydrated = true + } + }, + rerender(props) { + if (!hydrated) { + throw new Error('You must hydrate the component before you can rerender') + } + baseAct(() => { + ReactDOM.render(toRender(props), container) + }) + }, + unmount() { + if (hydrated) { + baseAct(() => { + ReactDOM.unmountComponentAtNode(container) + document.body.removeChild(container) + }) + } + }, + act + } +} + +const renderHook = createRenderHook(createRenderer) + +export { renderHook, act, cleanup } diff --git a/test/asyncHook.test.js b/test/dom/asyncHook.test.js similarity index 99% rename from test/asyncHook.test.js rename to test/dom/asyncHook.test.js index 61a58cb6..20fe9153 100644 --- a/test/asyncHook.test.js +++ b/test/dom/asyncHook.test.js @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { renderHook } from 'src' +import { renderHook } from 'src/dom' describe('async hook tests', () => { const useSequence = (...values) => { diff --git a/test/autoCleanup.disabled.test.js b/test/dom/autoCleanup.disabled.test.js similarity index 92% rename from test/autoCleanup.disabled.test.js rename to test/dom/autoCleanup.disabled.test.js index 7da342d5..533a0f7f 100644 --- a/test/autoCleanup.disabled.test.js +++ b/test/dom/autoCleanup.disabled.test.js @@ -8,7 +8,7 @@ describe('skip auto cleanup (disabled) tests', () => { beforeAll(() => { process.env.RHTL_SKIP_AUTO_CLEANUP = 'true' - renderHook = require('src').renderHook + renderHook = require('src/dom').renderHook }) test('first', () => { diff --git a/test/autoCleanup.noAfterEach.test.js b/test/dom/autoCleanup.noAfterEach.test.js similarity index 92% rename from test/autoCleanup.noAfterEach.test.js rename to test/dom/autoCleanup.noAfterEach.test.js index c1f51eea..7dc1a237 100644 --- a/test/autoCleanup.noAfterEach.test.js +++ b/test/dom/autoCleanup.noAfterEach.test.js @@ -8,7 +8,7 @@ describe('skip auto cleanup (no afterEach) tests', () => { beforeAll(() => { afterEach = false - renderHook = require('src').renderHook + renderHook = require('src/dom').renderHook }) test('first', () => { diff --git a/test/autoCleanup.test.js b/test/dom/autoCleanup.test.js similarity index 93% rename from test/autoCleanup.test.js rename to test/dom/autoCleanup.test.js index d644fe79..fcb599a2 100644 --- a/test/autoCleanup.test.js +++ b/test/dom/autoCleanup.test.js @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { renderHook } from 'src' +import { renderHook } from 'src/dom' // This verifies that by importing RHTL in an // environment which supports afterEach (like Jest) diff --git a/test/cleanup.test.js b/test/dom/cleanup.test.js similarity index 94% rename from test/cleanup.test.js rename to test/dom/cleanup.test.js index a8c3bbba..9dbabb1b 100644 --- a/test/cleanup.test.js +++ b/test/dom/cleanup.test.js @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { renderHook, cleanup } from 'src' +import { renderHook, cleanup } from 'src/dom' describe('cleanup tests', () => { test('should flush effects on cleanup', async () => { diff --git a/test/customHook.test.js b/test/dom/customHook.test.js similarity index 94% rename from test/customHook.test.js rename to test/dom/customHook.test.js index 72dd1bac..df156f7c 100644 --- a/test/customHook.test.js +++ b/test/dom/customHook.test.js @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react' -import { renderHook, act } from 'src' +import { renderHook, act } from 'src/dom' describe('custom hook tests', () => { function useCounter() { diff --git a/test/errorHook.test.js b/test/dom/errorHook.test.js similarity index 87% rename from test/errorHook.test.js rename to test/dom/errorHook.test.js index dbfb21fa..e7ced74b 100644 --- a/test/errorHook.test.js +++ b/test/dom/errorHook.test.js @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { renderHook } from 'src' +import { renderHook } from 'src/dom' describe('error hook tests', () => { function useError(throwError) { @@ -48,13 +48,13 @@ describe('error hook tests', () => { }) test('should reset error', () => { - const { result, rerender } = renderHook((throwError) => useError(throwError), { - initialProps: true + const { result, rerender } = renderHook(({ throwError }) => useError(throwError), { + initialProps: { throwError: true } }) expect(result.error).not.toBe(undefined) - rerender(false) + rerender({ throwError: false }) expect(result.current).not.toBe(undefined) expect(result.error).toBe(undefined) @@ -91,17 +91,15 @@ describe('error hook tests', () => { test('should reset async error', async () => { const { result, waitForNextUpdate, rerender } = renderHook( - (throwError) => useAsyncError(throwError), - { - initialProps: true - } + ({ throwError }) => useAsyncError(throwError), + { initialProps: { throwError: true } } ) await waitForNextUpdate() expect(result.error).not.toBe(undefined) - rerender(false) + rerender({ throwError: false }) await waitForNextUpdate() @@ -137,16 +135,13 @@ describe('error hook tests', () => { }) test('should reset effect error', () => { - const { result, waitForNextUpdate, rerender } = renderHook( - (throwError) => useEffectError(throwError), - { - initialProps: true - } - ) + const { result, rerender } = renderHook(({ throwError }) => useEffectError(throwError), { + initialProps: { throwError: true } + }) expect(result.error).not.toBe(undefined) - rerender(false) + rerender({ throwError: false }) expect(result.current).not.toBe(undefined) expect(result.error).toBe(undefined) diff --git a/test/suspenseHook.test.js b/test/dom/suspenseHook.test.js similarity index 97% rename from test/suspenseHook.test.js rename to test/dom/suspenseHook.test.js index f7ece119..2cbdf4f0 100644 --- a/test/suspenseHook.test.js +++ b/test/dom/suspenseHook.test.js @@ -1,4 +1,4 @@ -import { renderHook } from 'src' +import { renderHook } from 'src/dom' describe('suspense hook tests', () => { const cache = {} diff --git a/test/useContext.test.js b/test/dom/useContext.test.js similarity index 57% rename from test/useContext.test.js rename to test/dom/useContext.test.js index 2c22caca..1ffd7aad 100644 --- a/test/useContext.test.js +++ b/test/dom/useContext.test.js @@ -1,5 +1,5 @@ import React, { createContext, useContext } from 'react' -import { renderHook } from 'src' +import { renderHook } from 'src/dom' describe('useContext tests', () => { test('should get default value from context', () => { @@ -24,39 +24,19 @@ describe('useContext tests', () => { expect(result.current).toBe('bar') }) - test('should update mutated value in context', () => { - const TestContext = createContext('foo') - - const value = { current: 'bar' } - - const wrapper = ({ children }) => ( - {children} - ) - - const { result, rerender } = renderHook(() => useContext(TestContext), { wrapper }) - - value.current = 'baz' - - rerender() - - expect(result.current).toBe('baz') - }) - test('should update value in context when props are updated', () => { const TestContext = createContext('foo') - const wrapper = ({ current, children }) => ( - {children} + const wrapper = ({ contextValue, children }) => ( + {children} ) const { result, rerender } = renderHook(() => useContext(TestContext), { wrapper, - initialProps: { - current: 'bar' - } + initialProps: { contextValue: 'bar' } }) - rerender({ current: 'baz' }) + rerender({ contextValue: 'baz' }) expect(result.current).toBe('baz') }) diff --git a/test/useEffect.test.js b/test/dom/useEffect.test.js similarity index 97% rename from test/useEffect.test.js rename to test/dom/useEffect.test.js index d5c96d2f..1b609ff3 100644 --- a/test/useEffect.test.js +++ b/test/dom/useEffect.test.js @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect } from 'react' -import { renderHook } from 'src' +import { renderHook } from 'src/dom' describe('useEffect tests', () => { test('should handle useEffect hook', () => { diff --git a/test/useMemo.test.js b/test/dom/useMemo.test.js similarity index 97% rename from test/useMemo.test.js rename to test/dom/useMemo.test.js index 3abeb855..080d8356 100644 --- a/test/useMemo.test.js +++ b/test/dom/useMemo.test.js @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react' -import { renderHook } from 'src' +import { renderHook } from 'src/dom' describe('useCallback tests', () => { test('should handle useMemo hook', () => { diff --git a/test/dom/useReducer.test.js b/test/dom/useReducer.test.js new file mode 100644 index 00000000..99929026 --- /dev/null +++ b/test/dom/useReducer.test.js @@ -0,0 +1,18 @@ +import { useReducer } from 'react' +import { renderHook, act } from 'src/dom' + +describe('useReducer tests', () => { + test('should handle useReducer hook', () => { + const reducer = (state, action) => (action.type === 'inc' ? state + 1 : state) + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(reducer, 0) + return { state, dispatch } + }) + + expect(result.current.state).toBe(0) + + act(() => result.current.dispatch({ type: 'inc' })) + + expect(result.current.state).toBe(1) + }) +}) diff --git a/test/useRef.test.js b/test/dom/useRef.test.js similarity index 94% rename from test/useRef.test.js rename to test/dom/useRef.test.js index 63dd241d..37878417 100644 --- a/test/useRef.test.js +++ b/test/dom/useRef.test.js @@ -1,5 +1,5 @@ import { useRef, useImperativeHandle } from 'react' -import { renderHook } from 'src' +import { renderHook } from 'src/dom' describe('useHook tests', () => { test('should handle useRef hook', () => { diff --git a/test/dom/useState.test.js b/test/dom/useState.test.js new file mode 100644 index 00000000..ae61d0bc --- /dev/null +++ b/test/dom/useState.test.js @@ -0,0 +1,24 @@ +import { useState } from 'react' +import { renderHook, act } from 'src/dom' + +describe('useState tests', () => { + test('should use state value', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('foo') + return { value, setValue } + }) + + expect(result.current.value).toBe('foo') + }) + + test('should update state value using setter', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('foo') + return { value, setValue } + }) + + act(() => result.current.setValue('bar')) + + expect(result.current.value).toBe('bar') + }) +}) diff --git a/test/native/asyncHook.test.js b/test/native/asyncHook.test.js new file mode 100644 index 00000000..1a9f298c --- /dev/null +++ b/test/native/asyncHook.test.js @@ -0,0 +1,217 @@ +import { useState, useRef, useEffect } from 'react' +import { renderHook } from 'src/native' + +describe('async hook tests', () => { + const useSequence = (...values) => { + const [first, ...otherValues] = values + const [value, setValue] = useState(first) + const index = useRef(0) + + useEffect(() => { + const interval = setInterval(() => { + setValue(otherValues[index.current++]) + if (index.current === otherValues.length) { + clearInterval(interval) + } + }, 50) + return () => { + clearInterval(interval) + } + }, [...values]) + + return value + } + + test('should wait for next update', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + await waitForNextUpdate() + + expect(result.current).toBe('second') + }) + + test('should wait for multiple updates', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await waitForNextUpdate() + + expect(result.current).toBe('second') + + await waitForNextUpdate() + + expect(result.current).toBe('third') + }) + + test('should resolve all when updating', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()]) + + expect(result.current).toBe('second') + }) + + test('should reject if timeout exceeded when waiting for next update', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( + Error('Timed out in waitForNextUpdate after 10ms.') + ) + }) + + test('should wait for expectation to pass', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('third') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should not hang if expectation is already passing', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('first') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should reject if callback throws error', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should reject if callback immediately throws error', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + throw new Error('Something Unexpected') + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should wait for truthy value', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await wait(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for expectation to pass', async () => { + const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + expect(result.current).toBe('third') + }, + { timeout: 75 } + ) + ).rejects.toThrow(Error('Timed out in wait after 75ms.')) + }) + + test('should wait for value to change', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + await waitForValueToChange(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for value to change', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => result.current === 'third', { + timeout: 75 + }) + ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.')) + }) + + test('should reject if selector throws error', async () => { + const { result, waitForValueToChange } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current + }) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should not reject if selector throws error and suppress errors option is enabled', async () => { + const { result, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + await waitForValueToChange( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { suppressErrors: true } + ) + + expect(result.current).toBe('third') + }) +}) diff --git a/test/native/autoCleanup.disabled.test.js b/test/native/autoCleanup.disabled.test.js new file mode 100644 index 00000000..a3498f3b --- /dev/null +++ b/test/native/autoCleanup.disabled.test.js @@ -0,0 +1,28 @@ +import { useEffect } from 'react' + +// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set +// then we DON'T auto-wire up the afterEach for folks +describe('skip auto cleanup (disabled) tests', () => { + let cleanupCalled = false + let renderHook + + beforeAll(() => { + process.env.RHTL_SKIP_AUTO_CLEANUP = 'true' + renderHook = require('src/native').renderHook + }) + + test('first', () => { + const hookWithCleanup = () => { + useEffect(() => { + return () => { + cleanupCalled = true + } + }) + } + renderHook(() => hookWithCleanup()) + }) + + test('second', () => { + expect(cleanupCalled).toBe(false) + }) +}) diff --git a/test/native/autoCleanup.noAfterEach.test.js b/test/native/autoCleanup.noAfterEach.test.js new file mode 100644 index 00000000..363e02c9 --- /dev/null +++ b/test/native/autoCleanup.noAfterEach.test.js @@ -0,0 +1,28 @@ +import { useEffect } from 'react' + +// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set +// then we DON'T auto-wire up the afterEach for folks +describe('skip auto cleanup (no afterEach) tests', () => { + let cleanupCalled = false + let renderHook + + beforeAll(() => { + afterEach = false + renderHook = require('src/native').renderHook + }) + + test('first', () => { + const hookWithCleanup = () => { + useEffect(() => { + return () => { + cleanupCalled = true + } + }) + } + renderHook(() => hookWithCleanup()) + }) + + test('second', () => { + expect(cleanupCalled).toBe(false) + }) +}) diff --git a/test/native/autoCleanup.test.js b/test/native/autoCleanup.test.js new file mode 100644 index 00000000..8511b966 --- /dev/null +++ b/test/native/autoCleanup.test.js @@ -0,0 +1,24 @@ +import { useEffect } from 'react' +import { renderHook } from 'src/native' + +// This verifies that by importing RHTL in an +// environment which supports afterEach (like Jest) +// we'll get automatic cleanup between tests. +describe('auto cleanup tests', () => { + let cleanupCalled = false + + test('first', () => { + const hookWithCleanup = () => { + useEffect(() => { + return () => { + cleanupCalled = true + } + }) + } + renderHook(() => hookWithCleanup()) + }) + + test('second', () => { + expect(cleanupCalled).toBe(true) + }) +}) diff --git a/test/native/cleanup.test.js b/test/native/cleanup.test.js new file mode 100644 index 00000000..42481f42 --- /dev/null +++ b/test/native/cleanup.test.js @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +import { renderHook, cleanup } from 'src/native' + +describe('cleanup tests', () => { + test('should flush effects on cleanup', async () => { + let cleanupCalled = false + + const hookWithCleanup = () => { + useEffect(() => { + return () => { + cleanupCalled = true + } + }) + } + + renderHook(() => hookWithCleanup()) + + await cleanup() + + expect(cleanupCalled).toBe(true) + }) + + test('should cleanup all rendered hooks', async () => { + let cleanupCalled = [] + const hookWithCleanup = (id) => { + useEffect(() => { + return () => { + cleanupCalled[id] = true + } + }) + } + + renderHook(() => hookWithCleanup(1)) + renderHook(() => hookWithCleanup(2)) + + await cleanup() + + expect(cleanupCalled[1]).toBe(true) + expect(cleanupCalled[2]).toBe(true) + }) +}) diff --git a/test/native/customHook.test.js b/test/native/customHook.test.js new file mode 100644 index 00000000..ddf2106f --- /dev/null +++ b/test/native/customHook.test.js @@ -0,0 +1,29 @@ +import { useState, useCallback } from 'react' +import { renderHook, act } from 'src/native' + +describe('custom hook tests', () => { + function useCounter() { + const [count, setCount] = useState(0) + + const increment = useCallback(() => setCount(count + 1), [count]) + const decrement = useCallback(() => setCount(count - 1), [count]) + + return { count, increment, decrement } + } + + test('should increment counter', () => { + const { result } = renderHook(() => useCounter()) + + act(() => result.current.increment()) + + expect(result.current.count).toBe(1) + }) + + test('should decrement counter', () => { + const { result } = renderHook(() => useCounter()) + + act(() => result.current.decrement()) + + expect(result.current.count).toBe(-1) + }) +}) diff --git a/test/native/errorHook.test.js b/test/native/errorHook.test.js new file mode 100644 index 00000000..a465a8e2 --- /dev/null +++ b/test/native/errorHook.test.js @@ -0,0 +1,150 @@ +import { useState, useEffect } from 'react' +import { renderHook } from 'src/native' + +describe('error hook tests', () => { + function useError(throwError) { + if (throwError) { + throw new Error('expected') + } + return true + } + + function useAsyncError(throwError) { + const [value, setValue] = useState() + useEffect(() => { + const timeout = setTimeout(() => setValue(throwError), 100) + return () => clearTimeout(timeout) + }, [throwError]) + return useError(value) + } + + function useEffectError(throwError) { + useEffect(() => { + useError(throwError) + }, []) + return true + } + + describe('synchronous', () => { + test('should raise error', () => { + const { result } = renderHook(() => useError(true)) + + expect(() => { + expect(result.current).not.toBe(undefined) + }).toThrow(Error('expected')) + }) + + test('should capture error', () => { + const { result } = renderHook(() => useError(true)) + + expect(result.error).toEqual(Error('expected')) + }) + + test('should not capture error', () => { + const { result } = renderHook(() => useError(false)) + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + + test('should reset error', () => { + const { result, rerender } = renderHook(({ throwError }) => useError(throwError), { + initialProps: { throwError: true } + }) + + expect(result.error).not.toBe(undefined) + + rerender({ throwError: false }) + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + }) + + describe('asynchronous', () => { + test('should raise async error', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsyncError(true)) + + await waitForNextUpdate() + + expect(() => { + expect(result.current).not.toBe(undefined) + }).toThrow(Error('expected')) + }) + + test('should capture async error', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsyncError(true)) + + await waitForNextUpdate() + + expect(result.error).toEqual(Error('expected')) + }) + + test('should not capture async error', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsyncError(false)) + + await waitForNextUpdate() + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + + test('should reset async error', async () => { + const { result, waitForNextUpdate, rerender } = renderHook( + ({ throwError }) => useAsyncError(throwError), + { initialProps: { throwError: true } } + ) + + await waitForNextUpdate() + + expect(result.error).not.toBe(undefined) + + rerender({ throwError: false }) + + await waitForNextUpdate() + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + }) + + /* + These tests capture error cases that are not currently being caught successfully. + Refer to https://github.com/testing-library/react-hooks-testing-library/issues/308 + for more details. + */ + describe.skip('effect', () => { + test('should raise effect error', () => { + const { result } = renderHook(() => useEffectError(true)) + + expect(() => { + expect(result.current).not.toBe(undefined) + }).toThrow(Error('expected')) + }) + + test('should capture effect error', () => { + const { result } = renderHook(() => useEffectError(true)) + expect(result.error).toEqual(Error('expected')) + }) + + test('should not capture effect error', () => { + const { result } = renderHook(() => useEffectError(false)) + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + + test('should reset effect error', () => { + const { result, rerender } = renderHook(({ throwError }) => useEffectError(throwError), { + initialProps: { throwError: true } + }) + + expect(result.error).not.toBe(undefined) + + rerender({ throwError: false }) + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + }) +}) diff --git a/test/native/suspenseHook.test.js b/test/native/suspenseHook.test.js new file mode 100644 index 00000000..25059b50 --- /dev/null +++ b/test/native/suspenseHook.test.js @@ -0,0 +1,49 @@ +import { renderHook } from 'src/native' + +describe('suspense hook tests', () => { + const cache = {} + const fetchName = (isSuccessful) => { + if (!cache.value) { + cache.value = new Promise((resolve, reject) => { + setTimeout(() => { + if (isSuccessful) { + resolve('Bob') + } else { + reject(new Error('Failed to fetch name')) + } + }, 50) + }) + .then((value) => (cache.value = value)) + .catch((e) => (cache.value = e)) + } + return cache.value + } + + const useFetchName = (isSuccessful = true) => { + const name = fetchName(isSuccessful) + if (typeof name.then === 'function' || name instanceof Error) { + throw name + } + return name + } + + beforeEach(() => { + delete cache.value + }) + + test('should allow rendering to be suspended', async () => { + const { result, waitForNextUpdate } = renderHook(() => useFetchName(true)) + + await waitForNextUpdate() + + expect(result.current).toBe('Bob') + }) + + test('should set error if suspense promise rejects', async () => { + const { result, waitForNextUpdate } = renderHook(() => useFetchName(false)) + + await waitForNextUpdate() + + expect(result.error).toEqual(new Error('Failed to fetch name')) + }) +}) diff --git a/test/native/useContext.test.js b/test/native/useContext.test.js new file mode 100644 index 00000000..3ecf1e7f --- /dev/null +++ b/test/native/useContext.test.js @@ -0,0 +1,43 @@ +import React, { createContext, useContext } from 'react' +import { renderHook } from 'src/native' + +describe('useContext tests', () => { + test('should get default value from context', () => { + const TestContext = createContext('foo') + + const { result } = renderHook(() => useContext(TestContext)) + + const value = result.current + + expect(value).toBe('foo') + }) + + test('should get value from context provider', () => { + const TestContext = createContext('foo') + + const wrapper = ({ children }) => ( + {children} + ) + + const { result } = renderHook(() => useContext(TestContext), { wrapper }) + + expect(result.current).toBe('bar') + }) + + test('should update value in context when props are updated', () => { + const TestContext = createContext('foo') + + const wrapper = ({ contextValue, children }) => ( + {children} + ) + + const { result, rerender } = renderHook(() => useContext(TestContext), { + wrapper, + initialProps: { contextValue: 'bar' } + }) + + rerender({ contextValue: 'baz' }) + + expect(result.current).toBe('baz') + }) +}) diff --git a/test/native/useEffect.test.js b/test/native/useEffect.test.js new file mode 100644 index 00000000..5714b146 --- /dev/null +++ b/test/native/useEffect.test.js @@ -0,0 +1,62 @@ +import { useEffect, useLayoutEffect } from 'react' +import { renderHook } from 'src/native' + +describe('useEffect tests', () => { + test('should handle useEffect hook', () => { + const sideEffect = { [1]: false, [2]: false } + + const { rerender, unmount } = renderHook( + ({ id }) => { + useEffect(() => { + sideEffect[id] = true + return () => { + sideEffect[id] = false + } + }, [id]) + }, + { initialProps: { id: 1 } } + ) + + expect(sideEffect[1]).toBe(true) + expect(sideEffect[2]).toBe(false) + + rerender({ id: 2 }) + + expect(sideEffect[1]).toBe(false) + expect(sideEffect[2]).toBe(true) + + unmount() + + expect(sideEffect[1]).toBe(false) + expect(sideEffect[2]).toBe(false) + }) + + test('should handle useLayoutEffect hook', () => { + const sideEffect = { [1]: false, [2]: false } + + const { rerender, unmount } = renderHook( + ({ id }) => { + useLayoutEffect(() => { + sideEffect[id] = true + return () => { + sideEffect[id] = false + } + }, [id]) + }, + { initialProps: { id: 1 } } + ) + + expect(sideEffect[1]).toBe(true) + expect(sideEffect[2]).toBe(false) + + rerender({ id: 2 }) + + expect(sideEffect[1]).toBe(false) + expect(sideEffect[2]).toBe(true) + + unmount() + + expect(sideEffect[1]).toBe(false) + expect(sideEffect[2]).toBe(false) + }) +}) diff --git a/test/native/useMemo.test.js b/test/native/useMemo.test.js new file mode 100644 index 00000000..ce043ea5 --- /dev/null +++ b/test/native/useMemo.test.js @@ -0,0 +1,64 @@ +import { useMemo, useCallback } from 'react' +import { renderHook } from 'src/native' + +describe('useCallback tests', () => { + test('should handle useMemo hook', () => { + const { result, rerender } = renderHook(({ value }) => useMemo(() => ({ value }), [value]), { + initialProps: { value: 1 } + }) + + const value1 = result.current + + expect(value1).toEqual({ value: 1 }) + + rerender() + + const value2 = result.current + + expect(value2).toEqual({ value: 1 }) + + expect(value2).toBe(value1) + + rerender({ value: 2 }) + + const value3 = result.current + + expect(value3).toEqual({ value: 2 }) + + expect(value3).not.toBe(value1) + }) + + test('should handle useCallback hook', () => { + const { result, rerender } = renderHook( + ({ value }) => { + const callback = () => ({ value }) + return useCallback(callback, [value]) + }, + { initialProps: { value: 1 } } + ) + + const callback1 = result.current + + const calbackValue1 = callback1() + + expect(calbackValue1).toEqual({ value: 1 }) + + const callback2 = result.current + + const calbackValue2 = callback2() + + expect(calbackValue2).toEqual({ value: 1 }) + + expect(callback2).toBe(callback1) + + rerender({ value: 2 }) + + const callback3 = result.current + + const calbackValue3 = callback3() + + expect(calbackValue3).toEqual({ value: 2 }) + + expect(callback3).not.toBe(callback1) + }) +}) diff --git a/test/native/useReducer.test.js b/test/native/useReducer.test.js new file mode 100644 index 00000000..305ae656 --- /dev/null +++ b/test/native/useReducer.test.js @@ -0,0 +1,18 @@ +import { useReducer } from 'react' +import { renderHook, act } from 'src/native' + +describe('useReducer tests', () => { + test('should handle useReducer hook', () => { + const reducer = (state, action) => (action.type === 'inc' ? state + 1 : state) + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(reducer, 0) + return { state, dispatch } + }) + + expect(result.current.state).toBe(0) + + act(() => result.current.dispatch({ type: 'inc' })) + + expect(result.current.state).toBe(1) + }) +}) diff --git a/test/native/useRef.test.js b/test/native/useRef.test.js new file mode 100644 index 00000000..9955dfda --- /dev/null +++ b/test/native/useRef.test.js @@ -0,0 +1,27 @@ +import { useRef, useImperativeHandle } from 'react' +import { renderHook } from 'src/native' + +describe('useHook tests', () => { + test('should handle useRef hook', () => { + const { result } = renderHook(() => useRef()) + + const refContainer = result.current + + expect(Object.keys(refContainer)).toEqual(['current']) + expect(refContainer.current).toBeUndefined() + }) + + test('should handle useImperativeHandle hook', () => { + const { result } = renderHook(() => { + const ref = useRef() + useImperativeHandle(ref, () => ({ + fakeImperativeMethod: () => true + })) + return ref + }) + + const refContainer = result.current + + expect(refContainer.current.fakeImperativeMethod()).toBe(true) + }) +}) diff --git a/test/native/useState.test.js b/test/native/useState.test.js new file mode 100644 index 00000000..b59e64cd --- /dev/null +++ b/test/native/useState.test.js @@ -0,0 +1,24 @@ +import { useState } from 'react' +import { renderHook, act } from 'src/native' + +describe('useState tests', () => { + test('should use state value', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('foo') + return { value, setValue } + }) + + expect(result.current.value).toBe('foo') + }) + + test('should update state value using setter', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('foo') + return { value, setValue } + }) + + act(() => result.current.setValue('bar')) + + expect(result.current.value).toBe('bar') + }) +}) diff --git a/test/server/asyncHook.test.js b/test/server/asyncHook.test.js new file mode 100644 index 00000000..a8d4a7e9 --- /dev/null +++ b/test/server/asyncHook.test.js @@ -0,0 +1,277 @@ +import { useState, useRef, useEffect } from 'react' +import { renderHook } from 'src/server' + +describe('async hook tests', () => { + const useSequence = (...values) => { + const [first, ...otherValues] = values + const [value, setValue] = useState(first) + const index = useRef(0) + + useEffect(() => { + const interval = setInterval(() => { + setValue(otherValues[index.current++]) + if (index.current === otherValues.length) { + clearInterval(interval) + } + }, 50) + return () => { + clearInterval(interval) + } + }, [...values]) + + return value + } + + test('should wait for next update', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await waitForNextUpdate() + + expect(result.current).toBe('second') + }) + + test('should wait for multiple updates', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await waitForNextUpdate() + + expect(result.current).toBe('second') + + await waitForNextUpdate() + + expect(result.current).toBe('third') + }) + + test('should resolve all when updating', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await Promise.all([waitForNextUpdate(), waitForNextUpdate(), waitForNextUpdate()]) + + expect(result.current).toBe('second') + }) + + test('should reject if timeout exceeded when waiting for next update', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await expect(waitForNextUpdate({ timeout: 10 })).rejects.toThrow( + Error('Timed out in waitForNextUpdate after 10ms.') + ) + }) + + test('should wait for expectation to pass', async () => { + const { result, hydrate, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('third') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should not hang if expectation is already passing', async () => { + const { result, hydrate, wait } = renderHook(() => useSequence('first', 'second')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + let complete = false + await wait(() => { + expect(result.current).toBe('first') + complete = true + }) + expect(complete).toBe(true) + }) + + test('should reject if callback throws error', async () => { + const { result, hydrate, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should reject if callback immediately throws error', async () => { + const { result, hydrate, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + throw new Error('Something Unexpected') + }, + { + suppressErrors: false + } + ) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should wait for truthy value', async () => { + const { result, hydrate, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await wait(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for expectation to pass', async () => { + const { result, hydrate, wait } = renderHook(() => useSequence('first', 'second', 'third')) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await expect( + wait( + () => { + expect(result.current).toBe('third') + }, + { timeout: 75 } + ) + ).rejects.toThrow(Error('Timed out in wait after 75ms.')) + }) + + test('should wait for value to change', async () => { + const { result, hydrate, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await waitForValueToChange(() => result.current === 'third') + + expect(result.current).toBe('third') + }) + + test('should reject if timeout exceeded when waiting for value to change', async () => { + const { result, hydrate, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => result.current === 'third', { + timeout: 75 + }) + ).rejects.toThrow(Error('Timed out in waitForValueToChange after 75ms.')) + }) + + test('should reject if selector throws error', async () => { + const { result, hydrate, waitForValueToChange } = renderHook(() => + useSequence('first', 'second') + ) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await expect( + waitForValueToChange(() => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current + }) + ).rejects.toThrow(Error('Something Unexpected')) + }) + + test('should not reject if selector throws error and suppress errors option is enabled', async () => { + const { result, hydrate, waitForValueToChange } = renderHook(() => + useSequence('first', 'second', 'third') + ) + + expect(result.current).toBe('first') + + hydrate() + + expect(result.current).toBe('first') + + await waitForValueToChange( + () => { + if (result.current === 'second') { + throw new Error('Something Unexpected') + } + return result.current === 'third' + }, + { suppressErrors: true } + ) + + expect(result.current).toBe('third') + }) +}) diff --git a/test/server/autoCleanup.disabled.test.js b/test/server/autoCleanup.disabled.test.js new file mode 100644 index 00000000..d94e643c --- /dev/null +++ b/test/server/autoCleanup.disabled.test.js @@ -0,0 +1,28 @@ +import { useEffect } from 'react' + +// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set +// then we DON'T auto-wire up the afterEach for folks +describe('skip auto cleanup (disabled) tests', () => { + let cleanupCalled = false + let renderHook + + beforeAll(() => { + process.env.RHTL_SKIP_AUTO_CLEANUP = 'true' + renderHook = require('src/server').renderHook + }) + + test('first', () => { + const hookWithCleanup = () => { + useEffect(() => { + return () => { + cleanupCalled = true + } + }) + } + renderHook(() => hookWithCleanup()) + }) + + test('second', () => { + expect(cleanupCalled).toBe(false) + }) +}) diff --git a/test/server/autoCleanup.noAfterEach.test.js b/test/server/autoCleanup.noAfterEach.test.js new file mode 100644 index 00000000..d4f30a80 --- /dev/null +++ b/test/server/autoCleanup.noAfterEach.test.js @@ -0,0 +1,28 @@ +import { useEffect } from 'react' + +// This verifies that if RHTL_SKIP_AUTO_CLEANUP is set +// then we DON'T auto-wire up the afterEach for folks +describe('skip auto cleanup (no afterEach) tests', () => { + let cleanupCalled = false + let renderHook + + beforeAll(() => { + afterEach = false + renderHook = require('src/server').renderHook + }) + + test('first', () => { + const hookWithCleanup = () => { + useEffect(() => { + return () => { + cleanupCalled = true + } + }) + } + renderHook(() => hookWithCleanup()) + }) + + test('second', () => { + expect(cleanupCalled).toBe(false) + }) +}) diff --git a/test/server/autoCleanup.test.js b/test/server/autoCleanup.test.js new file mode 100644 index 00000000..67428422 --- /dev/null +++ b/test/server/autoCleanup.test.js @@ -0,0 +1,32 @@ +import { useEffect } from 'react' +import { renderHook } from 'src/server' + +// This verifies that by importing RHTL in an +// environment which supports afterEach (like Jest) +// we'll get automatic cleanup between tests. +describe('auto cleanup tests', () => { + const cleanups = { + ssr: false, + hydrated: false + } + + test('first', () => { + const hookWithCleanup = (name) => { + useEffect(() => { + return () => { + cleanups[name] = true + } + }) + } + + renderHook(() => hookWithCleanup('ssr')) + + const { hydrate } = renderHook(() => hookWithCleanup('hydrated')) + hydrate() + }) + + test('second', () => { + expect(cleanups.ssr).toBe(false) + expect(cleanups.hydrated).toBe(true) + }) +}) diff --git a/test/server/cleanup.test.js b/test/server/cleanup.test.js new file mode 100644 index 00000000..fb7f6d7d --- /dev/null +++ b/test/server/cleanup.test.js @@ -0,0 +1,67 @@ +import { useEffect } from 'react' +import { renderHook, cleanup } from 'src/server' + +describe('cleanup tests', () => { + test('should flush effects on cleanup', async () => { + let cleanupCalled = false + + const hookWithCleanup = () => { + useEffect(() => { + return () => { + cleanupCalled = true + } + }) + } + + const { hydrate } = renderHook(() => hookWithCleanup()) + + hydrate() + + await cleanup() + + expect(cleanupCalled).toBe(true) + }) + + test('should cleanup all rendered hooks', async () => { + let cleanupCalled = [] + const hookWithCleanup = (id) => { + useEffect(() => { + return () => { + cleanupCalled[id] = true + } + }) + } + + const { hydrate: hydrate1 } = renderHook(() => hookWithCleanup(1)) + const { hydrate: hydrate2 } = renderHook(() => hookWithCleanup(2)) + + hydrate1() + hydrate2() + + await cleanup() + + expect(cleanupCalled[1]).toBe(true) + expect(cleanupCalled[2]).toBe(true) + }) + + test('should only cleanup hydrated hooks', async () => { + let cleanupCalled = [false, false] + const hookWithCleanup = (id) => { + useEffect(() => { + return () => { + cleanupCalled[id] = true + } + }) + } + + renderHook(() => hookWithCleanup(1)) + const { hydrate } = renderHook(() => hookWithCleanup(2)) + + hydrate() + + await cleanup() + + expect(cleanupCalled[1]).toBe(false) + expect(cleanupCalled[2]).toBe(true) + }) +}) diff --git a/test/server/customHook.test.js b/test/server/customHook.test.js new file mode 100644 index 00000000..b699b7d8 --- /dev/null +++ b/test/server/customHook.test.js @@ -0,0 +1,33 @@ +import { useState, useCallback } from 'react' +import { renderHook, act } from 'src/server' + +describe('custom hook tests', () => { + function useCounter() { + const [count, setCount] = useState(0) + + const increment = useCallback(() => setCount(count + 1), [count]) + const decrement = useCallback(() => setCount(count - 1), [count]) + + return { count, increment, decrement } + } + + test('should increment counter', () => { + const { result, hydrate } = renderHook(() => useCounter()) + + hydrate() + + act(() => result.current.increment()) + + expect(result.current.count).toBe(1) + }) + + test('should decrement counter', () => { + const { result, hydrate } = renderHook(() => useCounter()) + + hydrate() + + act(() => result.current.decrement()) + + expect(result.current.count).toBe(-1) + }) +}) diff --git a/test/server/errorHook.test.js b/test/server/errorHook.test.js new file mode 100644 index 00000000..616a17c4 --- /dev/null +++ b/test/server/errorHook.test.js @@ -0,0 +1,170 @@ +import { useState, useEffect } from 'react' +import { renderHook } from 'src/server' + +describe('error hook tests', () => { + function useError(throwError) { + if (throwError) { + throw new Error('expected') + } + return true + } + + function useAsyncError(throwError) { + const [value, setValue] = useState() + useEffect(() => { + const timeout = setTimeout(() => setValue(throwError), 100) + return () => clearTimeout(timeout) + }, [throwError]) + return useError(value) + } + + function useEffectError(throwError) { + useEffect(() => { + useError(throwError) + }, []) + return true + } + + describe('synchronous', () => { + test('should raise error', () => { + const { result } = renderHook(() => useError(true)) + + expect(() => { + expect(result.current).not.toBe(undefined) + }).toThrow(Error('expected')) + }) + + test('should capture error', () => { + const { result } = renderHook(() => useError(true)) + + expect(result.error).toEqual(Error('expected')) + }) + + test('should not capture error', () => { + const { result } = renderHook(() => useError(false)) + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + + test('should reset error', () => { + const { result, hydrate, rerender } = renderHook(({ throwError }) => useError(throwError), { + initialProps: { throwError: true } + }) + + expect(result.error).not.toBe(undefined) + + hydrate() + + rerender({ throwError: false }) + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + }) + + describe('asynchronous', () => { + test('should raise async error', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => useAsyncError(true)) + + hydrate() + + await waitForNextUpdate() + + expect(() => { + expect(result.current).not.toBe(undefined) + }).toThrow(Error('expected')) + }) + + test('should capture async error', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => useAsyncError(true)) + + hydrate() + + await waitForNextUpdate() + + expect(result.error).toEqual(Error('expected')) + }) + + test('should not capture async error', async () => { + const { result, hydrate, waitForNextUpdate } = renderHook(() => useAsyncError(false)) + + hydrate() + + await waitForNextUpdate() + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + + test('should reset async error', async () => { + const { result, hydrate, waitForNextUpdate, rerender } = renderHook( + ({ throwError }) => useAsyncError(throwError), + { initialProps: { throwError: true } } + ) + + hydrate() + + await waitForNextUpdate() + + expect(result.error).not.toBe(undefined) + + rerender({ throwError: false }) + + await waitForNextUpdate() + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + }) + + /* + These tests capture error cases that are not currently being caught successfully. + Refer to https://github.com/testing-library/react-hooks-testing-library/issues/308 + for more details. + */ + describe.skip('effect', () => { + test('should raise effect error', () => { + const { result, hydrate } = renderHook(() => useEffectError(true)) + + hydrate() + + expect(() => { + expect(result.current).not.toBe(undefined) + }).toThrow(Error('expected')) + }) + + test('should capture effect error', () => { + const { result, hydrate } = renderHook(() => useEffectError(true)) + + hydrate() + + expect(result.error).toEqual(Error('expected')) + }) + + test('should not capture effect error', () => { + const { result, hydrate } = renderHook(() => useEffectError(false)) + + hydrate() + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + + test('should reset effect error', () => { + const { result, hydrate, rerender } = renderHook( + ({ throwError }) => useEffectError(throwError), + { initialProps: { throwError: true } } + ) + + hydrate() + + expect(result.error).not.toBe(undefined) + + rerender({ throwError: false }) + + expect(result.current).not.toBe(undefined) + expect(result.error).toBe(undefined) + }) + }) +}) diff --git a/test/server/useContext.test.js b/test/server/useContext.test.js new file mode 100644 index 00000000..2934d7f0 --- /dev/null +++ b/test/server/useContext.test.js @@ -0,0 +1,45 @@ +import React, { createContext, useContext } from 'react' +import { renderHook } from 'src/server' + +describe('useContext tests', () => { + test('should get default value from context', () => { + const TestContext = createContext('foo') + + const { result } = renderHook(() => useContext(TestContext)) + + const value = result.current + + expect(value).toBe('foo') + }) + + test('should get value from context provider', () => { + const TestContext = createContext('foo') + + const wrapper = ({ children }) => ( + {children} + ) + + const { result } = renderHook(() => useContext(TestContext), { wrapper }) + + expect(result.current).toBe('bar') + }) + + test('should update value in context when props are updated', () => { + const TestContext = createContext('foo') + + const wrapper = ({ contextValue, children }) => ( + {children} + ) + + const { result, hydrate, rerender } = renderHook(() => useContext(TestContext), { + wrapper, + initialProps: { contextValue: 'bar' } + }) + + hydrate() + + rerender({ contextValue: 'baz' }) + + expect(result.current).toBe('baz') + }) +}) diff --git a/test/server/useEffect.test.js b/test/server/useEffect.test.js new file mode 100644 index 00000000..ff2387cd --- /dev/null +++ b/test/server/useEffect.test.js @@ -0,0 +1,38 @@ +import { useEffect, useLayoutEffect } from 'react' +import { renderHook } from 'src/server' + +describe('useEffect tests', () => { + test('should handle useEffect hook', () => { + const sideEffect = { [1]: false, [2]: false } + + const { hydrate, rerender, unmount } = renderHook( + ({ id }) => { + useEffect(() => { + sideEffect[id] = true + return () => { + sideEffect[id] = false + } + }, [id]) + }, + { initialProps: { id: 1 } } + ) + + expect(sideEffect[1]).toBe(false) + expect(sideEffect[2]).toBe(false) + + hydrate() + + expect(sideEffect[1]).toBe(true) + expect(sideEffect[2]).toBe(false) + + rerender({ id: 2 }) + + expect(sideEffect[1]).toBe(false) + expect(sideEffect[2]).toBe(true) + + unmount() + + expect(sideEffect[1]).toBe(false) + expect(sideEffect[2]).toBe(false) + }) +}) diff --git a/test/server/useMemo.test.js b/test/server/useMemo.test.js new file mode 100644 index 00000000..19bc1960 --- /dev/null +++ b/test/server/useMemo.test.js @@ -0,0 +1,87 @@ +import { useMemo, useCallback } from 'react' +import { renderHook } from 'src/server' + +describe('useCallback tests', () => { + test('should handle useMemo hook', () => { + const { result, hydrate, rerender } = renderHook( + ({ value }) => useMemo(() => ({ value }), [value]), + { + initialProps: { value: 1 } + } + ) + + const value1 = result.current + + expect(value1).toEqual({ value: 1 }) + + hydrate() + + const value2 = result.current + + expect(value2).toEqual({ value: 1 }) + + expect(value2).not.toBe(value1) + + rerender() + + const value3 = result.current + + expect(value3).toEqual({ value: 1 }) + + expect(value3).toBe(value2) + + rerender({ value: 2 }) + + const value4 = result.current + + expect(value4).toEqual({ value: 2 }) + + expect(value4).not.toBe(value2) + }) + + test('should handle useCallback hook', () => { + const { result, hydrate, rerender } = renderHook( + ({ value }) => { + const callback = () => ({ value }) + return useCallback(callback, [value]) + }, + { initialProps: { value: 1 } } + ) + + const callback1 = result.current + + const calbackValue1 = callback1() + + expect(calbackValue1).toEqual({ value: 1 }) + + hydrate() + + const callback2 = result.current + + const calbackValue2 = callback2() + + expect(calbackValue2).toEqual({ value: 1 }) + + expect(callback2).not.toBe(callback1) + + rerender() + + const callback3 = result.current + + const calbackValue3 = callback3() + + expect(calbackValue3).toEqual({ value: 1 }) + + expect(callback3).toBe(callback2) + + rerender({ value: 2 }) + + const callback4 = result.current + + const calbackValue4 = callback4() + + expect(calbackValue4).toEqual({ value: 2 }) + + expect(callback4).not.toBe(callback2) + }) +}) diff --git a/test/server/useReducer.test.js b/test/server/useReducer.test.js new file mode 100644 index 00000000..ea3724ab --- /dev/null +++ b/test/server/useReducer.test.js @@ -0,0 +1,21 @@ +import { useReducer } from 'react' +import { renderHook, act } from 'src/server' + +describe('useReducer tests', () => { + test('should handle useReducer hook', () => { + const reducer = (state, action) => (action.type === 'inc' ? state + 1 : state) + + const { result, hydrate } = renderHook(() => { + const [state, dispatch] = useReducer(reducer, 0) + return { state, dispatch } + }) + + hydrate() + + expect(result.current.state).toBe(0) + + act(() => result.current.dispatch({ type: 'inc' })) + + expect(result.current.state).toBe(1) + }) +}) diff --git a/test/server/useRef.test.js b/test/server/useRef.test.js new file mode 100644 index 00000000..1327e604 --- /dev/null +++ b/test/server/useRef.test.js @@ -0,0 +1,29 @@ +import { useRef, useImperativeHandle } from 'react' +import { renderHook } from 'src/server' + +describe('useHook tests', () => { + test('should handle useRef hook', () => { + const { result } = renderHook(() => useRef('foo')) + + const refContainer = result.current + + expect(Object.keys(refContainer)).toEqual(['current']) + expect(refContainer.current).toBe('foo') + }) + + test('should handle useImperativeHandle hook', () => { + const { result, hydrate } = renderHook(() => { + const ref = useRef() + useImperativeHandle(ref, () => ({ + fakeImperativeMethod: () => true + })) + return ref + }) + + expect(result.current.current).toBeUndefined() + + hydrate() + + expect(result.current.current.fakeImperativeMethod()).toBe(true) + }) +}) diff --git a/test/server/useState.test.js b/test/server/useState.test.js new file mode 100644 index 00000000..48af8903 --- /dev/null +++ b/test/server/useState.test.js @@ -0,0 +1,39 @@ +import { useState } from 'react' +import { renderHook, act } from 'src/server' + +describe('useState tests', () => { + test('should use state value', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('foo') + return { value, setValue } + }) + + expect(result.current.value).toBe('foo') + }) + + test('should retain state value after hydration', () => { + const { result, hydrate } = renderHook(() => { + const [value, setValue] = useState('foo') + return { value, setValue } + }) + + hydrate() + + expect(result.current.value).toBe('foo') + }) + + test('should update state value using setter', () => { + const { result, hydrate } = renderHook(() => { + const [value, setValue] = useState('foo') + return { value, setValue } + }) + + hydrate() + + act(() => { + result.current.setValue('bar') + }) + + expect(result.current.value).toBe('bar') + }) +}) diff --git a/test/useReducer.test.js b/test/useReducer.test.js deleted file mode 100644 index 092d585d..00000000 --- a/test/useReducer.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { useReducer } from 'react' -import { renderHook, act } from 'src' - -describe('useReducer tests', () => { - test('should handle useReducer hook', () => { - const reducer = (state, action) => (action.type === 'inc' ? state + 1 : state) - const { result } = renderHook(() => useReducer(reducer, 0)) - - const [initialState, dispatch] = result.current - - expect(initialState).toBe(0) - - act(() => dispatch({ type: 'inc' })) - - const [state] = result.current - - expect(state).toBe(1) - }) -}) diff --git a/test/useState.test.js b/test/useState.test.js deleted file mode 100644 index ebb943f8..00000000 --- a/test/useState.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { useState } from 'react' -import { renderHook, act } from 'src' - -describe('useState tests', () => { - test('should use setState value', () => { - const { result } = renderHook(() => useState('foo')) - - const [value] = result.current - - expect(value).toBe('foo') - }) - - test('should update setState value using setter', () => { - const { result } = renderHook(() => useState('foo')) - - const [_, setValue] = result.current - - act(() => setValue('bar')) - - const [value] = result.current - - expect(value).toBe('bar') - }) -})