Avatar

👋🏻, I'm Julia.

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!

© 2016-2024 Julia Tan · Powered by Next JS.