I have some code, in a hook, to detect whether the browser is online / offline:
export function useConnectivity() {
const [isOnline, setNetwork] = useState(window.navigator.onLine);
const updateNetwork = () => {
setNetwork(window.navigator.onLine);
};
useEffect(() => {
window.addEventListener('offline', updateNetwork);
window.addEventListener('online', updateNetwork);
return () => {
window.removeEventListener('offline', updateNetwork);
window.removeEventListener('online', updateNetwork);
};
});
return isOnline;
}
I have this basic test:
test('hook should detect offline state', () => {
let internetState = jest.spyOn(window.navigator, 'onLine', 'get');
internetState.mockReturnValue(false);
const { result } = renderHook(() => useConnectivity());
expect(result.current.valueOf()).toBe(false);
});
However, I want to run a test to see whether it returns the correct value when an offline event is triggered, not just after the mocking of the returned value on render. What is the best way to approach this? Where I have got so far is this:
test('hook should detect offline state then online state', async () => {
const { result, waitForNextUpdate } = renderHook(() => useConnectivity());
act(() => {
const goOffline = new window.Event('offline');
window.dispatchEvent(goOffline);
});
await waitForNextUpdate();
expect(result.current).toBe(false);
});
I'm not sure about 'best', but this is one way: change the mock response halfway through the test, and tweak some of the async code:
test('hook should detect online state then offline state', async () => {
const onLineSpy = jest.spyOn(window.navigator, 'onLine', 'get');
// Pretend we're initially online:
onLineSpy.mockReturnValue(true);
const { result, waitForNextUpdate } = renderHook(() => useConnectivity());
await act(async () => {
const goOffline = new window.Event('offline');
// Pretend we're offline:
onLineSpy.mockReturnValue(false);
window.dispatchEvent(goOffline);
await waitForNextUpdate();
});
expect(result.current).toBe(false);
});
Related
I have the following hook:
import { useEffect, useRef, useState } from "react";
function useAsyncExample() {
const isMountedRef = useRef(false);
const [hasFetchedGoogle, setHasFetchedGoogle] = useState(false);
useEffect(() => {
if (!isMountedRef.current) {
isMountedRef.current = true;
const asyncWrapper = async () => {
await fetch("https://google.com");
setHasFetchedGoogle(true);
};
asyncWrapper();
}
}, []);
return hasFetchedGoogle;
}
With the following jest test (using msw and react-hooks testing library):
import { act, renderHook } from "#testing-library/react-hooks";
import { rest } from "msw";
import mswServer from "mswServer";
import useAsyncExample from "./useAsyncExample";
jest.useFakeTimers();
describe("using async hook", () => {
beforeEach(() =>
mswServer.use(
rest.get("https://google.com/", (req, res, ctx) => {
return res(ctx.json({ success: ":)" }));
})
)
);
test("should should return true", async () => {
const { result, waitFor, waitForNextUpdate, waitForValueToChange } = renderHook(() => useAsyncExample());
// ... things I tried
});
});
And I am simply trying to wait for the setHasFetchedGoogle call.
I tried multiple things:
await waitForNextUpdate(); // failed: exceeded timeout of max 5000 ms
await waitForValueToChange(() => result.current[1]); // failed: exceeded timeout of max 5000 ms
await waitFor(() => result.current[1]) // failed: exceeded timeout of max 5000 ms
The closest I have come so far is the with the following:
const spy = jest.spyOn(global, "fetch");
// ...
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
expect(spy).toHaveBeenLastCalledWith("https://google.com");
But even this ends right before the setHasFetchedGoogle call happens, since it only await the fetch.
Online I found plenty of examples for component, where you can waitFor an element or text to appear. But this is not possible with hooks, since I am not rendering any DOM elements.
How can I listen to internal async logic of my hook? I though the waitForNextUpdate has exactly that purpose, but it doesn't work for me.
Any help is appreciated!
Actually it turns out my example works as I wanted it to.
The problem is that in the real-world case I have, the custom hook is more complex and has other logic inside which uses setTimeouts. Therefore I had jest.useFakeTimers enabled.
Apparently jest.useFakeTimers doesn't work together with waitForNextUpdate.
See more info
I will now try to get my tests to work by enabling/disabling the fakeTimers when I need them
As you said in your answer, you are using jest.useFakeTimers, but you are incorrect to say it doesn't work with waitForNextUpdate because it does.
Here is an example. I've modified your request to google to simply be an asynchronous event by waiting for two seconds. Everything should be the same with an actual mocked request though.
const wait = (delay: number) => new Promise((resolve) => setTimeout(resolve, delay))
function useAsyncExample() {
const isMountedRef = useRef(false);
const [hasFetchedGoogle, setHasFetchedGoogle] = useState(false);
useEffect(() => {
if (!isMountedRef.current) {
isMountedRef.current = true;
const asyncWrapper = async () => {
await wait(200);
setHasFetchedGoogle(true);
};
asyncWrapper();
}
}, []);
return hasFetchedGoogle;
}
// The test, which assumes a call to jest.useFakeTimers occurred in some beforeEach.
it('should should return true', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsyncExample())
expect(result.current).toBe(false)
act(() => {
jest.advanceTimersByTime(200)
})
await waitForNextUpdate()
expect(result.current).toBe(true)
})
I want to be able to make a dispatch only if a document in Firebase gets updated, I have two categorised error at hand, a Firebase error, and no connection error, in other words, how to know for sure that the updateDoc() has passed so I can make the dispatch to change the current state according to that update. If not, of course show an error on the UI, the issue here is I am passing the dispatch after the await function. If I were using mongo I would handle this in the response.
const handelColor = (e: React.FormEvent<HTMLInputElement>): void => {
const updateData = async () => {
const docRefCol = doc(db, 'collection', currentUser.uid);
throw 'simulated no premission error';
await updateDoc(docRefCol, { customColor: 'red' });
};
throw 'simulated no connection error';
updateData().catch((error) => {
const errorCode = error.code;
alert(errorCode);
});
throw 'simulated faild updated data erorr';
alert('dispatch to local state');
//dispatch(addColor({ select: 'customColor', value: colors[e.currentTarget.value] }));
};
I did come up with the following solution but still I think I still can do better.
const updateData = async () => {
if (!window.navigator.onLine) {
throw { code: 'Please check your connection' };
}
const docRefCol = doc(db, 'collection', currentUser.uid);
await await updateDoc(docRefCol, { customColor: 'red' });
dispatch(addColor({ select: 'customColor', value: colors[e.currentTarget.value] }));
};
updateData().catch((error) => {
const errorCode = error.code;
alert(errorCode);
});
You can try to follow this code (untested) to avoid redundancy in calling your updateData:
const updateData = async () => {
if (!window.navigator.onLine) {
throw { code: 'Please check your connection' };
}
const docRefCol = doc(db, 'collection', currentUser.uid);
await updateDoc(docRefCol, { customColor: 'red' })
.then(() => {
dispatch(addColor({ select: 'customColor', value: colors[e.currentTarget.value] }));
})
.catch((error) => {
alert(error);
});
};
For more reference, you can check this related StackOverflow post that also discusses what you're trying to achieve.
I wanna add multiple photo to db by Array.map() and after that add Array with url storage to collection.
I have problem with async function, because i should wait for this function await addImages() but something is not good.
const addImages = async () => {
image.map(async (imagePhoto) => {
const childPath = `post/${firebase.auth().currentUser.uid}/${Math.random().toString(36)}`;
const response = await fetch(imagePhoto);
const blob = await response.blob();
const task = firebase
.storage()
.ref()
.child(childPath)
.put(blob);
const taskProgress = snapshot => {
console.log(`transferred: ${snapshot.bytesTransferred}`)
}
const taskCompleted = () => {
task.snapshot.ref.getDownloadURL().then((snapshot) => {
imageDB.push(snapshot)
})
}
const taskError = snapshot => {
console.log(snapshot)
}
task.on("state_changed", taskProgress, taskError, taskCompleted);
})
}
const addToDbServices = async () => {
await addImages();
firebase.firestore().collection("services")
.doc(firebase.auth().currentUser.uid)
.collection("userServices")
.add({
nameService,
errorCode,
description,
imageDB,
status,
creation: firebase.firestore.FieldValue.serverTimestamp()
}).then(() => {
Alert.alert('Serwis', 'Twoje zgłoszenie zostało pomyślnie dodane'),
navigation.goBack()
})
}
image.map(async (imagePhoto) => {...})
This creates an array of promises. These are executed but not awaited by default, so code execution continues regardless whether the operations are finished or not. If you want to await all these promises you can use Promis.all() like that:
const addImages = async () => {
const pendingOperations = image.map(async (imagePhoto) => {...});
// wait until all images are processed
return Promise.all(pendingOperations); // or await Promise.all(pendingOperations);
}
const addToDbServices = async () => {
await addImages();
...
}
Why below unit test case is failing? I have a similar situation in real-world, but here, I am testing a simplified version of it.
I am expecting that assertions should execute after act complete the state update of hook, but its not behaving that way. Though state is getting update, after assertions executes.
Please suggest if there is any other way to test such situation.
Code
const HookForTest = () => {
const [data, setData] = useState('');
const updateDataFromOutside = toData => {
setData(toData);
};
return [data, updateDataFromOutside];
};
Unit Test
describe('HookForTest', () => {
test('should change state on calling updateDataFromOutside function ', async () => {
let hookData;
testHook(() => {
hookData = HookForTest();
});
let [data, updateDataFromOutside] = hookData;
await act(async () => {
updateDataFromOutside('testData');
});
expect(data).toEqual('testData');
});
});
I am using a few utility functions for testing custom hook, below is code:
export const TestHook = ({callback}) => {
callback();
return null;
};
export const testHook = callback => {
mount(<TestHook callback={callback} />);
};
Test Result
● HookForTest › should change state on calling updateDataFromOutside function
expect(received).toEqual(expected) // deep equality
Expected: "testData"
Received: ""
178 | });
179 |
> 180 | expect(data).toEqual('testData');
The problem here is that let [data, updateDataFromOutside] = hookData; locks the value of data to be what its original value was. Even if something like hookData[0] = 'something else' was called, the value of data would still be ''.
Changing the test to something like this should work
describe('HookForTest', () => {
test('should change state on calling updateDataFromOutside function ', async () => {
let data;
let updateDataFromOutside;
testHook(() => {
let hookData = HookForTest();
data = hookData[0]
updateDataFromOutside = hookData[1]
});
await act(async () => {
updateDataFromOutside('testData');
});
expect(data).toEqual('testData');
});
});
I'm trying to test the following scenario:
A user with an expired token tries to access a resource he is not authorized
The resources returns a 401 error
The application updates a global state "isExpiredSession" to true
For this, I have 2 providers:
The authentication provider, with the global authentication state
The one responsible to fetch the resource
There are custom hooks for both, exposing shared logic of these components, i.e: fetchResource/expireSesssion
When the resource fetched returns a 401 status, it sets the isExpiredSession value in the authentication provider, through the sharing of a setState method.
AuthenticationContext.js
import React, { createContext, useState } from 'react';
const AuthenticationContext = createContext([{}, () => {}]);
const initialState = {
userInfo: null,
errorMessage: null,
isExpiredSession: false,
};
const AuthenticationProvider = ({ authStateTest, children }) => {
const [authState, setAuthState] = useState(initialState);
return (
<AuthenticationContext.Provider value={[authStateTest || authState, setAuthState]}>
{ children }
</AuthenticationContext.Provider>);
};
export { AuthenticationContext, AuthenticationProvider, initialState };
useAuthentication.js
import { AuthenticationContext, initialState } from './AuthenticationContext';
const useAuthentication = () => {
const [authState, setAuthState] = useContext(AuthenticationContext);
...
const expireSession = () => {
setAuthState({
...authState,
isExpiredSession: true,
});
};
...
return { expireSession };
}
ResourceContext.js is similar to the authentication, exposing a Provider
And the useResource.js has something like this:
const useResource = () => {
const [resourceState, setResourceState] = useContext(ResourceContext);
const [authState, setAuthState] = useContext(AuthenticationContext);
const { expireSession } = useAuthentication();
const getResource = () => {
const { values } = resourceState;
const { userInfo } = authState;
return MyService.fetchResource(userInfo.token)
.then((result) => {
if (result.ok) {
result.json()
.then((json) => {
setResourceState({
...resourceState,
values: json,
});
})
.catch((error) => {
setErrorMessage(`Error decoding response: ${error.message}`);
});
} else {
const errorMessage = result.status === 401 ?
'Your session is expired, please login again' :
'Error retrieving earnings';
setErrorMessage(errorMessage);
expireSession();
}
})
.catch((error) => {
setErrorMessage(error.message);
});
};
...
Then, on my tests, using react-hooks-testing-library I do the following:
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
act(() => result.current.getResource());
await waitForNextUpdate();
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
// Here is the issue, how to test the global value of the Authentication context? the line below, of course, doesn't work
expect(result.current.isExpiredSession).toBeTruthy();
});
I have tried a few solutions:
Rendering the useAuthentication on the tests as well, however, the changes made by the Resource doesn't seem to reflect on it.
Exposing the isExpiredSession variable through the Resource hook, i.e:
return {
...
isExpiredSession: authState.isExpiredSession,
...
};
I was expecting that by then this line would work:
expect(result.current.isExpiredSession).toBeTruthy();
But still not working and the value is still false
Any idea how can I implement a solution for this problem?
Author of react-hooks-testing-library here.
It's a bit hard without being able to run the code, but I think your issue might be the multiple state updates not batching correctly as they are not wrapped in an act call. The ability to act on async calls is in an alpha release of react (v16.9.0-alpha.0) and we have an issue tracking it as well.
So there may be 2 ways to solve it:
Update to the alpha version and a move the waitForNextUpdate into the act callback
npm install react#16.9.0-alpha.0
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
await act(async () => {
result.current.getResource();
await waitForNextUpdate();
});
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
expect(result.current.isExpiredSession).toBeTruthy();
});
Add in a second waitForNextUpdate call
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
act(() => result.current.getResource());
// await setErrorMessage to happen
await waitForNextUpdate();
// await setAuthState to happen
await waitForNextUpdate();
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
expect(result.current.isExpiredSession).toBeTruthy();
});
Your appetite for using alpha versions will likely dictate which option you go for, but, option 1 is the more "future proof". Option 2 may stop working one day once the alpha version hits a stable release.