0

How can we ensure that a custom hook actually calls a method exposed by another hook?

Let's say, I have a custom hook useName that internally leverages useState.

import { useState } from 'react'

export const useName = () => {

    const [name, setState] = useState()

    const setName = (firstName: string, lastName: string) => setState([firstName, lastName].join(' '))

    return {name, setName}
}

I need to assert that calling setName actually calls `setState'. My test case is written as following:

/**
* @jest-environment jsdom
*/

import * as React from 'react'
import { renderHook, act } from '@testing-library/react-hooks'
import { useName } from './useName'

jest.mock('react')

const setState = jest.fn()

React.useState.mockReturnValue(['ignore', setState]) //overwriting useState

test('ensures that setState is called', () => {
    
    const {result} = renderHook(() => useName())

    act(() => {
        result.current.setName("Kashif", "Nazar") //I am expecting this to hit jest.fn() declared above.
    })

    expect(setState).toBeCalled()

})

and I get the following result.

 FAIL  src/useName.test.ts
  ✕ ensures that setState is called (3 ms)

  ● ensures that setState is called

    TypeError: Cannot read property 'setName' of undefined

      18 |
      19 |     act(() => {
    > 20 |         result.current.setName("Kashif", "Nazar")
         |                        ^
      21 |     })
      22 |
      23 |     expect(setState).toBeCalled()

      at src/useName.test.ts:20:24
      at batchedUpdates$1 (node_modules/react-dom/cjs/react-dom.development.js:22380:12)
      at act (node_modules/react-dom/cjs/react-dom-test-utils.development.js:1042:14)
      at Object.<anonymous> (src/useName.test.ts:19:5)
      at TestScheduler.scheduleTests (node_modules/@jest/core/build/TestScheduler.js:333:13)
      at runJest (node_modules/@jest/core/build/runJest.js:404:19)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.32 s, estimated 1 s
Ran all test suites.

Is this possible, and am I doing it the right way?

Lin Du
  • 88,126
  • 95
  • 281
  • 483
Kashif Nazar
  • 20,775
  • 5
  • 29
  • 46

1 Answers1

1

You should test the returned state instead of the implementation detail(setState). Mock may destroy the functionality of setState. This causes the test case to pass, but the code under test will fail at the actual run time. And mock also make the test vulnerable, when your implementation details change, your test cases have to change accordingly such as mock the new object.

I only test if the interface is satisfied, no matter how the implementation details change, right

useName.ts:

import { useState } from 'react';

export const useName = () => {
  const [name, setState] = useState('');
  const setName = (firstName: string, lastName: string) => setState([firstName, lastName].join(' '));
  return { name, setName };
};

useName.test.ts:

import { renderHook, act } from '@testing-library/react-hooks';
import { useName } from './useName';

describe('70381825', () => {
  test('should pass', () => {
    const { result } = renderHook(() => {
      console.count('render');
      return useName();
    });
    expect(result.current.name).toBe('');
    act(() => {
      result.current.setName('Kashif', 'Nazar');
    });
    expect(result.current.name).toBe('Kashif Nazar');
    act(() => {
      result.current.setName('a', 'b');
    });
  });
});

Test result:

 PASS  examples/70381825/useName.test.ts
  70381825 - mock way
    ○ skipped should pass
  70381825
    ✓ should pass (29 ms)

  console.count
    render: 1

      at examples/70381825/useName.test.ts:31:15

  console.count
    render: 2

      at examples/70381825/useName.test.ts:31:15

  console.count
    render: 3

      at examples/70381825/useName.test.ts:31:15

Test Suites: 1 passed, 1 total
Tests:       1 skipped, 1 passed, 2 total
Snapshots:   0 total
Time:        1.251 s, estimated 8 s

Now, if you insist to use a mock way. You should only mock useState hook of React. jest.mock('react') will create mocks for all methods, properties, and functions exported by React, and this will break their functions.

E.g.

useName.test.ts:

import { renderHook, act } from '@testing-library/react-hooks';
import { useName } from './useName';
import React from 'react';

jest.mock('react', () => {
  return { ...(jest.requireActual('react') as any), useState: jest.fn() };
});

describe('70381825 - mock way', () => {
  test('should pass', () => {
    const setState = jest.fn();
    (React.useState as jest.MockedFunction<typeof React.useState>).mockReturnValue(['ignore', setState]);
    const { result } = renderHook(() => {
      console.count('render');
      return useName();
    });
    act(() => {
      result.current.setName('a', 'b');
    });
    expect(result.current.name).toBe('ignore');
    expect(setState).toBeCalled();
    act(() => {
      result.current.setName('c', 'd');
    });
  });
});

Test result:

 PASS  examples/70381825/useName.test.ts (7.885 s)
  70381825 - mock way
    ✓ should pass (29 ms)
  70381825
    ○ skipped should pass

  console.count
    render: 1

      at examples/70381825/useName.test.ts:14:15

Test Suites: 1 passed, 1 total
Tests:       1 skipped, 1 passed, 2 total
Snapshots:   0 total
Time:        8.487 s

Ok. Do you know why the mock way only renders one time and the other way renders three times when we call the setName? As I said earlier.

Lin Du
  • 88,126
  • 95
  • 281
  • 483