The guy on the cover is clearly not looking happy. No no this has nothing to do with Testing.
Testing is Fun and if you hate it maybe I can change your mind, stay with me.
We are utilising our super cool `useAsync()` hook that we developed in the previous part of the series but you don't need to read them in order to move ahead. This can be treated as a standalone blog on its own but I am categorising it as part 3 of our `useAsync()` hook series.
The `useAsync` hook looks like this:
1function useSafeDispatch(dispatch) {
2 const mounted = React.useRef(false)
3
4
5 React.useLayoutEffect(() => {
6 mounted.current = true
7 return () => (mounted.current = false)
8 }, [])
9 return React.useCallback(
10 (...args) => (mounted.current ? dispatch(...args) : void 0),
11 [dispatch],
12 )
13}
14
15
16const defaultInitialState = {status: 'idle', data: null, error: null}
17
18
19function useAsync(initialState) {
20 const initialStateRef = React.useRef({
21 ...defaultInitialState,
22 ...initialState,
23 })
24 const [{status, data, error}, setState] = React.useReducer(
25 (s, a) => ({...s, ...a}),
26 initialStateRef.current,
27 )
28
29
30 const safeSetState = useSafeDispatch(setState)
31
32
33 const setData = React.useCallback(
34 data => safeSetState({data, status: 'resolved'}),
35 [safeSetState],
36 )
37 const setError = React.useCallback(
38 error => safeSetState({error, status: 'rejected'}),
39 [safeSetState],
40 )
41 const reset = React.useCallback(
42 () => safeSetState(initialStateRef.current),
43 [safeSetState],
44 )
45
46
47 const run = React.useCallback(
48 promise => {
49 if (!promise || !promise.then) {
50 throw new Error(
51 `The argument passed to useAsync().run must be a promise. Maybe a function that's passed isn't returning anything?`,
52 )
53 }
54 safeSetState({status: 'pending'})
55 return promise.then(
56 data => {
57 setData(data)
58 return data
59 },
60 error => {
61 setError(error)
62 return Promise.reject(error)
63 },
64 )
65 },
66 [safeSetState, setData, setError],
67 )
68
69
70 return {
71 isIdle: status === 'idle',
72 isLoading: status === 'pending',
73 isError: status === 'rejected',
74 isSuccess: status === 'resolved',
75 setData,
76 setError,
77 error,
78 status,
79 data,
80 run,
81 reset,
82 }
83}
84
85
86export {useAsync}
Give it a read, it is fairly simple to understand and if you want to deep dive into a particular part, check part 2 here which is a thorough guide covering ideation to implementation and optimization.
We want to write a test for this so we can maintain confidence that as we make changes and fixes bugs we don't break existing functionality. To get the maximum confidence we need, we should ensure that our tests resemble the way the software will be used. The software is all about automating things that we don't want to or cannot do manually. Tests are no different, so consider how you would test this manually, then write your test to do the same thing.
You might be saying "Well hooks are functions only, we can unit test them."
Can we?
-> Hooks are not pure functions, else if it was pure, then it would be a simple task of calling it and asserting on the output.
-> Also by simply calling the function in a test, we'll break the rules of hooks and we'll be greeted with `Error: Invalid hook call`.
Kent (creator of the react-testing library) advises to not test custom hooks separately and recommends running them as part of integration tests with other components. Doing this will also help avoid our natural tendency to over-abstract our custom hook to support things that your components don't actually need. However, highly reusable or complex hooks can really benefit from a solid suite of tests dedicated to them specifically.
Taking his words, we should stay away from testing hooks separately if they are fairly simple and try to cover them in the integration tests while testing the component. But ours is not that simple and the component that we need to write can be pretty complicated and we can end up getting test failures not because the hook is broken, but because of the example, we wrote.
Hell Yeah! Now we know what to test and why to test!
We have two approaches:
1. Create a test component that uses the hook in the typical way the hook would be used by consumers and test that component.
2. Use `@testing-library/reacthooks`.
In this blog, we will cover the second approach using `react-testing-library`.
Before we jump straight into testing let's create a helper deferred method for mocking JavaScript `Promise` behavior.
1function deferred() {
2 let resolve, reject
3 const promise = new Promise((res, rej) => {
4 resolve = res
5 reject = rej
6 })
7 return {promise, resolve, reject}
8}
9```
10It is a simple method with which we can imperatively resolve or reject whenever we want.
11
12
13```js
14const {promise, resolve,reject} = deferred()
15//resolve
16const fakeResolvedValue = Symbol('some resolved value')
17run(promise)
18resolve(resolvedValue)
19await promise
20
21
22//reject
23const rejectedValue = Symbol('rejected value')
24run(promise)
25reject(rejectedValue)
26await promise.catch(() => {
27 /* ignore error */
28 })
29
You would have got a fair idea, things will get more clear once we start testing.
1import {renderHook} from '@testing-library/react-hooks'
2import {useAsync} from '../hooks'
3
4
5test('calling run with a promise which resolves', async () => {
6 const {promise, resolve} = deferred()
7 //this is how we can render the hook using the library
8 const {result} = renderHook(() => useAsync())
9 //try console logging result.current and see what exactly is the result object
10 console.log(result)
11}
This is what it prints:
1{
2 isIdle: true,
3 isLoading: false,
4 isError: false,
5 isSuccess: false,
6 setData: [Function (anonymous)],
7 setError: [Function (anonymous)],
8 error: null,
9 status: 'idle',
10 data: null,
11 run: [Function (anonymous)],
12 reset: [Function (anonymous)]
13}
This looks like what our hook will assign upon initialisation or we can say default state.
`Function(anonymous)` is not of our concern, basically, it says that it is some function and we don't need to know much more than that. So, we will assert them using `expect.any(Function)` and our job is done.
Also, let's create a default, pending, resolved and rejected state object for our ease.
1const defaultState = {
2 status: 'idle',
3 data: null,
4 error: null,
5
6
7 isIdle: true,
8 isLoading: false,
9 isError: false,
10 isSuccess: false,
11
12
13 run: expect.any(Function),
14 reset: expect.any(Function),
15 setData: expect.any(Function),
16 setError: expect.any(Function),
17}
18
19
20const pendingState = {
21 ...defaultState,
22 status: 'pending',
23 isIdle: false,
24 isLoading: true,
25}
26
27
28const resolvedState = {
29 ...defaultState,
30 status: 'resolved',
31 isIdle: false,
32 isSuccess: true,
33}
34
35
36const rejectedState = {
37 ...defaultState,
38 status: 'rejected',
39 isIdle: false,
40 isError: true,
41}
Now everything is set, so let's complete our tests.
TEST 1: Calling run with a promise which resolves
1test('calling run with a promise which resolves', async () => {
2 const {promise, resolve} = deferred()
3 const {result} = renderHook(() => useAsync())
4 expect(result.current).toEqual(defaultState)
5 /* we will pass our promise to run method and check if we are getting
6 pending state or not */
7 let p
8 act(() => {
9 p = result.current.run(promise)
10 })
11 expect(result.current).toEqual(pendingState)
12
13/* We are resolving our promise and asserting if the value is
14 equal to resolvedValue */
15
16
17 const resolvedValue = Symbol('resolved value')
18 await act(async () => {
19 resolve(resolvedValue)
20 await p
21 })
22 expect(result.current).toEqual({
23 ...resolvedState,
24 data: resolvedValue,
25 })
26
27
28 // asserting if reset method is working or not
29 act(() => {
30 result.current.reset()
31 })
32 expect(result.current).toEqual(defaultState)
33})
What is the `act` here?
In short,
act() makes sure that anything that might take time - rendering, user events, data fetching - within it is completed before test assertions are run.
Yay! our first test on its own has made us so confident in our hook. It has tested the complete happy path from initialisation to resolution and even resetting of state.
But we will be more confident when the hook passes the promise rejection test, the sad path :(.
TEST 2: Calling run with a promise which rejects
1test('calling run with a promise which rejects', async () => {
2 const {promise, reject} = deferred()
3 const {result} = renderHook(() => useAsync())
4 expect(result.current).toEqual(defaultState)
5 let p
6 act(() => {
7 p = result.current.run(promise)
8 })
9 expect(result.current).toEqual(pendingState)
10 /* same as our first test till now but now we will reject the promise
11 assert for rejectedState with our created rejected value */
12 const rejectedValue = Symbol('rejected value')
13 await act(async () => {
14 reject(rejectedValue)
15 await p.catch(() => {
16 /* ignore error */
17 })
18 })
19 expect(result.current).toEqual({...rejectedState, error: rejectedValue})
20})
Notice that how our tests are resembling with how our software will be used in the real world. This way we are making sure that we're focusing our efforts in the right place and not testing at too low of a level unnecessarily.
I will not write all the tests as it will make the blog too long, feel free to check all the tests implementation on Github.
Try implementing the below tests yourself and if you face any doubt or problems, feel free to ask in the comments or DM me:
-> TEST 3: can specify an initial state
->TEST 4: can set the data
-> TEST 5: can set the error
-> TEST 6: No state updates happen if the component is unmounted while pending
-> TEST 7: calling "run" without a promise results in an early error
A little about me, I am Harsh and I love to code, I feel at home while building web apps in React. I am currently learning Remix.
If you learned something from this blog, I am planning to bring more such blogs in Future, Let's keep in touch!
Go Check other blogs of the series!
Loves to Code 👨💻 | React Junkie ⚛️ | Learning and sharing 📚
No posts right now.