Creating an automated test framework for React Native apps

Reshma Sujith
VoucherCodes Tech Blog
9 min readNov 22, 2023

--

React native app testing using WDIO and Appium

In mobile app development’s fast-paced world, VoucherCodes reached a turning point. Initially, our app was written in native languages, tailored separately for Android and iOS. This approach, although powerful, brought its own set of challenges, mainly centred around maintenance. As our app grew, so did the complexities of ensuring consistent feature rollouts and bug fixes across two distinct codebases.

In light of these complexities, we embarked on a new direction; rewriting our app using React Native. React Native promised a unified codebase that would bridge both platforms, potentially cutting our maintenance woes in half. Another huge pro to React Native was the talent pool. Many of our UI Engineers were already skilled in React, enabling easier talent sharing. Moreover, their adaptable skills meant that if they were seeking a change, they could transition to other frontend tasks with ease. This adaptability is key in both attracting and keeping top-notch talent engaged in our projects. However, this considerable change also brought forth a crucial task for the QA team — venturing into test automation for our newly-minted React Native app.

This journey introduced us to an array of tools, methodologies, and practices. In this blog, I’ll share our experiences, detailing about our framework, page object best practices, the role of testIDs in React Native, and the unique challenges we faced testing on both Android and iOS platforms.

The need for test automation in React Native

Using React Native allows for a unified codebase for Android and iOS. However, this can introduce unique challenges, making thorough testing vital:

  1. Platform-specific issues: React Native bridges JavaScript with native components, potentially leading to differing behaviours between Android and iOS. Testing ensures consistency.
  2. UI/UX consistency: With a variety of devices and resolutions, it’s essential to ensure uniform appearance and function. Automated tests validate this across multiple devices.
  3. Quick feedback: Automated tests, integrated within the CI/CD pipeline, provide immediate insights into any problems, speeding up the development process without compromising quality.
  4. Regression testing: As the app evolves, automated tests ensure that new additions don’t adversely affect existing functionalities.
  5. Comprehensive coverage: While manual tests might miss certain scenarios, automated tests ensure a thorough examination of all app functionalities.

Framework overview

WDIO framework setup

Let’s take a closer look at the tools that power our React Native app automation. First, there’s WebDriver.io, our testing framework, for creating and running tests. Then, we have Appium, which acts as a bridge between our test scripts and the mobile platforms (iOS and Android) allowing interaction with the mobile apps. Appium also allows execution on both platforms, saving considerable effort.

Now, for the platform-specific tools: XCUITest and UIAutomator. XCUITest is Apple’s official framework for testing iOS apps, and it’s what we use to automate our app on iPhones. UIAutomator, on the other hand, is Google’s tool designed for automating Android apps. The key benefits here are twofold: cross-platform capability thanks to Appium, which allows us to write tests once and run them on both iOS and Android, and platform-specific expertise through XCUITest and UIAutomator, ensuring our tests are tailored to the unique characteristics of each platform. This combination saved time but also ensured our testing efforts were robust.

Creating and structuring WDIO tests

When creating WDIO tests, it’s crucial to ensure that the structure is clear, maintainable, and consistent. Here’s a comprehensive guide on how to achieve that:

First and foremost, we are basing our tests on the MochaJS framework. Detailed syntax and guidelines can be referenced from the official MochaJS documentation

Test file organisation

Using the MochaJS testing approach, each test file is dedicated to a single page or component, keeping things organised. We use the `describe` function to define the testing context, and within that, the `it` function pinpoints specific scenarios:

describe('Home Page', () => {
it('Checks one thing', async () => {
// test coverage here
});
it('Checks another thing', async () => {
// test coverage here
});
});

Page Objects

When automating tests using WDIO, organising your code efficiently becomes crucial. Page objects are a fundamental concept that can significantly enhance your test automation framework.

Page objects should follow a clear structure for maintainability and readability. Let’s break it down:

 // Imports
import { getPinFunction } from '../../path/to/getPinFunction';
import homeScreen from '../path/to/homeScreen';

// Object Generators (if needed)
const offerElements = context => {
return {
offerTitle: `id=${context}OfferTitle`,
merchantLogo: `id=${context}MerchantLogo`,
offerType: `id=${context}OfferType`,
};
};

// Elements
const vcExclusivesSection = {
voucherCodesExclusivesHeader: 'id=voucherCodesExclusivesHeader',
vcExclusivesSectionViewMoreLink: 'id=voucherCodesExclusivesViewMoreLink',
vcExclusivesOffer: 'id=voucherCodesExclusivesOffer',

// Uses an Object Generator
...offerElements('voucherCodesExclusives'),
};

// Page Object Methods (if needed)
class YourPageObjectClass {
async assertOfferDetailsScreen({ offerType }) {
// Code here
}
// More methods...
}

// Class declaration
export default new YourPageObjectClass();
  1. Imports:
  • Import relevant data and logic at the beginning of your page object file.
  • Opt for destructuring when you need only specific parts of an import.
  • Consider class extension when dealing with related page object.

2. Object generators for reusability:

  • Due to the nature of how we need to create our testIDs, the same component structure may need to be declared multiple times, but with a different preceding context. In these cases, it is good practice to create object generators.
  • Always include doc blocks for clarity and documentation.

3. Elements: keep them private:

  • Declare all web elements outside the class to keep them private and avoid direct manipulation. Page methods can access these elements when needed.

4. Page methods and class organisation:

  • Page methods are exclusively for use within the page object, not in other files or tests. Their primary purpose is to eliminate redundant code from class methods, making them more concise. They can also handle different scenarios, simplifying class methods. While page methods free up frequently used code blocks, it’s advisable to avoid excessive method nesting for better readability and maintainability.
  • Class and class methods represent what’s imported externally into your tests. Keep data outside the class for internal use only, ensuring it remains inaccessible externally. To maintain a logical structure, consider class Extension when dealing with related logic that spans multiple page objects.

Adding testIDs in React Native

Why we chose testID:

  1. Precision in automation: We wanted to ensure that our tests targeted the right elements even if the UI or components changed. The `testID` prop allows us to do precisely that.
  2. Consistency across devices: With our apps running on both iOS and Android, it’s crucial to have a testing mechanism that’s seamless across platforms — `testID` provides this cross-platform uniformity.
  3. No impact on accessibility: Using `testID` over accessibility labels meant that we wouldn’t disrupt our users’ accessibility settings. That’s a big win in terms of user experience.

Implementing testIDs

For a basic setup, adding a `testID` is as straightforward as:

<Component testID="yourTestId" />

However, to standardise our approach and accommodate platform nuances, we developed a helper function, “addTestIdentifiers” which ensures that `testID` is formatted correctly and aligns with the platform it’s running on.

import { Platform } from 'react-native';
import { getBundleId } from 'react-native-device-info';

const appIdentifier = getBundleId();

export function addTestIdentifiers(testID: string): string | undefined {
const testId = toCamelCase(testID);
if (!testId) {
return undefined;
}
const prefix = `${appIdentifier}:id/`;
const hasPrefix = testId.startsWith(prefix);

return Platform.select({
android: !hasPrefix ? `${prefix}${testId}` : testId,
ios: hasPrefix ? testId.slice(prefix.length) : testId,
});
}

Dynamic testIDs

Static testIDs work well until they don’t. To ensure specificity, especially where nesting isn’t feasible, unique testIDs are essential. The solution — dynamic variables.

Implementing dynamic testIDs: Inject dynamic data into testIDs for uniqueness:

import { addTestIdentifiers } from '~utils/testUtils/addTestIdentifiers';
let dynamicVar = 'uniqueData';
testID={addTestIdentifiers(`${dynamicVar} staticText`)};

This code will produce `testID= ‘uniqueDataStaticText’`

Parent-child components data flow: Sometimes, the necessary dynamic data resides in a parent component. For these instances, we introduced the “testIdContext prop. This prop lets a parent component share its unique data directly with the child’s testID, ensuring precision and specificity in our identifiers.

// Parent component:
<OfferExpiry testIdContext={sectionHeader} {...{ date }} />

//Child component:
const OfferExpiry: React.FC<OfferExpiryProps> = ({ date, testIdContext = '' }) => {
<Text testID={addTestIdentifiers(`${testIdContext}OfferExpiryText`)}>
}

Anytime we introduce a new prop, we need to ensure our Typescript interfaces are updated, keeping our code type-safe.

export interface OfferExpiryProps {
date: number;
testIdContext?: string | null;
}

This approach not only simplifies the data flow between parent and child components but also keeps our testIDs dynamic and accurate, optimising our testing process.

Challenges in automating React Native app on Android and iOS

Automating our React Native app on both Android and iOS presented challenges given the unique test frameworks each platform utilises: XCUITest for iOS and UIAutomator2 for Android. Our WDIO framework, though powerful, revealed some platform-specific quirks. Here are some of the obstacles we faced and our solutions:

1. Locating testIDs on iOS using Appium:

Problem: While testIDs are easily located and validated against in the Android app, the same cannot be said for the iOS version. Ensure that any newly added testIDs are reflected in the latest app file by updating the code on Expo — a tool we leverage for building and updating our app, downloading the new app file, and configuring WDIO to utilise it.

Solution:The solution can be broken down into two core components:

(i) Snapshot depth on iOS: iOS, by its nature, limits the rendering depth within the code hierarchy, which can hide deeper nested elements. To avoid this, we increased the maximum snapshot depth settings in the Appium driver in our WDIO config file in the beforeHook as below:

await driver.updateSettings({ snapshotMaxDepth: snapshotMaxDepthValue });

This change substantially broadens the snapshot depth, ensuring no element is left lurking in the shadows, no matter its nesting depth.

(ii) iOS rendering of view templates:

Problem: Touchable components in React Native, such as TouchableOpacity and PressableWithOpacity, are designed to enhance touch responses. Their ‘accessible’ prop is true by default, causing iOS to treat both the Touchable and its children as one interactive unit. For our automated tests, if a parent Touchable wraps a text component without the setting ‘accessible={false}’, the child’s testID becomes invisible to Appium. Simply, Appium recognises the Touchable as one entity, ignoring its children and their testIDs.

Solution: Setting the ‘accessible’ prop of the parent Touchable to false instructs iOS to consider the Touchable and its children separately. This ensures child components, even those deeply nested, become individually accessible to Appium.

2. Android visibility off-screen:

When attempting assertions such as isDisplayed or isPresent, Android devices will get an error/fail state if the element is not actively visible within the device’s viewport as Android’s default UIAutomator2 driver settings only spot elements that are currently visible on the screen. On iOS, using the default XCUITest driver settings, we could detect elements even if they were off-screen. However, we couldn’t always interact reliably with them without bringing them into view.

Solution: To address this challenge on Android, we used the UiScrollable class from the UIAutomator2 framework. This allowed scrolling elements into view and identify those not immediately visible.

const scrollToElement = `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceIdMatches(\".*${locator}.*\"))`;
await $(`android=${scrollToElement}`);

In this example, the targeted UI element is identified using resourceId as when we use the testID prop, it maps to “resource-id” for android. So, in Android, you would be looking for elements by their resource-id when trying to locate an element by its testID from React Native.

For our iOS tests, getting elements to scroll into view has been a challenge. We’ve come across various methods, and there’s a handy guide on using them with Appium here. Despite our efforts, we haven’t been able to implement a reliable scroll solution yet. However, the link above provides some promising approaches.

Conclusion

Diving into React Native testing was a mix of wins and learning moments. Tools like WDIO and Appium were crucial, but they also unveiled unique challenges between Android and iOS platforms. Though we’ve harnessed tools like UiScrollable effectively, areas like iOS scrolling still offer room for exploration. For those on a similar journey: embrace the hurdles, collaborate, and know that each obstacle is a stepping stone to greater expertise.

Have you heard? We’re hiring at VoucherCodes! Check out our careers page here.

--

--