Now that I can write React Native, what should I test?

Oluwatomi Oluwafemi Alu

Ever since I learned to write in React Native, I’ve been pondering one of life’s great mysteries: what should I test? Now as I write this, I struggle to find metaphors to explain testing in React Native. I once fancied using a towering skyscraper as a metaphor for the different testing levels in React Native. Yet, as I mulled it over, it struck me that categorizing tests into a neat hierarchy is like trying to herd cats. When does a unit test morph into an integration test? It’s as clear as mud. Perhaps you have that figured out, but I must warn you before you go on: my thoughts ping around my head like a pinball, so this will be a bumpy ride.

React Native, for those uninitiated, is this magical cross-platform wand powered by the sorcery of the JavaScript library React, perfect for conjuring user interfaces. Imagine you’re embarking on an adventure to create a welcoming app for newbie React Native developers like yourself. You roll up your sleeves, crack your knuckles, and initiate a project.

Consider this snippet of React Native code, as simple as a LEGO brick, where users input their name to receive a personalized greeting. It’s your first tryst with React Native, and you’re eager to test this functional unit of code.

import React, { useState } from 'react';
import { View, TextInput, Button, Text } from 'react-native';

const App = () => {
  const [name, setName] = useState('');
  const [welcomeMessage, setWelcomeMessage] = useState('');

  const handlePress = () => {
    setWelcomeMessage(`Hello World, welcome ${name} to the world of React Native`);
  };

  return (
    <View>
      <TextInput
        placeholder="Enter your name"
        onChangeText={text => setName(text)}
        value={name}
      />
      <Button
        title="Submit"
        onPress={handlePress}
      />
      {welcomeMessage !== '' && <Text>{welcomeMessage}</Text>}
    </View>
  );
};

export default App;

To dive into unit testing in JavaScript, you have tools like Jest and Vitest at your disposal. Jest, born in the creative labs of Meta for React applications, is your chosen ally, abundant with online resources.

But what exactly should you test? A golden rule in testing is to mimic how the code will be used in the wild: your app collects input from users and then artfully weaves it into a greeting upon a button press.

You start with the classic react-test-renderer approach,

import React from 'react';
import renderer, { act } from 'react-test-renderer';
import App from './App'; // Adjust the import path according to your file structure

describe('App', () => {
  it('renders correctly and updates welcome message on button press', () => {
    let tree;
    act(() => {
      tree = renderer.create(<App />);
    });
    const instance = tree.root;

    // Find elements by type and props
    const TextInput = instance.findByType('TextInput');
    const Button = instance.findByProps({title: 'Submit'});

    // Simulate onChangeText for TextInput (name change)
    act(() => {
      TextInput.props.onChangeText('John Doe');
    });

    // Simulate press on Button
    act(() => {
      Button.props.onPress();
    });

    // Force a re-render
    act(() => {
      tree.update(<App />);
    });

    // After state update, the welcome message should be displayed
    const updatedText = instance.findAllByType('Text');
    expect(updatedText.length).toBe(2);
    expect(updatedText[1].props.children).toBe('Hello World, welcome John Doe to the world of React Native');
  });
});

but then you discover the React-Native-Testing-Library by the clever folks at Callstack. It’s like trading a chisel for a power drill, making your tests more concise and readable.

import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import App from './App'; // Adjust the import path according to your file structure

describe('App', () => {
  it('displays the welcome message when the submit button is pressed', async () => {
    const { getByPlaceholderText, getByText, queryByText } = render(<App />);

    // Verify initial state: no welcome message should be displayed
    expect(queryByText(/Hello World, welcome/i)).toBeNull();

    // Find the TextInput and type a name into it
    const nameInput = getByPlaceholderText('Enter your name');
    fireEvent.changeText(nameInput, 'John Doe');

    // Find the button and press it
    const button = getByRole('button', { name: 'Submit' });
    fireEvent.press(button);

    // Wait for the welcome message to be displayed
    expect(await screen.findByText('Hello World, welcome John Doe to the world of React Native'))).toBeDefined();
  });
});

Unit Testing Achieved: Time for a Cup of Tea! Alas, the world of app development is seldom a walk in the park. Simple units of code often band together, creating a symphony of interactions, navigation, and business logic. Now, you dream of expanding your app, adding features like saving the enthusiastic developer’s name to a database located on a remote server, while guiding them to a new screen with success or error feedback.

So, you venture into the realm of integration tests.

npm install --save @react-navigation/native
npm install --save react-native-screens react-native-safe-area-context
npm install --save @react-navigation/native-stack
npm install --save axios
npm install --save-dev nock

Screen 1 we’ll call HomeScreen

const HomeScreen = ({navigation}) => {
  const [name, setName] = useState('');

  const handlePress = async () => {
    try {
      const response = await axios.get('your-api-url');
      navigation.navigate('ResultScreen', {result: 'Success', name});
    } catch (error) {
      navigation.navigate('ResultScreen', {result: 'Error', name});
    }
  };

  return (
    <View>
      <TextInput
        placeholder="Enter your name"
        onChangeText={text => setName(text)}
        value={name}
      />
      <Button title="Submit" onPress={handlePress} />
    </View>
  );
};

Screen 2 we will call ResultScreen

const ResultScreen = ({route}) => {
  return (
    <View>
      <Text>
        {route.params.result} - Welcome {route.params.name}
      </Text>
    </View>
  );
};

And the navigation root component

const App = () => {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Home">
        <Stack.Screen name="HomeScreen" component={HomeScreen} />
        <Stack.Screen name="ResultScreen" component={ResultScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
};

export default App;

Integration Tests

Integration tests are like a ballet, where individual dancers (units of software) perform in harmony. The goal is to test how these units interact, especially when they involve external services or platform APIs like data persistence or job scheduling.

You now set out to test the harmonious dance between the HomeScreen and ResultScreen, along with the HTTP requests that connect them. Using React Native and axios, along with mocking techniques, you simulate scenarios of both triumph and tribulation - what happens when HTTP requests succeed, and what unfolds when they don’t.

import 'react-native';
import React from 'react';
import nock from 'nock';
import axios from 'axios';
import {render, fireEvent, waitFor} from '@testing-library/react-native';
import App from '../App';

// Make axios http calls get intercepted by nock
axios.defaults.adapter = 'http'


describe('HomeScreen Integration Test', () => {
  it('navigates to ResultScreen with success message on successful HTTP request', async () => {
    // Mock successful response
    nock('https://www.sample.com').get('/results').reply(200, {data: 'some data'});

    // Render the App component instead of just the HomeScreen to test the integration
    // This approach is crucial for integration tests to ensure that components interact correctly within the context of the entire application,
    // including navigation between screens and other side effects like API calls.
    const {getByPlaceholderText, getByText} = render(<App />);

    // Simulate user input and button press
    fireEvent.changeText(getByPlaceholderText('Enter your name'), 'John Doe');
    fireEvent.press(getByText('Submit'));

    // Wait for navigation and assert ResultScreen
    await waitFor(() => {
      expect(getByText('Success - Welcome John Doe')).toBeTruthy();
    });
  });

  it('navigates to ResultScreen with error message on failed HTTP request', async () => {
    // Mock failed response
    nock('https://www.sample.com').get('/results').replyWithError('Something went wrong');


    const {getByPlaceholderText, getByText} = render(<App />);

    // Simulate user input and button press
    fireEvent.changeText(getByPlaceholderText('Enter your name'), 'Jane Doe');
    fireEvent.press(getByText('Submit'));

    // Wait for navigation and assert ResultScreen
    await waitFor(() => {
      expect(getByText('Error - Welcome Jane Doe')).toBeTruthy();
    });
  });
});

Mocking in React Native: The Art of Pretend Play

In the React Native testing theater, Jest and React Native Testing Library play leading roles due to their lightweight, speed and efficiency, achieved by running tests in a Node environment. This approach, however, requires some sleight of hand in the form of mocking. You mimic the interfaces of code dependent on resources outside the Node environment, focusing on mocking native platform APIs like gesture handlers, animations, and network services.

Why do we mock?

Native platform APIs or modules are parts of an app that directly interact with the mobile operating system, like the camera or GPS. In tests, we often don’t need to use the real camera or real GPS data to check if our app works. Mocking these modules simplifies the test environment and avoids complexities of dealing with actual hardware or OS features. With mocking we can simulate situations such as “GPS unavailable” or “Camera permission denied” and test app behaviour in these scenarios. Accessing real device features require more processing juice, rely on the state of the device or a user granting permissions, and ultimately slow down tests. Mocks are simple, quick to execute, in tests mocks go brrr.

What should I mock?

You want to mock libraries and dependencies that implement a native platform API, e.g. Gesture Handlers, Animation, Storage, Network Services. E.g. in the code examples above we rely on some libraries with dependencies on native platforms. React Navigation modules rely on gesture handlers, animations etc… The React Navigation docs provide a way to mock this in jest. Sometimes errors encountered while running tests may point you to dependencies in your code that may need to be mocked, look out for the error logs. Whenever you can’t find a guide for mocking a dependency, you can mock the deps locally in your test file or globally in jest.setup.js.

A template for mocking in Jest could look like this


jest.mock('the-magic-module-you-wish-to-mock', () => {
  return (
    // Here, you craft a JavaScript illusion of the interfaces you wish to mock 
  );
});

As a rule of thumb you shouldn’t mock dependencies implemented in JavaScript. If you find yourself doing that for convenience sake you might have yourself creating false positives in your tests. Remember, the goal is not to create a hall of mirrors but to reflect the true essence of your app’s performance. For those seeking deeper knowledge in the expertise of Jest, delve into its documentation There, you’ll find wisdom on how to spy on functions, create doppelgangers of function implementations, and even how to mimic the future (mocking promises, that is).

To recap, we have navigated the foundational concepts of unit and integration tests. Exploring the nuances of mocking and the playful artistry required to make our apps both robust and delightful. From the simple, welcoming gestures of unit tests to the complex choreography of integration tests, we’ve seen how each layer adds its unique stitch to the fabric of our applications. Along the way, we’ve learned to mimic the world outside with mocking, ensuring our tests are as close to reality as they can be, all while navigating through the magical realm of React Native with jest and a sprinkle of humor.

However, there’s a realm of testing we’ve merely skimmed: the expansive, snow-capped domain of end-to-end testing. Here, tests transform into users, traversing the app from inception to conclusion, executing actions and harboring expectations to guarantee that every interaction flows seamlessly. End-to-end testing in React Native encompasses a narrative so vast and detailed it warrants its own dedicated blog post. It’s about sculpting experiences, fostering resilience, and ensuring reliability. And though we’ve covered significant ground, the journey into the heart of React Native testing is far from over.