Integration Testing for React Native Apps

Gleb Bahmutov

Sr Director of Engineering

React

Finland 2021

You want to write a mobile app... no problem

  • learn React
  • npx react-native init MyProject
  • Profit!

Why we decided to rewrite our iOS & Android apps from scratch — in React Native. Mercari US

import React from 'react';
import {Text, View} from 'react-native';
import {Header} from './Header';
import {heading} from './Typography';

const WelcomeScreen = () => (
  <View>
    <Header title="Welcome to React Native"/>
    <Text style={heading}>Step One</Text>
    <Text>
      Edit App.js to change this screen and turn it
      into your app.
 ...
import React from 'react';
import {Text, View} from 'react-native';
import {Header} from './Header';
import {heading} from './Typography';

const WelcomeScreen = () => (
  <View>
    <Header title="Welcome to React Native"/>
    <Text style={heading}>Step One</Text>
    <Text>
      Edit App.js to change this screen and turn it
      into your app.
 ...

Are you sure it is working?

  • ESLint + Flow / Typescript
  • Jest for unit and component tests
    • jsDOM, synthetic events
  • Detox / Appium for built native application
  • Render React RN app in the browser
    • Use Cypress.io test runner

A simple RN app

import React, { useEffect, useState } from 'react';
import { ActivityIndicator, FlatList, Text, View } from 'react-native';

export default function App() {
  const [isLoading, setLoading] = useState(true);
  const [data, setData] = useState([]);

  useEffect(() => {
    fetch('https://jsonplaceholder.cypress.io/users')
      .then((response) => response.json())
      .then((json) => setData(json))
      .catch((error) => console.error(error))
      .finally(() => setLoading(false));
  }, []);

  return (
    <View style={{ flex: 1, padding: 24 }}>
      {isLoading ? <ActivityIndicator testID="loading"/> : (
        <FlatList
          accessibilityLabel="users"
          data={data}
          keyExtractor={({ id }, index) => String(id)}
          renderItem={({ item }) => (
            <Text testID="user" accessibilityLabel="user">
              {item.name}, {item.email}</Text>
          )}
        />
      )}
    </View>
  );
};

App.js

Test the Web Version!

Assume the Expo translated the RN code into the same functionality for the Web, iOS, and Android

{
  "baseUrl": "http://localhost:19006",
  "viewportWidth": 500,
  "viewportHeight": 700
}

cypress.json

it('loads a list of users', () => {
  cy.visit('/')
  cy.get('[data-testid=user]')
    .should('have.length.gt', 3)
})

spec.js

Spy on the network call

it('spy on the network load', () => {
  cy.intercept('/users').as('users')
  cy.visit('/')
  cy.wait('@users').its('response.body')
    .should('be.an', 'Array')
    .and('have.length.gt', 5)
    .then(users => {
      cy.get('[data-testid=user]')
        .should('have.length', users.length)
    })
})

spec.js

Spy on the network call

it('spy on the network load', () => {
  cy.intercept('/users').as('users')
  cy.visit('/')
  cy.wait('@users').its('response.body')
    .should('be.an', 'Array')
    .and('have.length.gt', 5)
    .then(users => {
      cy.get('[data-testid=user]')
        .should('have.length', users.length)
    })
})

spec.js

Loading indicator

it('shows the loading indicator', () => {
  // slow down the response by 1 second
  // https://on.cypress.io/intercept
  cy.intercept('/users', (req) => {
    return req.continue(res => res.setDelay(1000))
  }).as('users')
  cy.visit('/')
  // the loading indicator should be visible at first
  cy.get('[data-testid=loading]').should('be.visible')
  // the disappear
  cy.get('[data-testid=loading]').should('not.exist')
  cy.wait('@users')
})

spec.js

import { ActivityIndicator } from 'react-native';
{isLoading ? <ActivityIndicator testID="loading"/> ...

App.js

Loading indicator

it('shows the loading indicator', () => {
  // slow down the response by 1 second
  // https://on.cypress.io/intercept
  cy.intercept('/users', (req) => {
    return req.continue(res => res.setDelay(1000))
  }).as('users')
  cy.visit('/')
  // the loading indicator should be visible at first
  cy.get('[data-testid=loading]').should('be.visible')
  // the disappear
  cy.get('[data-testid=loading]').should('not.exist')
  cy.wait('@users')
})

spec.js

import { ActivityIndicator } from 'react-native';
{isLoading ? <ActivityIndicator testID="loading"/> ...

App.js

Stub network

it('shows mock data', () => {
  cy.intercept('/users', { 
    fixture: 'users.json' 
  })
  cy.visit('/')
  cy.get('[data-testid=user]')
    .should('have.length', 3)
})

spec.js

Network errors

cy.intercept('/users', (req) => {
  return Cypress.Promise.delay(1000)
    .then(() => req.reply({ forceNetworkError: true }))
})
// observe the application's behavior
// in our case, the app simply logs the error
cy.visit('/', {
  onBeforeLoad(win) {
    cy.spy(win.console, 'error').as('logError')
  },
})
cy.get('[data-testid=loading]').should('be.visible')
// confirm the loading indicator goes away
cy.get('[data-testid=loading]').should('not.exist')
cy.get('@logError').should('have.been.called')

spec.js

useEffect(() => {
  fetch('https://jsonplaceholder.cypress.io/users')
    .then((response) => response.json())
    .then((json) => setData(json))
    .catch((error) => console.error(error))
    .finally(() => setLoading(false));
}, []);

App.js

Network errors

cy.intercept('/users', (req) => {
  return Cypress.Promise.delay(1000)
    .then(() => req.reply({ forceNetworkError: true }))
})
// observe the application's behavior
// in our case, the app simply logs the error
cy.visit('/', {
  onBeforeLoad(win) {
    cy.spy(win.console, 'error').as('logError')
  },
})
cy.get('[data-testid=loading]').should('be.visible')
// confirm the loading indicator goes away
cy.get('[data-testid=loading]').should('not.exist')
cy.get('@logError').should('have.been.called')

spec.js

useEffect(() => {
  fetch('https://jsonplaceholder.cypress.io/users')
    .then((response) => response.json())
    .then((json) => setData(json))
    .catch((error) => console.error(error))
    .finally(() => setLoading(false));
}, []);

App.js

Is the app accessible?

RN supports Aria attributes and test IDs

<View style={{ flex: 1, padding: 24 }}>
  {isLoading ? (
    <ActivityIndicator
      testID="loading"
      accessibilityLabel="App is loading users"
    />
  ) : (
    <FlatList
      accessibilityLabel="users"
      data={data}
      keyExtractor={({ id }, index) => String(id)}
      renderItem={({ item }) => (
        <Text testID="user" accessibilityLabel="user">
          {item.name}, {item.email}
        </Text>
      )}
    />
  )}
</View>

App.js

Is the app accessible?

it('is accessible', () => {
  cy.intercept('/users', {
    fixture: 'users.json',
    delay: 2000,
  })
  cy.visit('/')
  cy.get('[aria-label="App is loading users"]').should('be.visible')
  cy.get('[aria-label="users"]')
    .should('be.visible')
    .get('[aria-label=user]')
    .should('have.length', 3)
})

spec.js

Is the app accessible?

it('is accessible', () => {
  cy.intercept('/users', {
    fixture: 'users.json',
    delay: 2000,
  })
  cy.visit('/')
  cy.get('[aria-label="App is loading users"]').should('be.visible')
  cy.get('[aria-label="users"]')
    .should('be.visible')
    .get('[aria-label=user]')
    .should('have.length', 3)
})

spec.js

Is the app accessible?

$ npm install --save-dev cypress-axe axe-core
+ cypress-axe@0.12.2
+ axe-core@4.2.2

Full a11y testing

spec.js

cy.visit('/')
  ...
// a11y check
cy.injectAxe();
cy.checkA11y();
$ npm install --save-dev cypress-axe axe-core
+ cypress-axe@0.12.2
+ axe-core@4.2.2

Full a11y testing

spec.js

cy.visit('/')
  ...
// a11y check
cy.injectAxe();
cy.checkA11y();

Did we test everything?

Yes, we did.

module.exports = function(api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['istanbul']
  };
};

babel.config.js

+ @cypress/code-coverage

Does the app look good?

the same

Use any Cypress visual plugin

https://on.cypress.io/visual-testing

spec.js

cy.visit('/')
  ...
// visual snapshot
cy.percySnapshot('two items')

Does the app look good?

the same

Use any Cypress visual plugin

https://on.cypress.io/visual-testing

spec.js

cy.visit('/')
  ...
// visual snapshot
cy.percySnapshot('two items')

Does it work?

Does Expo guarantee Web = iOS = Android?

  • I trust Expo that is used by 1000s of projects more than my own code
  • Testing Web version in Expo tests my code, my logic, my data, my backend
  • For native components that Expo cannot show in the browser = mock them

Thank You 👏

React

Finland 2021

Integration Testing for React Native Apps

By Gleb Bahmutov

Integration Testing for React Native Apps

My unpopular opinion is that testing is ... important. How do you test your React Native apps? In this presentation, I will show how to run full integrations tests using Cypress while the RN app is running in the browser. This method can cover most of the application's code and be effective at finding logical errors and mistakes when calling the server APIs. Presented at ReactFinland 2021. Find the video at https://www.youtube.com/watch?v=PIxaFbMBez0

  • 14,503