Sorry if that's not the best place to ask but I haven't found any good examples in the Docs or Issues here.
I created a custom react hook that is supposed to handle api requests, which I store in the redux state. Hook works fine but I have trouble testing it. My test setup is jest and enzyme, but I decided to give a try react-hooks-testing-library/react-hooks here as well.
The main issue is that I'm not sure how to test that setLoadProductsOperation was called with the result of dispatch. Seems like what I have access to is just setLoadProductsOperation that is returned. Any ideas ❓
This is how my hook looks like.
import { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import {
clearSorts,
loadPrices,
loadProducts,
} from '../../../products/actions.js';
export default (loadingTargetTenantPromise, tenantId) => {
const dispatch = useDispatch();
const [loadProductsOperation, setLoadProductsOperation] = useState(null);
useEffect(() => {
const fetchProducts = async () => {
await loadingTargetTenantPromise();
setLoadProductsOperation(dispatch(loadProducts({ tenantId })));
dispatch(loadPrices({ tenantId }));
};
fetchProducts();
return () => {
dispatch(clearSorts());
};
}, [dispatch, loadingTargetTenantPromise, tenantId]);
return [loadProductsOperation, setLoadProductsOperation];
};
Here are tests
import { cleanup, renderHook, act } from '@testing-library/react-hooks';
import { useDispatch } from 'react-redux';
import {
clearSorts,
loadPrices,
loadProducts,
} from '../../../products/actions.js';
import useLoadProducts from './index.js';
import { useEffect, useState } from 'react';
jest.mock('../../../products/actions.js');
describe('use load products', () => {
let loadingTargetTenantPromise;
let promise;
beforeEach(() => {
promise = Promise.resolve({ id: 'some-target-tenant' });
loadingTargetTenantPromise = jest.fn(() => promise);
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls setLoadProductsOperation with the result of dispatch when target tenant is loaded', async () => {
let useState = jest.fn();
let loadProductsOperation = Promise.resolve('derp');
let setLoadProductsOperation = jest.fn();
useState.mockReturnValue([loadProductsOperation, setLoadProductsOperation]);
const dispatch = jest.fn();
useDispatch.mockReturnValue(dispatch);
dispatch.mockReturnValue('derp');
const { result } = render(loadingTargetTenantPromise, 'some-target-tenant');
const [returnedLoadProductsOperation, returnedSetLoadProductsOperation] = result.current;
expect(returnedSetLoadProductsOperation).not.toHaveBeenCalled();
await act(() => promise);
expect(returnedSetLoadProductsOperation).toHaveBeenCalledWith('derp');
});
const render = (...args) => {
return renderHook(() => useLoadProducts(...args));
};
});
Gives me Matcher error: received value must be a mock or spy function. Received has type: function. Received has value: [Function bound dispatchAction]
Hi @elenajdanova,
There's quite a bit going here so it's a bit hard to pin down precisely where it's going wrong for you, so I'll just try to point out a few things and hopefully that helps a bit. For what it's worth, there is also a discord server were you can get support for this type of thing, which might give you faster turn around and more people to help. Anyway:
useDispatch probably needs a Provider to provide the store via context to dispatch into. You can use the wrapper feature to provide this.useState mock in your test, but not providing it in any way to your hook to use. In general, I would advise against mocking any of React's hooks and instead mock what it is using them for, in this case dispatch from the store (see the first point)toHaveBeenCalledWith will only work on a mock function. returnedSetLoadProductsOperation is the real useState setter that React is creating inside your hook (see the second point).returnedLoadProductsOperation and returnedSetLoadProductsOperation from result.current in your test. Don't do this. It will lock their values to the first returned versions (probably null for returnedLoadProductsOperation) and won't update them as the hook rerenders. Just access them as result.current[0] and result.current[1] instead. The array access makes this a bit ugly, so consider if you actually need to return an array or if an object with named keys would work instead (that's up to you and your requirements, not me).In general, it feels like you are trying to test way too many of the implementation details of your hook, instead of focussing on the inputs and outputs. This will often lead to a hard to maintain test suite where every single change involves a subsequent change to the tests as well, instead of just ensuring the expected outputs are still received for the given inputs. Ideally, your test would look something like (_completely untested_):
describe('use load products', () => {
it('should load products for target tenant', async () => {
const store = {
dispatch = jest.fn()
};
store.dispatch.mockReturnValue('derp');
const loadingTargetTenantPromise = () => Promise.resolve({ id: 'some-target-tenant' });
const wrapper = ({ children }) => <Provider store={store}>{children}</Provider>
const { result, waitForValueToChange } = renderHook(() => useLoadProducts(loadingTargetTenantPromise, 'some-target-tenant'), {
wrapper
})
await waitForValueToChange(() => result.current[0]);
expect(result.current[0]).toBe('derp');
});
});
This test lets the hook run as much of the real code as possible and makes fewer assumptions on the internals of the hook.
Good luck, and let me know if you want any clarification on any of the above.
@mpeyper Thanks a bunch! Your answer was very detailed and insightful for me! Especially points 3 and 4
I don't really like to have <Provider> in my tests but I tried to go this path, even tried to use redux-mock-store for that.
Anyways with mock-store or without it, test you kindly suggested still gives me result.current[0]: undefined
Edit: sorry I was editing my comment a lot. Yes, now I have : Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.Error: err
I'll try your suggestions and will update you
I'm afraid because of useDispatch expecting to find a store in context, you sort of have to provide it something. The other alternative bis to use jest's import mocking to provide your own useDispatch, something like
const dispatch = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: () => dispatch
}));
_Note: I'm not super familiar with module mocking in jest, so I might have this a bit wrong too._
As for why the test would be failing like that, I probably messed up the mocking if dispatch. Based on the code you shared, the initial value for loadProductsOperation is null and we're waiting for it it to change, which it does, but become undefined, so setLoadProductsOperation(dispatch(loadProducts({ tenantId }))) (the only time you call the setter) must not be doing what we think it's doing, and returning undefined is the default for jest.fn(), so it stands to reason it's the dispatch that is returning it.
Try pulling parts of that line into seperate variables and using a debugger or console.log to see what each step to doing.
Edit: between me reading and me posting, your comment was edited to change the issue to being an async timeout. I'll need to consume that now and get back to you again.
Finally working version for googling people:
it('should load products for target tenant', async () => {
const dispatch = jest.fn();
useDispatch.mockReturnValue(dispatch);
dispatch.mockReturnValue('derp');
const loadingTargetTenantPromise = () => Promise.resolve({ id: 'some-target-tenant' });
const { result, waitForValueToChange } = renderHook(
() => useLoadProducts(loadingTargetTenantPromise, 'some-target-tenant')
)
await waitForValueToChange(() => result.current.loadProductsOperation);
expect(result.current.loadProductsOperation).toBe('derp');
});
@mpeyper Thanks for the help!
How did you replace useDispatch with the mock function so the import in your hook file gets the right one?
right! Totally forgot I have manual mocks in separate file
export * from 'react-redux-test';
export const useDispatch = jest.fn(() => () => {});
This is specific for Jest, not sure about other frameworks - https://jestjs.io/docs/en/manual-mocks
Awesome. Seems like a fair tradeoff to remove the redux store dependency from your test. Glad you've got a green test now 😃
Most helpful comment
Finally working version for googling people:
@mpeyper Thanks for the help!