The release of React Navigation 5 drastically changed how navigation is structured for React Native projects. One major change with this release was the introduction of useNavigation
.
This React Hook makes it easy to access the navigation
prop from child components without needing to pass it down through props, but what does this mean for unit testing components that use this hook?
The Problem
If you’ve tried testing a component that uses the useNavigation
hook, you’ve likely seen this error message:
Couldn’t find a navigation object. Is your component inside a screen in a navigator?
This happens because the useNavigation
hook needs to retrieve the navigation
prop from a parent Screen component, and you’re most likely rendering the component in isolation. This leads to a test failure since the hook is not able to retrieve the navigation
prop.
Don’t worry. There are a handful of ways to handle this.
Solution #1: Wrapping with TestNavigationContainer
The error message tells us what the problem is. If we wrapped our component in a Screen while we were testing, it we would be able to access the navigation
prop, and our test would pass.
You could create a MockedNavigator
component as described in this post by Daria Rucka:
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
const Stack = createStackNavigator();
const MockedNavigator = ({ component, params = {} }) => {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen
name="MockedScreen"
component={ component }
initialParams={ params }
/>
</Stack.Navigator>
</NavigationContainer>
);
};
export default MockedNavigator;
The benefit of this approach is that it provides an actual navigation
prop to the component under test. Unfortunately, it also requires rendering each component that uses the useNavigation
hook in a MockedNavigator
component for every unit test. This approach also lacks the ability to assert the usage of the navigation
prop in the component.
Solution #2: Mocking useNavigation in Jest Setup File
Another option would be to mock the @react-navigation/native
package entirely. This would remove the need to wrap the component under test in a MockedNavigator
since the useNavigation
hook wouldn’t actually try to retrieve a navigation
prop from a parent Screen component.
Jest mocks can be configured in a few different ways, but I found the following steps to be fairly straightforward (assuming you’ve already followed the Testing with Jest section of the React Navigation docs).
The only change that’s required is to add this to the Jest setup file:
jest.mock("@react-navigation/native", () => {
const actualNav = jest.requireActual("@react-navigation/native");
return {
...actualNav,
useNavigation: () => ({
navigate: jest.fn(),
dispatch: jest.fn(),
}),
};
});
With this mock in place, anywhere that useNavigation
is imported from @react-navigation/native
will now receive the mocked version.
This works and eliminates the need to wrap each component with a MockedNavigator
while testing, but we’re still missing the ability to assert the usage of the navigation
prop in the component. Let’s change that.
Solution #3: Mocking useNavigation per Test File
Both of the previous solutions lacked the ability to assert how the navigation
prop is used in the component under test. What if you wanted to write a test that verifies a particular DrawerAction
is dispatched using the navigation
prop when a button is pressed? We could write an end-to-end test for this with something like Detox, but I would prefer a quicker solution that doesn’t require nearly as much setup.
In order to assert the usage of the navigation
prop, we will need to pass in our own mock functions for the navigate
or dispatch
functions. We were already using jest.fn()
in the Jest setup file, but we didn’t have a way to reference those functions to make assertions on.
With the React Navigation mock in the test file, we can initialize a variable to hold a reference to the jest.fn()
that we return from the mocked useNavigation
. Now assertions can be made about how many times the mock function was called, what parameters it was called with, and so much more.
Here’s an example testing a MenuButton
component that should dispatch the toggleDrawer
action when pressed:
import React from "react";
import MenuButton from "./MenuButton";
import { render, fireEvent } from "@testing-library/react-native";
import { DrawerActions } from "@react-navigation/native";
const mockedDispatch = jest.fn();
// Mocks like this need to be configured at the top level
// of the test file, they can't be setup inside your tests.
jest.mock("@react-navigation/native", () => {
const actualNav = jest.requireActual("@react-navigation/native");
return {
...actualNav,
useNavigation: () => ({
navigate: jest.fn(),
dispatch: mockedDispatch,
}),
};
});
describe("MenuButton", () => {
beforeEach(() => {
// Alternatively, set "clearMocks" in your Jest config to "true"
mockedDispatch.mockClear();
});
it("toggles navigation drawer on press", () => {
const { getByTestId } = render(<MenuButton />);
const button = getByTestId("menu-button");
fireEvent.press(button);
expect(mockedDispatch).toHaveBeenCalledTimes(1);
expect(mockedDispatch).toHaveBeenCalledWith(DrawerActions.toggleDrawer());
});
});
As is the case with any JavaScript project, there are plenty of ways to achieve the same result. The trick is knowing which techniques to apply at the appropriate time. I prefer the level of detail that the third solution provides, but that doesn’t mean that the other solutions aren’t also viable. Try them out on your own project and see which one works best with your workflow.
I hope this post has taught you a thing or two about testing your components that use React Navigation and about mocking JavaScript libraries in general.
Good article. However this feels like it’s testing implementation details rather than behaviour.