I'm testing a custom hook with react-testing-library which basically does this:
function useHook() {
const [state, setState] = useState();
const fetch = async () => {
const response = await httpCall();
if (instanceof response !== Error) {
setState("GOOD")
} else {
setState("BAD")
}
}
return { state, fetch }
}
and my test file is something like this:
it("test", async () => {
const { result, waitForNextUpdate } = renderHooks(() => useHook())
await result.current.fetch();
expect(result.current.state).toBe(undefined)
await waitForNextUpdate();
expect(result.current.state).toBe("GOOD") //or at least "BAD"
})
I wrote this because I called the async function fetch() that should trigger the setState, I assert that no rerender has been occurred yet, and then I waitForNextUpdate() in order to wait this rerender and I assert that the state returned by the hooks has now a value "GOOD" or "BAD".
My problem is that my test gives me an error: Timeout - Async callback was not invoked within the 5000 ms ..., and this error occurred when the test waits for the waitForNextUpdate().
I don't know what's wrong with my test. I'm sure (because i tested it) that the hook is working properly, the http call has been made. I know that checking values inside the test but also because the hook is working properly inside the application.
I don't understand why it seems that the update of the state never occures.
I'm the first one of my team who is testing with this tool so i'm quite lost.
First of all you have small mistake in if statment of the hook, so let's correct that and also add import of httpCall function for example sake
import { useState } from 'react'
import { httpCall } from './httpCall'
export function useHook() {
const [state, setState] = useState<string>()
const fetch = async () => {
const response = await httpCall()
if (response instanceof Error) {
setState('GOOD')
} else {
setState('BAD')
}
}
return { state, fetch }
}
now we can have two test cases, based on mocking httpCall results. Note that rerender is used instead of waitForNextUpdate
import { waitFor } from '#testing-library/react'
import { renderHook } from '#testing-library/react-hooks'
import { httpCall } from './httpCall'
import { useHook } from './useHook'
jest.mock('./httpCall', () => ({
httpCall: jest.fn()
}))
describe('useHook', () => {
it('should return error', async () => {
// httpCall was mocked above, so we can replace it's implementation
httpCall.mockImplementation(() => new Error('error'))
const { result, rerender } = renderHook(() => useHook())
await result.current.fetch()
rerender()
await waitFor(() => {
expect(result.current.state).toBe('BAD')
})
})
it('should return response', async () => {
httpCall.mockImplementation(() => ({
ok: true
}))
const { result, rerender } = renderHook(() => useHook())
await result.current.fetch()
rerender()
await waitFor(() => {
expect(result.current.state).toBe('GOOD')
})
})
})
You should not use await when calling the function under test of your hook.
It should instead be wrapped in act(), though as specified in the documentation about async testing it can be omitted when await waitForNextUpdate() is called.
The test passes with this change :
it('test', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHook());
act(() => {
result.current.fetch();
});
expect(result.current.state).toBe(undefined);
await waitForNextUpdate();
expect(result.current.state).toBe('GOOD'); //or at least "BAD"
});
Related
I am trying unsuccessfully to mock a Promise:
utils.js
export const fetching = () => {
const data = [1,2,3];
return new Promise(resolve=>resolve(data));
}
MyComponent
export function MyComponent() {
...
useEffect(()=>{
async function callback() {
const fetchedData = await fetching();
}
callback();
}, []);
return (
...
);
}
MyTest
import * as myStuff from ...;
jest.spyOn(myStuff, 'fetching').mockImplementation(()=>Promise.resolve([5]))
test('should ...', async () => {
// given
render(<MyComponent/>);
});
What am I getting as fetchedData by running the above test is undefined instead of [5]. Any idea why?
You're using the call in useEffect which means it's triggering after the component is actually mounted. Your test is async, but you're not advancing timers. Try:
jest.useFakeTimers()
const fetchSpy = jest.spyOn(myStuff, 'fetching').mockImplementation(()=>Promise.resolve([5]))
test('should ...', async () => {
// given
render(<MyComponent/>);
// here, we essentially push time forward, after useEffect has been triggered by the mounted component
act(() => {
jest.advanceTimersByTime(1000)
})
expect(fetchSpy).toBeCalled()
});
I have a component that handles the login part of my app and I would like to test it's loading state.
Basically it does the following:
press a button to login
disable the button
make the request
enable back the button
What is the best way to mock AuthStore.login in this case to be able to test when isLoading is true and when it get back to false ?
I tried mocking with
const loginSpy = jest.spyOn(AuthStore, 'login').mockResolvedValueOnce({ success: true })
but then it returns immediately and I'm not able to test when the button should be disabled.
Here is a sample of the code
const Login = function(){
const [isLoading, setIsLoading] = useState(false)
async function onPressGoogleLogin() {
setIsLoading(true)
const {
success,
error
} = await AuthStore.login()
setIsLoading(false)
}
return ...
}
My test using #testing-library/react-native looks like this.
it.only('testing login', async () => {
const { getByA11yHint } = render(<LoginScreen componentId="id-1" />)
const btn = getByA11yHint('login')
expect(btn).not.toBeDisabled()
await act(async () => {
await fireEvent.press(btn)
})
expect(btn).toBeDisabled()
expect(within(btn).getByTestId('loader')).toBeTruthy()
})
Any ideas ?
Test 1
Mock AuthStore.login() to not return a resolving promise. Mock it to return a pending promise. Inquire about the state of things when the button is pressed.
Test 2
Then, in a separate test, mock AuthStore.login() to return a resolving promise. Inquire about the state of things when the button is pressed.
Test 3
Bonus points: in a separate test, mock AuthStore.login() to return a rejecting promise. Inquire about the state of things when the button is pressed.
So I managed to get it to work in one test (with Jest 27)
import { act, fireEvent, render } from '#testing-library/react-native'
import React, { useCallback, useState } from 'react'
import { Pressable } from 'react-native'
const Utils = {
sleep: (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
const Component = function () {
const [refreshing, setRefresh] = useState(false)
const refresh = useCallback(async () => {
setRefresh(true)
await Utils.sleep(5000)
setRefresh(false)
}, [])
return <Pressable testID="btn" onPress={refresh} disabled={refreshing} />
}
const sleepSpy = jest.spyOn(Utils, 'sleep').mockImplementation(() => {
return new Promise((resolve) => {
process.nextTick(resolve)
})
})
describe('Test', () => {
it('test', async () => {
const { getByTestId } = render(<Component />)
const btn = getByTestId('btn')
expect(btn.props.accessibilityState.disabled).toBeFalsy()
let f = fireEvent.press(btn)
expect(sleepSpy).toBeCalledTimes(1)
expect(btn.props.accessibilityState.disabled).toBeTruthy()
await act(async () => {
await f
})
expect(btn.props.accessibilityState.disabled).toBeFalsy()
})
})
I'm having trouble understanding how to write a test for a hook without the following warning when using renderHook from "#testing-library/react-hooks".
"Warning: An update to TestHook inside a test was not wrapped in act(...)."
Basically the hook sets initial value in state using useState and then within a useEffect hook I do something asynchronously which ends up updating the state value.
import React from "react";
// fake request
const fetchData = () => Promise.resolve("data");
export const useGetData = () => {
const initialData = { state: "loading" };
const [data, setData] = React.useState(initialData);
React.useEffect(() => {
fetchData()
.then(() => setData({ state: "loaded" }));
}, []);
return data;
};
The hook simply returns the state value at all times.. so I've written a test to assert that it returns the initial value at first and eventually returns the new state value.
import { renderHook } from "#testing-library/react-hooks";
import { useGetData } from "./useGetData";
describe("useGetData", async () => {
it('Should initially return an object with state as "loading"', () => {
const { result } = renderHook(() => useGetData());
expect(result.current).toEqual({ state: "loading" });
});
it('Should eventually return an object with state as "loaded"', async () => {
const { result, waitForNextUpdate } = renderHook(() => useGetData());
await waitForNextUpdate();
expect(result.current).toEqual({ state: "loaded" });
});
});
I've created a sandbox that replicates this:
https://codesandbox.io/s/dazzling-faraday-ht4cd?file=/src/useGetData.test.ts
I've looked into what this warning means and what act is.. but for this particular scenario I'm not sure whats missing.
You can fix it by doing this:
await act(async () => {
await waitForNextUpdate();
});
You need to wrap any function that's going to update the state by the act function
I have a React component using hooks like this:
const myComponent = (props) => {
useEffect(() => {
FetchData()
.then(data => {
setState({data: data});
}
// some other code
}, []);
//some other code and render method...
}
fetchData is in charge to use axios and get the data from an API:
const FetchData = async () => {
try {
res = await myApiClient.get('/myEndpoint);
} catch (err) {
console.log('error in FetchData');
res = err.response
}
}
and finally myApiClient is defined externally. I had to use this setup in order to be able to use different APIs...
import axios from "axios";
axios.defaults.headers.post["Content-Type"] = "application/json";
const myApiClient = axios.create({
baseURL: process.env.REACT_APP_API1_BASEURL
});
const anotherApiClient = axios.create({
baseURL: process.env.REACT_APP_API2_BASEURL
});
export {
myApiClient,
anotherApiClient
};
with this setup I am getting the warning
Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
I googled a bit and I saw some suggestions on how to clean up requests from useEffect, like this, but my axios is defined externally. So how can I send the cancellation using this setup?
Also, the application is using redux, not sure if it is in some way involved.
Any other suggestion to avoid the error is welcome.
You can use defer from rxjs for this:
const FetchData = () => {
try {
return myApiClient.get("/myEndpoint");
} catch (err) {
console.log("error in FetchData");
return err.response;
}
};
const myComponent = (props) => {
useEffect(() => {
const subscription = defer(FetchData()).subscribe({
next: ({
data
}) => {
setState({
data: data
});
},
error: () => {
// error handling
},
complete: () => {
// cancel loading state etc
}
});
return () => subscription.unsubscribe();
}, []);
}
Alway check if you are dealing with fetch or any long operations.
let _isMounted = false;
const HooksFunction = props => {
const [data, setData] = useState({}); // data supposed to be object
const fetchData = async ()=> {
const res = await myApiClient.get('/myEndpoint');
if(_isMounted) setData(res.data); // res.data supposed to return an object
}
useEffect(()=> {
_isMounted = true;
return ()=> {
_isMounted = false;
}
},[]);
return (
<div>
{/*....*/}
<div/>
);
}
When testing an async react hook with #testing-library/react-hooks I see an error message. The error message mentions wrapping code in act(...) but I'm not sure where I should do this.
I have tried to wrap parts of the code in act(...) but each attempt leads to the test failing.
// day.js
import { useState, useEffect } from 'react';
import { getDay } from '../api/day';
export function useDay() {
const [state, set] = useState({ loading: false });
useEffect(() => {
let canSet = true;
set({ loading: true });
const setDay = async () => {
const day = await getDay();
if (canSet) {
set(day);
}
};
setDay();
return () => (canSet = false);
}, []);
return state;
}
// day.test.js
import { renderHook, act } from "#testing-library/react-hooks";
import { useDay } from "./day";
jest.mock("../api/day", () => ({
getDay: jest.fn().mockReturnValue({ some: "value" })
}));
describe.only("model/day", () => {
it("returns data", async () => {
const { result, waitForNextUpdate } = renderHook(() => useDay());
await waitForNextUpdate();
expect(result.current).toEqual({ some: "value" });
});
});
// test output
console.error node_modules/react-test-renderer/cjs/react-test-renderer.development.js:102
Warning: An update to TestHook inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This is a known issue: https://github.com/testing-library/react-testing-library/issues/281
Before 16.9.0-alpha.0 React itself didn't handle the async stuff pretty good, so that has nothing to do with the testing library, really. Read the comments of the issue if you're interested in that.
You have two options now:
Update your React (& react-dom) to 16.9.0-alpha.0
Add a snippet (e. g. in your test setup file) to suppress that warning when console.log tries to print it:
// FIXME Remove when we upgrade to React >= 16.9
const originalConsoleError = console.error;
console.error = (...args) => {
if (/Warning.*not wrapped in act/.test(args[0])) {
return;
}
originalConsoleError(...args);
};