Part 3: Building and testing native mobile apps with React Native and WebdriverIO

We have reached the conclusion of the trilogy! To recap the adventure to date, in Part 1 we built an app for iOS and Android using React Native. In Part 2 we refactored our app to make it test automation friendly. With the groundwork set forth, we are ready to write an end-to-end automation test.

Using WebdriverIO we can develop cross-platform automation tests entirely in JavaScript, without context switching between Swift and Kotlin. Through having automation tests, we gain assurance that future code changes made in our app won’t negatively affect the user experience.

Write once, run everywhere :)

You can find the full source code for this React Native project here:

GitHub - nathankrishnan/react-native-stock-price-app: Mobile app built using React Native to lookup stock price values.
Mobile app built using React Native to lookup stock price values. - GitHub - nathankrishnan/react-native-stock-price-app: Mobile app built using React Native to lookup stock price values.

Contents of this Guide

● Prerequisites

  • WebdriverIO
  • Mocha
  • Babel
  • Appium Server

● Setting up our Test Project

  • Test Configuration
  • Helper Functions

● Creating a View Object

● Structuring the Test Suite

  • Using the Mocha Framework
  • Executing the Test Suite

Prerequisites

WebdriverIO

WebdriverIO is a popular JavaScript Appium client that provides its own API for issuing Appium commands. We can install as a development dependency from yarn:

$ yarn add webdriverio --dev

Note: The --dev flag ensures that our package is downloaded as a development dependency and will be listed under the devDependencies list in our package.json. We are adding it in this manner to signify that this package doesn't need to be shipped with the app source code.

Mocha

Mocha is a popular framework for structuring and running test suites, traditionally used by developers writing unit tests in JavaScript projects. Using Mocha in our project will provide a uniform template for structuring our test suites. We can install it as a development dependency from yarn:

$ yarn add mocha --dev

Babel

Babel is a JavaScript compiler that allows us to use the latest and greatest JavaScript language features in NodeJS. For example, NodeJS doesn’t support the ES6 package import syntax by default, but by using Babel in our project we can use it. Expo took care of setting up Babel for our React Native project and we will now set it up for our automation tests. We can install it as a development dependency from yarn:

$ yarn add @babel/preset-env --dev

We will also add a configuration file in our project to make use of the Babel development dependency. In our project’s root directory create a file named .babelrc and add the following contents to it:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "loose": true
      }
    ]
  ]
}

Appium Server

WebdriverIO acts as our Appium client, but we need to also run Appium Server, which will act as a bridge to translate the actions in our tests to the necessary logic in either UIAutomator2 or XCUITest behind the scenes. We’ll install it globally from npm:

$ npm install --global appium

Note: We will install Appium Server globally to give us the flexibility of running it outside the context of our project’s working directory.

Setting up our Test Project

In our project’s working directory create a directory named tests. If you run ls in your terminal this what our project directory currently looks like:

.
├── App.js
├── README.md
├── app.json
├── assets
├── babel.config.js
├── components
├── node_modules
├── package.json
├── tests
├── utils
└── yarn.lock

In the tests directory, we will create our automation test and several helpful supporting classes and functions. We will setup the scaffold now and I will explain as we proceed. Within the tests directory create a file named stockPriceApp.js and two subdirectories named utils and viewObjects. This is what our tests directory looks like now:

tests
├── utils
├── viewObjects
└── stockPriceApp.js

The stockPriceApp.js file will contain our test suite. However, there are a few preparation tasks for us to perform before we officially write our test.

Test Configuration

In the utils directory we will create a configuration file for our upcoming test suite to use. Create a file in the utils directory named testConfig.js. In this file add the following contents:

export default class TestConfig {
	static webdriverPath = '/wd/hub';
	static webdriverPort = 4723;

	static androidBaseCapabilities(appPackage, appActivity) {
		const desiredCapabilities = {
      		platformName: "Android",
			automationName: "UiAutomator2",
      		deviceName: "Android Emulator",
      		appPackage: typeof appPackage !== 'undefined' ? appPackage : "com.nathankrishnan.stockprice",
      		appActivity: typeof appActivity !== 'undefined' ? appActivity : "host.exp.exponent.LauncherActivity",
      		automationName: "UiAutomator2",
		}

		return {
			path: this.webdriverPath,
			port: this.webdriverPort,
			capabilities: desiredCapabilities
		}
	}

	static iosBaseCapabilities(bundleId) {
		const desiredCapabilities = {
			platformName: "iOS",
			automationName: "XCUITest",
			deviceName: "iPhone 12 Pro Max",
			platformVersion: "14.2",
			bundleId: typeof bundleId !== 'undefined' ? bundleId : "com.nathankrishnan.stockprice"
		}

		return {
			path: this.webdriverPath,
			port: this.webdriverPort,
			capabilities: desiredCapabilities
		}
	}
}

Our TestConfig class has factory methods for creating a RemoteOptions object that WebdriverIO can use for setting up our Appium session. Let's sift through this:

  • Appium has a client-server architecture. In our project, WebdriverIO is our Appium client that will make requests on our behalf to the Appium Server, in order for our desired automation actions to be performed. To reach the Appium Server, the client must provide the Webdriver URL. Since we are running Appium Server locally, our Webdriver URL is: http://localhost:4723/wd/hub. Since running Appium Server locally is a common task, WebdriverIO just needs to know the webdriverPath and webdriverPort before it attempts to connect locally.
  • We also define two static methods for returning the configurations needed to run an Android and iOS session. Recall that desired capabilities is an Appium term for specifying how you want your test to work. For our tests, we will run them on a local Android Emulator and iOS Simulator. If an (appPackage, appActivity) or bundleId isn't specified, it will default to the associated ones for the app we built. Recall, that we set these values in our app.jsonin Part 2. I went with com.nathankrishnan.stockprice for my project.

Helper Functions

We will also create helper functions to facilitate actions we want to perform in our app, such as: finding an element, tapping an element, and sending keyboard input to an element. In the utils directory create another file called elementActions.js. In this file add the following contents:

// Wait up to 5 seconds
const MAX_ELEMENT_WAIT_THRESHOLD_MS = 5000;

async function findElement(driver, elementSelector, timeout = MAX_ELEMENT_WAIT_THRESHOLD_MS) {
	const element = await driver.$(elementSelector);
	await element.waitForExist({ timeout });
	return element;
}

async function tapElement(driver, elementSelector, timeout = MAX_ELEMENT_WAIT_THRESHOLD_MS) {
	const element = await findElement(driver, elementSelector, timeout);
	return element.click();
}

async function setValueOnElement(driver, elementSelector, value, timeout = MAX_ELEMENT_WAIT_THRESHOLD_MS) {
	const element = await findElement(driver, elementSelector, timeout);
	await element.setValue(value);
}

export { findElement, tapElement, setValueOnElement};

Here’s an overview of the three functions:

  • findElement(): Given an element selector string, check to see if the element exists in the current view. We will wait up to 5 seconds to see if the element exists and return it.
  • tapElement(): First run findElement() to get a reference to the element and then perform a tap action.
  • setValueOfElement(): first run findElement() to get a reference to the element and then send an input value.

Creating a View Object

View Object Model is a design pattern used for encapsulating the UI elements and actions within an app. In this design pattern, objects are created to contain important UI element references and actions for each view in the app. I recommend taking a look at the Structuring Automation Tests using the Page/View Object Model guide if you want to learn more.

In the viewObjects directory we created earlier, create a file named mainStockPriceView.js. In this file add the following contents:

import { findElement, tapElement, setValueOnElement } from "../utils/elementActions"

export default class MainStockPriceView {
	selectors = {
		stockNameText: "~stockNameText",
		stockPriceText: "~stockPriceText",
		stockChangeValueText: "~stockChangeValueText",
		stockTickerSymbolSearchInput: "~stockTickerSymbolSearchInput",
		stockTickerSymbolSearchBtn: "~stockTickerSymbolSearchBtn"
	}

	constructor(driver) {
		this.driver = driver;
	}

	async getStockName() {
		const stockName = await findElement(this.driver, this.selectors.stockNameText);
		return stockName.getText();
	}

	async getStockPrice() {
		const stockPrice = await findElement(this.driver, this.selectors.stockPriceText);
		return stockPrice.getText();
	}

	async getChangeValue() {
		const changeValue = await findElement(this.driver, this.selectors.stockChangeValueText);
		return changeValue.getText();
	}

	async setStockTickerSymbol(stockTickerSymbolString) {
		await setValueOnElement(this.driver, this.selectors.stockTickerSymbolSearchInput, stockTickerSymbolString);
	}

	async tapSearchBtn() {
		await tapElement(this.driver, this.selectors.stockTickerSymbolSearchBtn);
	}
}

Let’s dissect what we have added:

  • We created a class called MainStockPriceView to represent the main view of our app.
  • In this class, we have created an object to store our element selector strings. Due to our preparation work in Part 2, we are able to reach all of the necessary elements through the Accessibility ID locator strategy in both the Android and iOS builds of the app.
  • The constructor() of this class takes a reference to the Appium driver object.
  • To facilitate the actions we want to perform in this view of the app, we’ve created associated setter, getter, and action methods. To power these methods, we’ve imported the helper functions we created in the previous section.

We are now ready to create our test suite.

Structuring the Test Suite

Using the Mocha Framework

Using the Mocha framework we installed as a development dependency, we can organize our suite using the following structure:

// Our Test Suite Name
describe('Test Suite Name', function() {
  
  // Setup code we can run before each test session starts
  beforeEach(function() {
  
  });
  
  // Our Test Case
  it('Test Case Name', function() {
  });
  
  // Teardown code we can run after each test session has executed
  afterEach(function() {
  });
});
  • describe() will encompass our test suite in its entirety.
  • In the beforeEach() hook we can start each session based on each platform's desired capabilities.
  • We can create test cases by adding it() blocks. In this case we just need one test case. In the it() block we can write our test case logic.
  • In the afterEach() hook we can perform any actions before we tear down and end of each test session.

Let’s revisit the stockPriceApp.js file we created in the tests directory earlier and add the following contents to it:

import { strict as assert } from 'assert';
import { remote } from 'webdriverio';
import TestConfig from './utils/testConfig';
import MainStockPriceView from './viewObjects/mainStockPriceView';

const targetPlatforms = [TestConfig.iosBaseCapabilities(), TestConfig.androidBaseCapabilities()];

targetPlatforms.forEach(function(platform) {
	describe(`Stock Price ${platform.capabilities.platformName} App Test`, function() {
		let driver;
		// Allow the entire test suite to take up to 60 seconds max to execute
		this.timeout(60000);
	
		beforeEach(async function() {
			const testOptions = platform;
			driver = await remote(testOptions);
		});
	
		it('Get the stock price of Twitter', async function() {
			const StockPriceView = new MainStockPriceView(driver);
			await StockPriceView.setStockTickerSymbol("TWTR");
			await StockPriceView.tapSearchBtn();
	
			const stockName = await StockPriceView.getStockName();
			const stockPrice = await StockPriceView.getStockPrice();
			const stockPriceChangeValue = await StockPriceView.getChangeValue();
	
			assert.equal(stockName, "Twitter, Inc.");
			assert.equal(typeof parseFloat(stockPrice), "number");
			assert.equal(typeof stockPriceChangeValue, "string");	
		});
	
		afterEach(async function() {
			await driver.deleteSession();
		});
	});
});
  • In our test suite we define an array of platforms we want to run our test on, where each platform is represented by its associated capabilities object.
  • The first parameter to the Mocha describe() function is a description string we can provide to name our test suite. We interpolate the platform OS name in this description string, e.g. Stock Price iOS Test and Stock Price Android Test.
  • We allow each iteration of the test suite to take up to 60 seconds max to execute before killing the test session. We selected 60 seconds as the max timeout value to account for the edge case where the WebDriverAgent app is not present on target iOS Simulator and Appium Server needs to first compile & install it before our test session starts.
  • In our test case we exercise the user flow of entering a stock ticker symbol (TWTR), tapping the Submit button, and then verify that the result in the view contains the company name, a stock price number, and the stock price change value string.

Executing the Test Suite

Mocha provides a test runner we can use to execute. For example we can run:

$ mocha --require @babel/register 'tests/*.js'

And this will run our stockPriceApp.js test suite on both iOS and Android. The Mocha test runner can pick up our test suite because we are using Mocha's framework hooks (i.e. describe(), it(), etc.)

For added convenience, we can add a tests command to the scripts section of our package.json:

{
  ...
  
"scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "eject": "expo eject",
    "tests": "mocha --require @babel/register 'tests/*.js'"
  }
	
  ...
}

Now we can run yarn run tests as a shortcut to execute our test suite.

Note: Before you execute test runs, ensure that you have Appium Server running in the background and your iOS Simulator and/or Android Emulator available. You can start Appium Server from your terminal by running:

$ appium

Conclusion

Congratulations, you have written an end-to-end automation test targeting both iOS and Android app builds from one codebase! 🎉 Through this journey we’ve covered a lot of significant topics and you should feel proud of your accomplishments. You now know how to build and test native mobile apps with React Native and WebdriverIO.

Separately, I’m currently working on a platform that enables developers to efficiently diagnose, resolve, and optimize their automation tests. We currently have an ongoing beta program for users to try it and if you want an invitation you can join the waitlist here. Just click on “Request an Invite.”