May 1, 2023 - 11 min read

photo of Ahmad

Ahmad

Consultant

How to set up automated E2E tests for React Native with Detox

Share

As developers, the conversations (and frustrations) around writing tests are something we've all been a part of. When asking fellow developers about their experience writing tests, they shared some common challenges:

  • Tests are complicated to set up

  • They’re unintuitive to write

  • They’re difficult to maintain throughout a project

To help with these challenges, this article will focus on simplifying how our E2E (end-to-end) tests are set up and written using Detox in a step-by-step process.

Before we start

What we’ll be creating:

  • A simple React Native app

  • An E2E test suite with automated tests

What you’ll need:

  • VS Code (or preferred IDE)

  • Npm (version 9.x)

  • Node.js (version 14.x or up)

  • An Expo account

  • Xcode (version 14)

First things first

Let’s create a simple React Native application to write tests for. We’ll be using Expo to initialize our app (read more about why we love Expo here).

We’ll start with the expo cli:

npx create-expo-app --template

We’ll be using the Blank (TypeScript) template provided to generate the app boilerplate.

Now that we have the app setup, let’s add in a few elements and styles to create a simple login screen. Your app.jsx render function should look like this:

export default function App() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState(false)
  const [status, setStatus] = useState('')

  const handleLogin = () => {
    setError(false)

    if (!email) {
      setError(true)
      setStatus('Please enter an email')
      return
    }

    if (!password) {
      setError(true)
      setStatus('Please enter a password')
      return
    }

    setStatus('Successful login!')
  }

  return (
    <View style={styles.container}>

      <Text style={styles.heading}>Welcome to our app!</Text>
      <View style={styles.inputView}>
        <TextInput
          style={styles.textInput}
          placeholder="Enter email"
          placeholderTextColor="#003f5c"
          secureTextEntry={false}
          onChangeText={(email) => setEmail(email)}
        /> 
      </View> 
      <View style={styles.inputView}>
        <TextInput
          style={styles.textInput}
          placeholder="Enter password"
          placeholderTextColor="#003f5c"
          secureTextEntry={true}
          onChangeText={(password) => setPassword(password)}
        /> 
      </View>

      <TouchableOpacity style={styles.loginBtn} onPress={() => handleLogin()}>
        <Text>LOGIN</Text>
      </TouchableOpacity>

      <Text style={{...styles.status, color: error ? 'red' : 'green'}}>{status}</Text>

      <StatusBar style="auto" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  inputView: {
    backgroundColor: "#e0e4e0",
    borderRadius: 15,
    width: "70%",
    height: 40,
    marginBottom: 20,
    alignItems: "center",
  },
  textInput: {
    width: '100%',
    height: 50,
    flex: 1,
    padding: 5,
    marginLeft: 20,
  },
  heading: {
    fontSize: 40,
    marginBottom: 48
  },
  status: {
    marginTop: 24
  },
  loginBtn: {
    width: "80%",
    borderRadius: 15,
    height: 40,
    alignItems: "center",
    justifyContent: "center",
    marginTop: 40,
    backgroundColor: "#18ccbe",
  }
});

The app interface should look something like this: An app showing a login interface

Before continuing, let’s take a second to think about what kind of test cases we want to run against this screen. Here are a few to start with:

  • The heading text should be visible

  • The input fields should be visible

  • The text input fields should allow the user to type into them

  • The password field should hide the user input

  • Pressing the login button should validate both input fields

Instead of running through these tests manually every time we update our app, we’re going to create scripts to automate them!

Setting up our test dependencies

Once we have the app working, let’s install our testing dependencies, Detox as our end to end framework, and Jest as our javascript testing framework. Through our terminal, let's use npm to install these dependencies, including the -D flag to save them. We used detox@^20.5.0, and jest@^29.0.5 at the time of this article.

npm install detox -D

npm install jest -D

The Detox docs have a great project setup guide you can use.

Following these docs, we’ll initialize Detox using their CLI:

detox init

After setting up our Detox environment and following the steps in the docs, we should have an /e2e directory created that we can work out of, and a new .detoxrc.js config updated with our app name.

Our testing dependencies are now installed!

Pro tip: We’ve found that it’s important to create models that represent screens and app elements rather than testing elements in isolation. To ensure a common understanding of what each model represents and how it should be used, the QA and development team members collaborate to define them together. The end result is a user-friendly interface that is easy to understand.

Writing our first test case

Let’s create our first test case by ensuring that the heading is visible on the screen.

To do this, we need to be able to select the heading element on the screen to interact with it. We can use objects to create a common way of applying selectors to elements. This allows us to reference a single selector source easily by both the tests and the UI.

Let's create a LoginSelectors file in a components/selectors folder under our e2e directory:

SelectorFolder The file should look something like this:

export const LoginSelector = {
 heading: "app:login:heading",
 emailInput: "app:login:emailInput",
 passwordInput: "app:login:passwordInput",
 loginButton: "app:login:loginButton"
}

Note: The string associated with the selector can be any unique identifier appropriate for you.

Next, we can add these to the appropriate elements. We can take advantage of the testID attribute used specifically for identifying elements in e2e tests. Let’s add the selector to our app heading element:

<Text style={styles.heading} testID={LoginSelector.heading}>
  Welcome to our app!
</Text>

We can begin writing our tests now that our selectors have been set up. By following the Detox documentation, our test should look like this:

it('Should display the heading', async () => {
  await expect(element(by.id(LoginSelector.heading))).toBeVisible();
});

This checks if the heading element is visible by using our login selector and applying the Detox visibility method.

To future-proof and make our tests easier to manage, we’ll be applying a pattern to model the screens we’re trying to test. This pattern is called page object models.

Setting up our page object models

When we talk about page object models, we refer to modeling the screens of our application using classes. This allows us to write our tests consistently and keep them organized for the entire application.

To hold some shared logic for all screen elements, we need to create a common base class. For our current example, we’ll focus on an element's visibility. Let's put these classes in our components/elements folder.

ElementFolder

First, create a BaseElement class:

import { expect } from 'detox'

export default class BaseElement {
   /**
    * Instance of the element on the screen
    */
   private _element: Detox.NativeElement
   constructor(element: Detox.NativeElement) {
       this._element = element
   }

   protected get element(): Detox.NativeElement {
       return this._element
   }

   /**
    * Checks if current element is visible on the UI
    * @returns {Promise<boolean>} True if visible, else false
    */
   public async isVisible(): Promise<boolean> {
       try {
           await expect(this._element).toBeVisible()
           return true
       } catch (e) {
           console.log(e)
           return false
       }
   }
}

This is a class that all our future elements will build on top of. It’ll will hold a reference to the native Detox element object as well as define a common isVisible() method to check if the element is visible on the screen, making use of the native detox methods.

We need to create our TextElement class that’ll will be used to model any <Text> components in our app:


/**
* Represents a text element on the screen
* Text element extends base element completely, with no new implementation
*/
export class TextElement extends BaseElement implements ITextElement {
   constructor(element: Detox.NativeElement) {
       super(element)
   }
}

As we can see, no additional functionality is needed for text elements, the only thing we’ll be using is the isVisible() method from the base class. We’re now ready to create our page object model for the test screen. We'll store this class in our components/pages folder:

import { element, by } from 'detox'
import { ITextElement, TextElement, LoginSelector } from "../components"

export class LoginPage {

 /**
  * Instance of the heading label on the login screen
  */
 private _heading?: Detox.NativeElement

 constructor() {
 }

 /**
  * Bind all elements on the login screen to their class properties
  */
 async bind(): Promise<LoginPage> {
   this._heading = element(by.id(LoginSelector.heading))
   return this
 }

 public get heading(): ITextElement {
   if (!this._heading) {
     throw new Error("Attempting to access Login page heading before calling bind()")
   }
   return new TextElement(this._heading)
 }
}

The native UI elements are now directly accessible to us thanks to a model that stores a reference to them.

Let’s revisit our initial test and see how it can be written. First, we need to initialize our new page object model using the Jest beforeAll() hook at the top of the test:

import { LoginPage } from "../components"

describe('Login screen', () => {

 // Create reference for the login page object model
 let loginPage: LoginPage
 beforeAll(async () => {
   // Initialize the login page object model
   loginPage = new LoginPage()

   // Bind the elements on the screen to the model
   await loginPage.bind()
 })
})

Here’s how we can rewrite our previous test:

it('Should display the heading', async () => {
  expect(await loginPage.heading.isVisible()).toBe(true)
})

Let’s compare these tests side by side…

Before:

it('Should display the heading', async () => {
  await expect(element(by.id(LoginSelector.heading))).toBeVisible();
});

After:

it('Should display the heading', async () => {
  expect(await loginPage.heading.isVisible()).toBe(true)
})

We can now write our tests using more common language and less technical jargon because of our page object models. With the language less complex, this makes it easier for non-developers to understand what's going on. In addition to making this process future-proof, this makes it easier to write the tests.

Running our tests

To get the native application build for our tests, we must compile our code before running them. By first following the instructions provided by Expo, we can build our application using the following command:

npx expo run:ios

According to the Detox guides, when our application is built, we must specify the build location in our .detoxrc.js file. It should look something like this, with the build output found under an /ios/build directory by default:

apps: {
   'ios.debug': {
     type: 'ios.app',
     binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/e2edemo.app',
     build: 'xcodebuild -workspace ios/e2edemo.xcworkspace -scheme e2edemo -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
   },
}

We can now build the application for our detox tests using the following command:

detox build -c ios.sim.debug

With our config set, and the build done, we can run our test using the Detox command:

detox test -c ios.sim.debug

The test cases will now run automatically, so we can sit back and watch the magic happen!

Afterwards, we should see our test case pass in the generated output.

The automated testing process in action

Conclusion

With Detox, we’ve now written our first automated test using page object models. To build a maintainable base for our tests, we were able to take the existing testing framework of Detox and wrap it in our custom classes.

In the future, you can model any and all application screens using this pattern, which will make even the trickiest test cases simpler to write and understand.

You can check out our full test suite example for this app in our GitHub repo.

Share

You might also like

Transform your digital products

Talk to us today