Thinking back on the last 4 or 5 years in frontend development, the biggest innovations that come to my mind are Redux and React Hooks. Redux has been a game changer for sharing state in tree-like component structures and React Hooks have made React twice as easy to work with.
Speaking about the latter, I’ve loved working with hooks but testing them is a whole different beast. Hooks can’t be tested like normal functions because they need to run inside of a React component. And testing them inside a React component makes it challenging to create assertions on the returned values.
Enter renderHook from React Testing Library. Here’s a basic example of what it looks like:
import { renderHook } from "@testing-library/react";
import { useMyCustomHook } from "../../../wherever";
// ...inside your test
const { result } = renderHook(() => {
return useMyCustomHook();
});
While the implementation is pretty simple, there’s a lot going on here that may not be obvious at first glance. Let’s talk about some of those things:
Render Hook – Under the Hood
First, it’s important to note that renderHook is creating a component under the hood. Why? Well, it has to. It has no idea what our custom hook might be doing but it’s guaranteed it will be using built in React hooks such as useState or useEffect.
Second, it’s taking in a callback. Why would it take in a callback instead of taking in our hook and then invoking it? Well, some hooks take in other parameters and so invoking the hook from inside renderHook would limit our flexibility.
To showcase an example of this, imagine we had a hook that took a name and returned true if that name is a character in Parks & Rec. It might look something like this:
import { renderHook } from "@testing-library/react";
import { useIsParksAndRecCharacter } from "../../../wherever";
// ...inside your test
const { result } = renderHook(() => {
return useIsParksAndRecCharacter("Michael Scott");
});
Now imagine what would happen if renderHook only took in our custom hook like this:
import { renderHook } from "@testing-library/react";
import { useIsParksAndRecCharacter } from "../../../wherever";
// ...inside your test
const { result } = renderHook(useIsParksAndRecCharacter);
We now no longer have the ability to pass variables into our custom hooks. That’s the reason they chose to have a callback which invokes the custom hook.
The last thing worth mentioning here is the destructured result that is returned from renderHook. On the returned result variable, there’s a property called current. Going back to our useIsParksAndRecCharacter hook we could create tests around it like this:
import { renderHook } from "@testing-library/react";
import { useIsParksAndRecCharacter } from "../../../wherever";
describe("useIsParksAndRecCharacter", () => {
it("should return false if the name is not a character", () => {
const { result } = renderHook(() => {
return useIsParksAndRecCharacter("Michael Scott");
});
expect(result.current).toBe(false);
});
it("should return true if the name is a character", () => {
const { result } = renderHook(() => {
return useIsParksAndRecCharacter("Tom Haverford");
});
expect(result.current).toBe(true);
});
});
Notice how we only grab the result variable from the original destructure and not current. Why do we do that? And why doesn’t React Testing Library just give us the current variable?
The reason for that is the current variable can change over time because the our hook’s returned value changes over time. React Testing Library needed a way to reflect those changes over time so they chose to use Javascript pass by reference.
The basic jist of pass by reference here is if we have access to an object and then later someone else changes a key-value pair on that object, we can access the new value through the original object.
Where might this be useful? Imagine a useIsSignedIn custom hook like this:
import React from "react";
export const useIsSignedIn = () => {
const [checkIsSignedIn, setCheckIsSignedIn] = useState(false);
const [isSignedIn, setIsSignedIn] = useState(false);
useEffect(() => {
Api.authentication.isSignedIn().then((isAuthenticated) => {
setCheckIsSignedIn(true);
setIsSignedIn(isAuthenticated);
});
}, []);
return [checkIsSignedIn, isSignedIn];
};
Now let’s pretend we want to check if the checkIsSignedIn is working correctly. Here’s how we would go about that:
import { renderHook } from "@testing-library/react";
import { useIsSignedIn } from "../useIsSignedIn";
import { Api } from "../../../someApiFile";
jest.useFakeTimers();
describe("useIsSignedIn", () => {
it("should correctly set the checkIsSignedIn", () => {
jest.spyOn(Api.authentication, "isSignedIn").mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 30);
});
});
const { result } = renderHook(() => {
return useIsSignedIn();
});
const checkIsSignedIn = result.current[0];
expect(checkIsSignedIn).toBe(false);
jest.advanceTimersByTime(30);
const checkIsSignedInTwo = result.current[0];
expect(checkIsSignedInTwo).toBe(true);
});
});
There’s a lot going on here so let’s break it down. In our Api spy, we specify that the isSignedIn call will resolve after 30 milliseconds. So when the hook first runs, we expect checkIsSignedIn to be false.
Notice though that we never run the hook again. Instead, we speed up the timer to catch up with our Api call and like magic, current is changed to reflect the new value. That’s the power of pass by reference in this case.
Complex Cases
The last thing we need to talk about as part of this section is testing hooks that rely on other providers. Let’s pretend we have the following hook:
import React from "react";
import { useSelector } from "react-redux";
export const useUserMetadata = () => {
const userMetadata = useSelector((state) => {
return state.userMetadata;
});
return userMetadata;
};
While we could spy on the useSelector and just return what we want, it can sometimes be useful to create the React tree ourselves. While we don’t have access to the rendered component, React Testing Library gives us additional options to be able to wrap the component.
import { renderHook } from "@testing-library/react";
import { useUserMetadata } from "../useUserMetadata";
import { configureStore } from "../../wherever";
describe("useUserMetadata", () => {
it("should correctly set the checkIsSignedIn", () => {
const store = configureStore({
userMetadata: {
name: "Michael Jordan",
id: "GO**AT23",
},
});
const wrapper = ({ children }: { children?: ReactNode }) => (
<ReduxProvider store={store}>{children}</ReduxProvider>
);
const { result } = renderHook(
() => {
return useUserMetadata();
},
{ wrapper }
);
const userMetadata = result.current;
const isTheGoat = userMetadata.id === "GO**AT23";
expect(isTheGoat).toBe(true);
});
});
The renderHook method takes in an optional wrapper which is a function that creates the DOM structure. In this case, we’re wrapping our hook in Redux so that it has the correct state returned from the custom hook.
Well, that wraps up the four part React Testing Library series. Hope you enjoyed it!