Setting Up React Native Jest Tests With Higher Order Components
#jest #react-native #typescript
6 min read
If your React Native app is anything like our app, you’ve got wrappers upon wrappers, wrapping your screens and screen components. Some examples might be:
- SafeAreaProvider - to ensure you’re only accessing a device’s safe area
- ThemeProvider - say you were using something like Styled Components to provide a theme context to your entire app
- Redux - to manage state across your app
This can make things tricky when it comes to writing your unit and integration tests, as your components might inadvertently depend on something that’s being provided by one or more of your higher order components (HoCs).
In an attempt to simplify the setup of our Jest tests, we wrote some helper functions to make it easier to tap into the HoCs we needed on a test by test basis. Making things simpler means lowering the barrier to writing more tests whilst shortening development time, so this is a major win. 🎉
Here’s an example of how it can be done in Typescript. The external packages we use are Redux Toolkit, Styled Components and React Native Safe Area Context.
// testHelpers.tsx
import * as React from 'react'
import { getDefaultMiddleware } from '@reduxjs/toolkit'
import lodash from 'lodash'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { Provider as ReduxProvider } from 'react-redux'
import renderer, { ReactTestInstance } from 'react-test-renderer'
import createMockStore from 'redux-mock-store'
import { ThemeProvider } from 'styled-components/native'
import { TRootState } from '@app/core/state/root'
import { initialState } from '@app/core/state/mockedInitialState'
import { theme } from '@app/themes'
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>
}
type TConfig = {
mockRedux?: boolean
mockSafeAreaProvider?: boolean
mockTheme?: boolean
state?: DeepPartial<TRootState>
}
const initialMetrics = {
frame: { height: 0, width: 0, x: 0, y: 0 },
insets: { bottom: 0, left: 0, right: 0, top: 0 },
}
export function createMockedElement(element: React.ReactElement, config?: TConfig) {
let mockedElement = element
if (config?.mockRedux !== false) {
const middlewares = getDefaultMiddleware()
const mockStore = createMockStore(middlewares)
const state = lodash.merge(initialState, config?.state)
const store = mockStore(state)
mockedElement = <ReduxProvider store={store}>{mockedElement}</ReduxProvider>
}
if (config?.mockTheme !== false) {
mockedElement = <ThemeProvider theme={theme}>{mockedElement}</ThemeProvider>
}
if (config?.mockSafeAreaProvider !== false) {
mockedElement = <SafeAreaProvider initialMetrics={initialMetrics}>{mockedElement}</SafeAreaProvider>
}
return mockedElement
}
export function createReactTestInstance(element: React.ReactElement, config?: TConfig): ReactTestInstance {
return renderer.create(createMockedElement(element, config)).root
}
There’s quite a lot going on here, so let’s break it down. But first, we should talk about...
How the helper functions will be used in practice
I always find it easier to first understand how we’d want to use these helper methods in the wild. I’ve therefore added an example of how we’d integrate these helpers into our tests. Note that this uses React’s Test Renderer which is useful for say, checking for the presence of expected elements.
import { createReactTestInstance } from './testHelpers'
describe('MyComponent tests', () => {
it('renders correct version for users who shown interest', () => {
const instance = createReactTestInstance(<MyComponent />)
expect(instance.findByProps({ testID: `interested-icon` })).toBeTruthy()
})
it('renders correct version for users who have not shown interest', () => {
const instance = createReactTestInstance(<MyComponent />)
expect(instance.findByProps({ testID: `not-interested-icon` })).toBeTruthy()
})
})
If you wanted to test for whether certain user actions result in specific expectations, the React Testing Library (which sits on top of React’s Test Renderer) is great for that. Rather than using our createReactTestInstance
helper, we can just tap into the createMockedElement
helper. Here’s an example.
import { fireEvent, render } from '@testing-library/react-native'
import { act } from 'react-test-renderer'
import { createMockedElement } from './testHelpers'
const navigateMock = jest
.mock
// your mock...
()
describe('BackButton tests', () => {
it('navigates to the right screen onPress', async () => {
const mockedElement = createMockedElement(<BackButton previousScreen="PreviousScreenName" />)
const renderAPI = await render(mockedElement)
await act(async () => {
const backButton = renderAPI.getByTestId('button-back-navigation')
await fireEvent.press(backButton)
expect(navigateMock).toHaveBeenCalledWith('PreviousScreenName')
})
})
})
Now that you understand how the helper functions are going to be used in practice, let’s go back to how we set up the helpers file.
Breaking how the helpers file
At the heart of this file is the createMockedElement
function.
export function createMockedElement(element: React.ReactElement, config?: TConfig) {
let mockedElement = element
if (config?.mockRedux !== false) {
const middlewares = getDefaultMiddleware()
const mockStore = createMockStore(middlewares)
const state = lodash.merge(initialState, config?.state)
const store = mockStore(state)
mockedElement = <ReduxProvider store={store}>{mockedElement}</ReduxProvider>
}
if (config?.mockTheme !== false) {
mockedElement = <ThemeProvider theme={theme}>{mockedElement}</ThemeProvider>
}
if (config?.mockSafeAreaProvider !== false) {
mockedElement = <SafeAreaProvider initialMetrics={initialMetrics}>{mockedElement}</SafeAreaProvider>
}
return mockedElement
}
This function takes two arguments - the element/component you want to test, and an optional config
object. This config object allows you to specify what wrappers to include when rendering your component during the test (if any). For example, if you need to mock the Redux state, you can set up your test in this way:
it("doesn't open the modal when row is active", async () => {
const mockedState = { show_modal: false }
const config = { state: mockedState }
const mockedElement = createMockedElement(<Row />, config)
const renderAPI = await render(mockedElement)
await act(async () => {
// ... your test expectations
})
})
You can similarly do the same if you need to include the ThemeProvider
and / or SafeAreaProvider
wrappers. As defined in TConfig
, note that these two options take boolean
inputs.
Deeper dive into setting up Redux state
When mocking the Redux state, you’ll likely need to ensure your test Redux state has been set up with some initial values. To do this, we extracted all the initial states out from our various Redux Toolkit slices and combined it into a single object, which we then passed into the lodash
merge function (to ensure it deep merges with our mocked state).
// @app/core/state/mockedInitialState
import { initialStateFeature1 } from '@covid/core/state/feature1.slice'
import { initialStateFeature2 } from '@covid/core/state/feature2.slice'
import { initialStateFeature3 } from '@covid/core/state/feature3.slice'
export const initialState: TRootState = {
feature1: initialStateFeature1,
feature2: initialStateFeature2,
feature3: initialStateFeature3,
}
And that’s it! Hopefully this makes your React Native testing life a little easier. 😄 If you’ve got any suggestions or improvements for me, do let me know - I’m always keen to up my testing game!