Unit testing a custom hook to ensure that it calls another hook - reactjs

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?

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.

Related

window.dispatchEvent from test code does not trigger window.addEventListener

I have below listener added for which I am trying to write test using Jest. However, it seems as though the event I'm dispatching doesn't reach my code.
window.addEventListener('message', (event) => {
if (event.data.type === 'abc') {
console.log(event.data.payload);
}
});
I have tried below 2 approaches and both of them don't seem to work. I'm unable to verify the call using the spy object I'm creating. Please refer to the code below:
const listenerSpy = jest.spyOn(window, 'addEventListener');
const data = {
type: 'abc',
payload: '',
};
const messageEvent = new MessageEvent('message', {data});
window.dispatchEvent(messageEvent);
expect(listenerSpy).toHaveBeenCalledTimes(1);
const listenerSpy = jest.spyOn(window, 'addEventListener');
const data = {
type: 'abc',
payload: '',
};
window.postMessage(data, '*');
expect(listenerSpy).toHaveBeenCalledTimes(1);
For the 1st approach, have also tried using 'new Event('message')'.
With above 2 approaches, I get the error as below:
expect(jest.fn()).toHaveBeenCalledTimes(expected)
Expected number of calls: 1
Received number of calls: 0
102 | window.dispatchEvent(messageEvent);
103 |
> 104 | expect(listenerSpy).toHaveBeenCalledTimes(1);
| ^
I have also tried to follow different websites including below:
https://medium.com/#DavideRama/testing-global-event-listener-within-a-react-component-b9d661e59953
https://github.com/enzymejs/enzyme/issues/426
But no luck there as with typescript, I cannot follow the solution given. I have also tried to find answers on stackoverflow, but the none of solutions suggested seem to work for me.
I am new to react and got stuck with this. Any pointers on this would help.
jsdom fire the message event inside a setTimeout, see this
setTimeout(() => {
fireAnEvent("message", this, MessageEvent, { data: message });
}, 0);
For more info, see issue
So, it's asynchronous and you need to wait for the macro task scheduled by setTimeout to be finished before the test case ends.
index.ts:
export function main() {
window.addEventListener('message', (event) => {
if (event.data.type === 'abc') {
console.log(event.data.payload);
}
});
}
index.test.ts:
import { main } from './';
function flushMessageQueue(ms = 10) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('71912032', () => {
test('should pass', async () => {
const logSpy = jest.spyOn(console, 'log');
main();
const data = { type: 'abc', payload: 'xyz' };
window.postMessage(data, '*');
await flushMessageQueue();
expect(logSpy).toBeCalledWith('xyz');
});
});
Test result:
PASS stackoverflow/71912032/index.test.ts
71912032
✓ should pass (41 ms)
console.log
xyz
at console.<anonymous> (node_modules/jest-mock/build/index.js:845:25)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 4.01 s, estimated 13 s
Also, take a look at this question React Jest: trigger 'addEventListener' 'message' from tests
Have you tried taking a look at this discussion? It seems to have a similar requirement.
Dispatch a Custom Event and test if it was correctly triggered (React TypeScript, Jest)

Unable to test combination of useEffect and setTimeout using Jest

Trying to assert a simple state change made using useEffect and setTimeout. The effect calls setTimeout with a value of 1500ms which should change the displayed string from 'unchanged' to 'changed'.
component
import * as React from 'React';
import {useEffect} from 'React';
import {Text, View} from 'react-native';
export function Dummy() {
const [str, changeStr] = React.useState('unchanged');
useEffect(() => {
setTimeout(() => {
changeStr('changed');
}, 1500);
}, []);
return (
<View>
<Text>{str}</Text>
</View>
);
}
test
import {render, waitFor} from '#testing-library/react-native';
import * as React from 'React';
import {Dummy} from '../Dummy';
// Activate fake timers
jest.useFakeTimers();
describe('Dummy', () => {
it('should change the string after 1500 ms', async () => {
const {getByText} = render(<Dummy />);
// run all timers which should fire the state update
jest.runAllTimers();
await waitFor(
() => {
expect(getByText('changed')).toBeTruthy();
},
{timeout: 5000},
);
});
});
result
FAIL src/components/__tests__/dummy.spec.js (8.037 s)
Dummy
✕ should change the string after 1500 ms (5226 ms)
● Dummy › should change the string after 1500 ms
: Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Error:
7 |
8 | describe('Dummy', () => {
> 9 | it('should change the string after 1500 ms', async () => {
| ^
10 | const {getByText} = render(<Dummy />);
11 |
12 | // run all timers which should fire the state update
at new Spec (node_modules/jest-jasmine2/build/jasmine/Spec.js:116:22)
at Suite.<anonymous> (src/components/__tests__/dummy.spec.js:9:3)
anyone know why this is and how I can successfully test this?

React testing lib not update the state

My component:
import React from 'react'
const TestAsync = () => {
const [counter, setCounter] = React.useState(0)
const delayCount = () => (
setTimeout(() => {
setCounter(counter + 1)
}, 500)
)
return (
<>
<h1 data-testid="counter">{ counter }</h1>
<button data-testid="button-up" onClick={delayCount}> Up</button>
<button data-testid="button-down" onClick={() => setCounter(counter - 1)}>Down</button>
</>
)
}
export default TestAsync
My test file:
describe("Test async", () => {
it("increments counter after 0.5s", async () => {
const { getByTestId, getByText } = render(<TestAsync />);
fireEvent.click(getByTestId("button-up"));
const counter = await waitForElement(() => getByTestId("counter"));
expect(counter).toHaveTextContent("1");
});
});
After run the test file, I got error said:
Expected element to have text content:
1
Received:
0
I am a little bit confused why I use waitForElement to get the element but why the element still has the old value?
React-testing-lib version 9.3.2
First of all, waitForElement has been deprecated. Use a find* query (preferred: https://testing-library.com/docs/dom-testing-library/api-queries#findby) or use waitFor instead: https://testing-library.com/docs/dom-testing-library/api-async#waitfor
Now, we use waitFor:
waitFor may run the callback a number of times until the timeout is reached.
You need to wrap the assertion statement inside the callback of the waitFor. So that waitFor can run the callback multiple times. If you put the expect(counter).toHaveTextContent('1'); statement outside and after waitFor statement, then it only run once. React has not been updated when assertions run.
Why RTL will run the callback multiple times(run callback every interval before timeout)?
RTL use MutationObserver to watch for changes being made to the DOM tree, see here. Remember, our test environment is jsdom, it supports MutationObserver, see here.
That means when React updates the state and applies the update to the DOM, the changes of the DOM tree can be detected and RTL will run the callback again including the assertion. When the React component states are applied and become stable, the last run of the callback is taken as the final assertion of the test. If the assertion fails, an error is reported, otherwise, the test passes.
So the working example should be:
index.tsx:
import React from 'react';
const TestAsync = () => {
const [counter, setCounter] = React.useState(0);
const delayCount = () =>
setTimeout(() => {
setCounter(counter + 1);
}, 500);
return (
<>
<h1 data-testid="counter">{counter}</h1>
<button data-testid="button-up" onClick={delayCount}>
Up
</button>
<button data-testid="button-down" onClick={() => setCounter(counter - 1)}>
Down
</button>
</>
);
};
export default TestAsync;
index.test.tsx:
import { fireEvent, render, waitFor } from '#testing-library/react';
import React from 'react';
import TestAsync from '.';
import '#testing-library/jest-dom/extend-expect';
describe('Test async', () => {
it('increments counter after 0.5s', async () => {
const { getByTestId } = render(<TestAsync />);
fireEvent.click(getByTestId('button-up'));
await waitFor(() => {
const counter = getByTestId('counter');
expect(counter).toHaveTextContent('1');
});
});
});
Test result:
PASS stackoverflow/71639088/index.test.tsx
Test async
✓ increments counter after 0.5s (540 ms)
-----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------|---------|----------|---------|---------|-------------------
All files | 88.89 | 100 | 75 | 88.89 |
index.tsx | 88.89 | 100 | 75 | 88.89 | 17
-----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.307 s

Describe method can only pass with 1 test unless re-rendering each component again and again

I'm trying to figure out why my test - which passes when ran alone - is failing whenever the describe block contains more than 1 test. Take this example, which I've taken from my real code and simplified:
describe('Create Account Form', () => {
const {container} = render(<CreateAccountForm />);
const email = container.querySelector('input[name="email"]');
const password1 = container.querySelector('input[name="password1"]');
it('Should render all fields', () => {
allInputs.forEach((input) => {
expect(input).toBeInTheDocument();
});
});
it('Another test', () => {
expect(email).toBeInTheDocument(); // fails
});
});
The 2nd test fails, but passes only when commenting out the first test, or re-rendering the container again in the test like this:
it('Another test', () => {
const {container} = render(<CreateAccountForm />);
const email = container.querySelector('input[name="email"]');
expect(email).toBeInTheDocument(); // passes
});
Why does this have to happen? I would much rather not have to re-render the container and declare new variables inside each test block.
Thank you
RTL will unmount React trees that were mounted with render in afterEach hook. See cleanup.
Please note that this is done automatically if the testing framework you're using supports the afterEach global and it is injected to your testing environment (like mocha, Jest, and Jasmine).
Move the render code into beforeEach or individual test case. So that we can create react trees before each test case. Isolate test cases from each other, using their own test data without affecting the rest.
E.g.
index.tsx:
import React from 'react';
export function Example() {
return (
<div>
<input name="email" />
<input name="password1" />
</div>
);
}
index.test.tsx:
import { render } from '#testing-library/react';
import '#testing-library/jest-dom/extend-expect';
import React from 'react';
import { Example } from './';
describe('70753645', () => {
let email, password1, allInputs;
beforeEach(() => {
const { container } = render(<Example />);
email = container.querySelector('input[name="email"]');
password1 = container.querySelector('input[name="password1"]');
allInputs = container.querySelectorAll('input');
});
it('Should render all fields', () => {
allInputs.forEach((input) => {
expect(input).toBeInTheDocument();
});
});
it('Another test', () => {
expect(email).toBeInTheDocument();
});
});
Test result:
PASS stackoverflow/70753645/index.test.tsx (9.222 s)
70753645
✓ Should render all fields (24 ms)
✓ Another test (3 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 9.717 s
package versions:
"#testing-library/react": "^11.2.2",
"jest": "^26.6.3",

update and get new state from setState Hook with Jest

I succeed to create a functional component, to mock the useState function and to get the call to the mocking function. But the functional component keep its initial value. Is there no way at all to get the new functional component created after update with its new initial "useState" value ?
For example, if I do an "simulate(click)" with enzyme twice on the button in the code below, I will have twice the value "1" returned in the mock function.
This limits a lot possible tests.
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>Vous avez cliqué {count} fois</p>
<button id="count-up" onClick={() => setCount(count + 1)}>
Cliquez ici
</button>
</div>
);
}
here's my test code :
import React, { useState as useStateMock, setState } from 'react';
import { shallow, mount, render } from 'enzyme';
import Example from './file with example component'
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
}));
describe('<Home />', () => {
let wrapper;
const setState = jest.fn();
beforeEach(async () => {
useStateMock.mockImplementation(init => [init, setState]);
wrapper = mount(<Example />)
});
describe('Count Up', () => {
it('calls setCount with count + 1', () => {
wrapper.find('#count-up').first().simulate('click');
expect(setState).toHaveBeenLastCalledWith(1);
wrapper.find('#count-up').simulate('click');
expect(setState).toHaveBeenLastCalledWith(2);
});
});
})
I would like the setState mock function to return 1 then 2 as its corresponds to the initial state 0 + 1 and then this results 1 + 1 again.
But the functional component is not updated, and I don't know how to do that.
Thanks for your help
Because you mock useState without providing an implementation, the functionality of useState has changed.
Don't mock the react module and its implementations. Continue to use their original implementation. You should test the component behavior instead of the implementation detail.
In other words, we want to test from the user's point of view, they don't care about your implementation, they just care about what they can see on the screen.
Component behavior is: What does your component render when the state changes.
So the unit test should be:
Example.tsx:
import React from 'react';
import { useState } from 'react';
export function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>Vous avez cliqué {count} fois</p>
<button id="count-up" onClick={() => setCount(count + 1)}>
Cliquez ici
</button>
</div>
);
}
Example.test.tsx:
import { mount } from 'enzyme';
import React from 'react';
import { Example } from './Example';
describe('70585877', () => {
test('should pass', () => {
const wrapper = mount(<Example />);
const button = wrapper.find('#count-up');
// The initial state of the component as seen by the user
expect(wrapper.find('p').text()).toEqual('Vous avez cliqué 0 fois');
// User click the button
button.simulate('click');
// The next state of the component as seen by the user
expect(wrapper.find('p').text()).toEqual('Vous avez cliqué 1 fois');
// User click the button again
button.simulate('click');
// The next state of the component as seen by the user
expect(wrapper.find('p').text()).toEqual('Vous avez cliqué 2 fois');
});
});
Test result:
PASS examples/70585877/Example.test.tsx (9.532 s)
70585877
✓ should pass (34 ms)
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
Example.tsx | 100 | 100 | 100 | 100 |
-------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 10.519 s

Resources