diff --git a/docs/example-react-hooks.md b/docs/example-react-hooks.md new file mode 100644 index 000000000..cbc14c73a --- /dev/null +++ b/docs/example-react-hooks.md @@ -0,0 +1,202 @@ +--- +id: example-react-hooks +title: React Hooks +--- + +`react-testing-library` provides the +[`testHook`](/docs/react-testing-library/api#testhook) utility to test custom +hooks. + +> **Note** +> +> This is the recommended way to test reusable custom react hooks. It is not +> however recommended to use the testHook utility to test single-use custom +> hooks. Typically those are better tested by testing the component that is +> using it. + +## Using `result` + +Testing the last returned value of a hook using the `result` ref + +```jsx +function useCounter({ initialCount = 0, step = 1 } = {}) { + const [count, setCount] = React.useState(initialCount) + const increment = () => setCount(c => c + step) + const decrement = () => setCount(c => c - step) + return { count, increment, decrement } +} +``` + +```jsx +test('returns result ref with latest result from hook execution', () => { + const { result } = testHook(useCounter) + expect(result.current.count).toBe(0) + act(() => { + result.current.increment() + }) + expect(result.current.count).toBe(1) +}) +``` + +## State + +Testing a hook that provides state + +```jsx +import { useState } from 'react' + +export function useCounter({ initialCount = 0, step = 1 } = {}) { + const [count, setCount] = useState(initialCount) + const increment = () => setCount(c => c + step) + const decrement = () => setCount(c => c - step) + return { count, increment, decrement } +} +``` + +```jsx +import { testHook, act, cleanup } from 'react-testing-library' +afterEach(cleanup) + +describe('useCounter', () => { + test('accepts default initial values', () => { + let count + testHook(() => ({ count } = useCounter())) + + expect(count).toBe(0) + }) + + test('accepts a default initial value for `count`', () => { + let count + testHook(() => ({ count } = useCounter({}))) + + expect(count).toBe(0) + }) + + test('provides an `increment` function', () => { + let count, increment + testHook(() => ({ count, increment } = useCounter({ step: 2 }))) + + expect(count).toBe(0) + act(() => { + increment() + }) + expect(count).toBe(2) + }) + + test('provides an `decrement` function', () => { + let count, decrement + testHook(() => ({ count, decrement } = useCounter({ step: 2 }))) + + expect(count).toBe(0) + act(() => { + decrement() + }) + expect(count).toBe(-2) + }) + + test('accepts a default initial value for `step`', () => { + let count, increment + testHook(() => ({ count, increment } = useCounter({}))) + + expect(count).toBe(0) + act(() => { + increment() + }) + expect(count).toBe(1) + }) +}) +``` + +## Unmount Side-Effects + +Using the `unmount` function to check useEffect behavior when unmounting + +```jsx +import { useState, useEffect } from 'react' + +export function useDocumentTitle(title) { + const [originalTitle, setOriginalTitle] = useState(document.title) + useEffect(() => { + setOriginalTitle(document.title) + document.title = title + return () => { + document.title = originalTitle + } + }, [title]) +} +``` + +```jsx +describe('useDocumentTitle', () => { + test('sets a title', () => { + document.title = 'original title' + testHook(() => { + useDocumentTitle('modified title') + }) + + expect(document.title).toBe('modified title') + }) + + test('returns to original title when component is unmounted', () => { + document.title = 'original title' + const { unmount } = testHook(() => { + useDocumentTitle('modified title') + }) + + unmount() + expect(document.title).toBe('original title') + }) +}) +``` + +## Rerender Side-Effects + +Using the `rerender` function to test calling useEffect multiple times + +```jsx +import { useEffect } from 'react' + +export function useCall(callback, deps) { + useEffect(() => { + callback() + }, deps) +} +``` + +```jsx +describe('useCall', () => { + test('calls once on render', () => { + const spy = jest.fn() + testHook(() => { + useCall(spy, []) + }) + expect(spy).toHaveBeenCalledTimes(1) + }) + + test('calls again if deps change', () => { + let deps = [false] + const spy = jest.fn() + const { rerender } = testHook(() => { + useCall(spy, deps) + }) + expect(spy).toHaveBeenCalledTimes(1) + + deps = [true] + rerender() + expect(spy).toHaveBeenCalledTimes(2) + }) + + test('does not call again if deps are the same', () => { + let deps = [false] + const spy = jest.fn() + const { rerender } = testHook(() => { + useCall(spy, deps) + }) + expect(spy).toHaveBeenCalledTimes(1) + + deps = [false] + rerender() + expect(spy).toHaveBeenCalledTimes(1) + }) +}) +``` diff --git a/docs/react-testing-library/api.md b/docs/react-testing-library/api.md index 0f6d63124..e7b5c2c49 100644 --- a/docs/react-testing-library/api.md +++ b/docs/react-testing-library/api.md @@ -271,3 +271,69 @@ This is a light wrapper around the [`react-dom/test-utils` `act` function](https://reactjs.org/docs/test-utils.html#act). All it does is forward all arguments to the act function if your version of react supports `act`. + +## `testHook` + +`testHook` is a utility to test custom hooks. It is designed to help test +reusable hooks in isolation. + +You should also write integration tests for components using custom hooks, and +one-off hooks should be tested as part of the component instead. + +**Usage** + +```jsx +import { testHook } from 'react-testing-libary' + +testHook(hook[, renderOptions]) +``` + +**Arguments** + +- `hook` customHook to test +- `renderOptions` options object to pass to the underlying `render`. See + [render options](#render-options). This is mostly useful for wrapping the hook + with a context provider. + +**Returns** + +```jsx +const { rerender, unmount, result } = testHook(hook) +``` + +- `rerender` Call this function to render the wrapper again, i.e., to test that + the hook handles props changes +- `unmount` Call this to unmount the component, i.e., to test side-effects and + cleanup behavior +- `result` An object that acts like a React ref with a `current` property + pointing to the last value the hook returned. For example: + `expect(result.current.count).toBe(0)` + +**Example** + +```jsx +// Example custom hook +function useCounter({ initialCount = 0, step = 1 } = {}) { + const [count, setCount] = React.useState(initialCount) + const increment = () => setCount(c => c + step) + const decrement = () => setCount(c => c - step) + return { count, increment, decrement } +} +``` + +```jsx +// Test using the `result` ref +test('returns result ref with latest result from hook execution', () => { + const { result } = testHook(useCounter) + + expect(result.current.count).toBe(0) + act(() => result.current.increment()) + expect(result.current.count).toBe(1) +}) +``` + +**More** + +- [More Examples](/docs/example-react-hooks) +- [Tests](https://github.com/kentcdodds/react-testing-library/blob/master/src/__tests__/test-hook.js) +- [Types](https://github.com/kentcdodds/react-testing-library/blob/master/typings/index.d.ts) diff --git a/website/blog/2019-02-06-react-hooks.md b/website/blog/2019-02-06-react-hooks.md new file mode 100755 index 000000000..770920879 --- /dev/null +++ b/website/blog/2019-02-06-react-hooks.md @@ -0,0 +1,16 @@ +--- +title: React Hooks Are Supported +author: Alex Krolick +authorURL: http://github.com/alexkrolick +--- + +[Hooks have been released in React 16.8](https://reactjs.org/blog/2019/02/06/react-v16.8.0.html#testing-hooks) +and they are supported out of the box by `react-testing-library`! + +Because `react-testing-library` only uses the external interface of your React +components, hooks work right away! If you rewrite a class component with hooks +your tests should still pass. + +For unit testing custom hooks, we've also added a `testHook` utility. Check out +the [docs for `testHook`](/docs/react-testing-library/api#testhook). Thanks to +[@donavon](https://github.com/donavon) for the PR. diff --git a/website/sidebars.json b/website/sidebars.json index cbeb06325..ded4f8d6c 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -12,6 +12,7 @@ "example-input-event", "example-update-props", "example-react-context", + "example-react-hooks", "example-react-redux", "example-react-router", "example-reach-router",