Mocking async function jest to test transitive state - reactjs

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()
})
})

Related

React prevent re render

How should I prevent a re render when I click on onClose to close the modal.
It looks like the dispatch function: dispatch(setActiveStep(0) cause some troubles.
export default function ImportOrderModal(props: ImportOrderModalProps) {
const { open, onClose } = props
const {
orderImport: { activeStep }
} = useAppSelector((state: RootState) => state)
const steps = useImportOrderConfig()
const dispatch = useDispatch()
React.useEffect(() => {
getOrdersList()
}, [])
const onCloseModal = () => {
onClose()
// Force a re render because of activeStep value
dispatch(setActiveStep(0))
}
const getOrdersList = async () => {
const orders = //API call
dispatch(setOrdersList(orders))
}
return (
<Modal open={open} onClose={onCloseModal}>
<Stepper steps={steps} currentStepNumber={activeStep} />
<FormSteps />
</Modal>
)
}
This block is outside of your useEffect()
const getOrdersList = async () => {
const orders = //API call
dispatch(setOrdersList(orders))
}
This will cause rendering troubles.
if you're using an older version of React (<17) that doesn't enforce <React.StrictMode> you can get away with rewriting that as:
useEffect(() => {
getOrderList
.then((orders) => dispatch(setOrdersList(orders))
.catch((error) => console.error(error));
}, [dispatch]);
if you're using a newer version of React (>18) you will have to cleanup your asynchronous call in the cleanup function of your useEffect().
useEffect(() => {
// This has to be passed down to your fetch/axios call
const controller = new AbortController();
getOrderList(controller)
.then((orders) => dispatch(setOrdersList(orders))
.catch((error) => console.error(error));
return () => {
// This will abort any ongoing async call
controller.abort();
}
}, [dispatch]);
For this to make sense I will probably have to write an example of the api call for you as well, if you don't mind I'll use axios for the example but it essentially works the same-ish with .fetch.
const getOrderList = async (controller) => {
try {
const { data } = await axios.get("url", { signal: controller.signal });
return data.orders;
} catch (e) {
throw e;
}
}

WaitForNextUpdate of renderHook of react-testing-library timeout

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"
});

Mocked useHistory is not called in async event handler

Summary
I'm writing test code for my react app, but somehow, it always fails.
My app code is very simple, there is only one button, and if it's clicked, a function handleSubmit is fired.
What the handler does are
Fetching data from backend(This is async function)
Move to /complete page.
What I did
I mocked the function fetching data from API in test code
I mocked the useHistory in test code
Note
I realized that if the line that is fetching data from API is commented out, the test will pass.
Code
My main app code
import { useFetchDataFromAPI } from '#/usecase/useFetchDataFromAPI';
:
const { fetchDataFromAPI } = useFetchDataFromAPI();
:
const handleSubmit = async () => {
// If the line below is not commented out, test will fail
// const { id } = await fetchDataFromAPI();
history.push(`/complete`);
};
return (
<>
<button onClick={handleSubmit}>Button</button>
</>
My test code
:
jest.mock('#/usecase/useFetchDataFromAPI', () => ({
useFetchDataFromAPI: () => {
return { fetchDataFromAPI: jest.fn((): number => {
return 1;
})}
}
}));
const mockHistoryPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom') as any,
useHistory: () => ({
push: mockHistoryPush,
}),
}));
:
const renderApplicationWithRouterHistory = () => {
const history = createMemoryHistory();
const wrapper = render(
<Router history={history}>
<Application />
</Router>
);
return { ...wrapper, history };
};
:
describe('Test onClick handler', async () => {
test('Submit', () => {
const { getByText, getByRole } = renderApplication();
const elementSubmit = getByText('Button');
expect(elementSubmit).toBeInTheDocument();
fireEvent.click(elementSubmit);
expect(mockHistoryPush).toHaveBeenCalled();
});
});
Your event handler is called on button click, but because it is asynchronous, its result is not evaluated until after your test runs. In this particular case, you don't need the async behavior, so just use:
const handleSubmit = () => {
history.push(`/complete`)
}
testing-library provides a method waitFor for this if your handler did need to await something:
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalled())
Though another simple way is to simply await a promise in your test so that the expectation is delayed by a tick:
fireEvent.click(elementSubmit);
await Promise.resolve();
expect(mockHistoryPush).toHaveBeenCalled();

Can't perform a React state update on an unmounted component. Problem with React, redux and useEffect

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/>
);
}

React-hooks. Can't perform a React state update on an unmounted component

I get this error:
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.
when fetching of data is started and component was unmounted, but function is trying to update state of unmounted component.
What is the best way to solve this?
CodePen example.
default function Test() {
const [notSeenAmount, setNotSeenAmount] = useState(false)
useEffect(() => {
let timer = setInterval(updateNotSeenAmount, 2000)
return () => clearInterval(timer)
}, [])
async function updateNotSeenAmount() {
let data // here i fetch data
setNotSeenAmount(data) // here is problem. If component was unmounted, i get error.
}
async function anotherFunction() {
updateNotSeenAmount() //it can trigger update too
}
return <button onClick={updateNotSeenAmount}>Push me</button> //update can be triggered manually
}
The easiest solution is to use a local variable that keeps track of whether the component is mounted or not. This is a common pattern with the class based approach. Here is an example that implement it with hooks:
function Example() {
const [text, setText] = React.useState("waiting...");
React.useEffect(() => {
let isCancelled = false;
simulateSlowNetworkRequest().then(() => {
if (!isCancelled) {
setText("done!");
}
});
return () => {
isCancelled = true;
};
}, []);
return <h2>{text}</h2>;
}
Here is an alternative with useRef (see below). Note that with a list of dependencies this solution won't work. The value of the ref will stay true after the first render. In that case the first solution is more appropriate.
function Example() {
const isCancelled = React.useRef(false);
const [text, setText] = React.useState("waiting...");
React.useEffect(() => {
fetch();
return () => {
isCancelled.current = true;
};
}, []);
function fetch() {
simulateSlowNetworkRequest().then(() => {
if (!isCancelled.current) {
setText("done!");
}
});
}
return <h2>{text}</h2>;
}
You can find more information about this pattern inside this article. Here is an issue inside the React project on GitHub that showcase this solution.
If you are fetching data from axios(using hooks) and the error still occurs, just wrap the setter inside the condition
let isRendered = useRef(false);
useEffect(() => {
isRendered = true;
axios
.get("/sample/api")
.then(res => {
if (isRendered) {
setState(res.data);
}
return null;
})
.catch(err => console.log(err));
return () => {
isRendered = false;
};
}, []);
TL;DR
Here is a CodeSandBox example
The other answers work of course, I just wanted to share a solution I came up with.
I built this hook that works just like React's useState, but will only setState if the component is mounted. I find it more elegant because you don't have to mess arround with an isMounted variable in your component !
Installation :
npm install use-state-if-mounted
Usage :
const [count, setCount] = useStateIfMounted(0);
You can find more advanced documentation on the npm page of the hook.
Here is a simple solution for this. This warning is due to when we do some fetch request while that request is in the background (because some requests take some time.)and we navigate back from that screen then react cannot update the state. here is the example code for this. write this line before every state Update.
if(!isScreenMounted.current) return;
Here is Complete Example
import React , {useRef} from 'react'
import { Text,StatusBar,SafeAreaView,ScrollView, StyleSheet } from 'react-native'
import BASEURL from '../constants/BaseURL';
const SearchScreen = () => {
const isScreenMounted = useRef(true)
useEffect(() => {
return () => isScreenMounted.current = false
},[])
const ConvertFileSubmit = () => {
if(!isScreenMounted.current) return;
setUpLoading(true)
var formdata = new FormData();
var file = {
uri: `file://${route.params.selectedfiles[0].uri}`,
type:`${route.params.selectedfiles[0].minetype}`,
name:`${route.params.selectedfiles[0].displayname}`,
};
formdata.append("file",file);
fetch(`${BASEURL}/UploadFile`, {
method: 'POST',
body: formdata,
redirect: 'manual'
}).then(response => response.json())
.then(result => {
if(!isScreenMounted.current) return;
setUpLoading(false)
}).catch(error => {
console.log('error', error)
});
}
return(
<>
<StatusBar barStyle="dark-content" />
<SafeAreaView>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={styles.scrollView}>
<Text>Search Screen</Text>
</ScrollView>
</SafeAreaView>
</>
)
}
export default SearchScreen;
const styles = StyleSheet.create({
scrollView: {
backgroundColor:"red",
},
container:{
flex:1,
justifyContent:"center",
alignItems:"center"
}
})
This answer is not related to the specific question but I got the same 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. and as a React newcomer could not find a solution to it.
My problem was related to useState in an unmounted component.
I noticed that I was calling a set state function (setIsLoading) after the function that unmounted my component:
const Login = () => {
const [isLoading, setIsLoading] = useState(false);
const handleLogin = () => {
setIsLoading(true);
firebase.auth().then(
functionToUnMountLoginSection();
// the problem is here
setIsLoading(false);
)
}
}
The correct way is to call setIsLoading when the component is still mounted, before calling the function to unmount/process user login in my specific case:
firebase.auth().then(
setIsLoading(false);
functionToUnMountLoginSection();
)
You add the state related datas into the useEffect body for not rerunning them every rerendering process. This method will solve the problem.
useEffect(() => {
let timer = setInterval(updateNotSeenAmount, 2000)
return () => clearInterval(timer)
}, [notSeenAmount])
REF: Tip: Optimizing Performance by Skipping Effects
Custom Hook Solution (ReactJs/NextJs)
Create a new folder named 'shared' and add two folders named 'hooks', 'utils' in it. Add a new file called 'commonFunctions.js' inside utils folder and add the code snippet below.
export const promisify = (fn) => {
return new Promise((resolve, reject) => {
fn
.then(response => resolve(response))
.catch(error => reject(error));
});
};
Add a new file called 'fetch-hook.js' inside hooks folder and add the code snippet below.
import { useCallback, useEffect, useRef } from "react";
import { promisify } from "../utils/commonFunctions";
export const useFetch = () => {
const isUnmounted = useRef(false);
useEffect(() => {
isUnmounted.current = false;
return () => {
isUnmounted.current = true;
};
}, []);
const call = useCallback((fn, onSuccess, onError = null) => {
promisify(fn).then(response => {
console.group('useFetch Hook response', response);
if (!isUnmounted.current) {
console.log('updating state..');
onSuccess(response.data);
}
else
console.log('aborted state update!');
console.groupEnd();
}).catch(error => {
console.log("useFetch Hook error", error);
if (!isUnmounted.current)
if (onError)
onError(error);
});
}, []);
return { call }
};
Folder Structure
Our custom hook is now ready. We use it in our component like below
const OurComponent = (props) => {
//..
const [subscriptions, setSubscriptions] = useState<any>([]);
//..
const { call } = useFetch();
// example method, change with your own
const getSubscriptions = useCallback(async () => {
call(
payment.companySubscriptions(userId), // example api call, change with your own
(data) => setSubscriptions(data),
);
}, [userId]);
//..
const updateSubscriptions = useCallback(async () => {
setTimeout(async () => {
await getSubscriptions();
}, 5000);// 5 seconds delay
}, [getSubscriptions]);
//..
}
In our component, we call 'updateSubscriptions' method. It will trigger 'getSubscriptions' method in which we used our custom hook. If we try to navigate to a different page after calling updateSubscriptions method before 5 seconds over, our custom hook will abort state update and prevent that warning on the title of this question
Wanna see opposite?
Change 'getSubscriptions' method with the one below
const getSubscriptions = useCallback(async () => {
const response = await payment.companySubscriptions(userId);
setSubscriptions(response);
}, [userId]);
Now try to call 'updateSubscriptions' method and navigate to a different page before 5 seconds over
Try this custom hook:
import { useEffect, useRef } from 'react';
export const useIsMounted = () => {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => (isMounted.current = false);
}, []);
return isMounted;
};
function Example() {
const isMounted = useIsMounted();
const [text, setText] = useState();
const safeSetState = useCallback((callback, ...args) => {
if (isMounted.current) {
callback(...args);
}
}, []);
useEffect(() => {
safeSetState(setText, 'Hello')
});
}, []);
return <h2>{text}</h2>;
}

Resources