TutorialsLast Updated Jul 22, 20265 min read

Continuous integration for React Native applications

Fikayo Adepoju

Fullstack Developer and Tech Author

React Native lets developers build truly native iOS and Android apps from a single JavaScript or TypeScript codebase. Components map directly to the platform’s native UI primitives, so the result isn’t a webview — it’s the real thing.

Since December 2024, the React Native team has recommended starting new projects with a framework. The default recommendation is Expo, which manages the native toolchain and ships a faster developer loop. We’ll use Expo here.

In this tutorial, we’ll learn how to:

  • Write and run tests for a React Native application
  • Automate those tests by building a continuous integration pipeline

Prerequisites

To follow along, a few things are required:

  1. Basic knowledge of JavaScript or TypeScript
  2. Node.js 22 LTS or newer (24 LTS recommended) installed locally
  3. A CircleCI account
  4. A GitHub account
  5. A development environment for iOS or Android if we want to run the app in a simulator

Note: the simulator setup is optional. Without it, we won’t be able to run the sample app in an emulator, but we’ll still be able to write and run the tests, which is what the rest of this tutorial focuses on.

With those installed and set up, it’s time to begin.

Creating a sample React Native application

To start, create a new Expo app with the TypeScript template. Choose a location, then run:

npx create-expo-app@latest MyTestProject --template blank-typescript

This scaffolds a new project inside a MyTestProject folder. On a first run, expect dependency installation to take a minute or two.

To start the project on a simulator, cd into the new project directory and run:

cd MyTestProject
npx expo start --ios

That builds the project and launches it on the default iOS simulator:

virtual device running - Xcode

We can leave the app running in its own terminal window. Expo’s Metro bundler picks up file changes and reloads the simulator automatically. Metro is the JavaScript bundler React Native uses to package and serve our code to the simulator.

For this tutorial, we’ll build a small app: a button that, when pressed, displays a message. Then we’ll write a test suite that verifies that behavior.

Open App.tsx in the project root and replace its contents with:

import React from "react";
import {
  Button,
  StatusBar,
  StyleSheet,
  Text,
  View,
  useColorScheme,
} from "react-native";

const App = () => {
  const [message, setMessage] = React.useState<string | undefined>(undefined);

  const isDarkMode = useColorScheme() === "dark";
  const backgroundStyle = {
    backgroundColor: isDarkMode ? "#222" : "#fff",
  };

  return (
    <View style={[styles.container, backgroundStyle]}>
      <StatusBar
        barStyle={isDarkMode ? "light-content" : "dark-content"}
        backgroundColor={backgroundStyle.backgroundColor}
      />

      <Button
        title="Say Hello"
        onPress={() => {
          setTimeout(() => {
            setMessage("Hello Tester");
          }, Math.floor(Math.random() * 200));
        }}
      />
      {message && (
        <Text style={styles.messageText} testID="printed-message">
          {message}
        </Text>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingTop: 60,
  },
  messageText: {
    fontSize: 38,
    textAlign: "center",
    marginTop: 10,
  },
});

export default App;

The UI is a button labeled Say Hello that displays the message Hello Tester when pressed. We use setTimeout with a small random delay to simulate an asynchronous operation — that gives our test something interesting to wait for. The React Native Text component renders the message below the button, and StyleSheet.create defines the message styling.

In the simulator, click the Say Hello button.

App running - Emulator

Setting up and adding tests

Expo’s blank template doesn’t include a test runner by default, so we’ll add one. We’ll use Jest together with the React Native Testing Library (RNTL) and Expo’s Jest preset.

Install the dev dependencies:

npm install --save-dev jest jest-expo @testing-library/react-native react-test-renderer@19.1.0 @types/jest

We pin react-test-renderer to 19.1.0 to match the React version Expo SDK 54 ships. RNTL needs a matching react-test-renderer peer.

Open package.json and add a test script and a Jest configuration block:

{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "preset": "jest-expo"
  }
}

Create the test file at __tests__/App.test.tsx and add this snippet:

import React from "react";
import { fireEvent, render, waitFor } from "@testing-library/react-native";

import App from "../App";

it("renders the message after pressing the button", async () => {
  const { getByTestId, getByText, queryByTestId, toJSON } = render(<App />);

  const button = getByText("Say Hello");
  fireEvent.press(button);

  await waitFor(() => expect(queryByTestId("printed-message")).toBeTruthy());

  expect(getByTestId("printed-message").props.children).toBe("Hello Tester");
  expect(toJSON()).toMatchSnapshot();
});

The test renders the App component, finds the button by its label, and fires a press event. Because the message renders asynchronously (via the setTimeout in App.tsx), we use waitFor to wait until the Text component with testID="printed-message" shows up before asserting on its content. The final assertion compares the rendered tree against a snapshot, so the first run writes a baseline and subsequent runs catch unintended UI changes.

To run the test suite, from the project root run:

npm test

The output should show one passing test:

Tests passed - CLI

Writing the CI pipeline

A continuous integration pipeline runs the test suite automatically every time we push changes to GitHub. We’ll create one with CircleCI.

At the project root, create a folder named .circleci, then add a file inside it called config.yml:

version: 2.1
orbs:
  node: circleci/node@7.2.1
jobs:
  test:
    docker:
      - image: cimg/node:24.0
    steps:
      - checkout
      - node/install-packages
      - run:
          name: Run tests
          command: npm test
workflows:
  test-on-push:
    jobs:
      - test

The config uses CircleCI’s circleci/node orb. The orb’s node/install-packages step detects package-lock.json, runs npm ci, and caches ~/.npm keyed on the lockfile — so we get reproducible installs and a fast cache without writing the cache logic ourselves. The job runs on cimg/node:24.0 (Node.js 24 LTS) and the test-on-push workflow ties everything together so a push to GitHub triggers the job.

Save the file and push the project to GitHub. Make sure the GitHub account is the one connected to your CircleCI account.

Then, from the CircleCI dashboard, select Create Project, choose GitHub, pick the project from the dropdown, give it a meaningful name, and click Create Project.

CircleCI detects the configuration file but does not run the pipeline automatically on first link. To trigger the first run, push a small commit, or click Trigger Pipeline from the dashboard.

The pipeline will run and pass:

Build Success - CircleCI

Conclusion

Mobile apps need testing as much as web apps do, often more so — asking users to update a release every time we ship a fix is a fast way to lose them. Setting up an automated test pipeline early means bugs get caught before they reach production builds, not after.

The complete source code for this tutorial is available here on GitHub.

Happy coding!


Fikayo Adepoju is a LinkedIn Learning (Lynda.com) Author, Full-stack developer, technical writer, and tech content creator proficient in Web and Mobile technologies and DevOps with over 10 years experience developing scalable distributed applications. With over 40 articles written for CircleCI, Twilio, Auth0, and The New Stack blogs, and also on his personal Medium page, he loves to share his knowledge to as many developers as would benefit from it. You can also check out his video courses on Udemy.