Skip to content
Colin Wren

Testing Your React Native App With Expo & Appium

Testing, JavaScript, Automation, Software Development5 min read

Appium driving components in expo and storybook
An example of Appium driving the Android Emulator to run an automated drag and drop test within the Storybook React Native framework

At the start of July I decided to bite the bullet and take a run at building my own app, JiffyCV. I had built a number of React Native apps for others but this was going to be all mine and so far it’s been an incredibly rewarding experience.

I started the project by doing some user research and then used that to build a series of mockups in Figma to carry out usability testing on the solution I felt delivered the most value to the user.

After I was happy that the app was usable and valuable I started turning those designs into React components using Storybook and Expo. This allowed me to quickly turn around the components, but there was one aspect missing.

I wanted to be able to test the components on multiple levels during development. Unit tests can only do so much and can’t tell you if everything works on different devices, so I started looking at UI automation.

I initially started looking at Detox as it allows you to get closer to the React Native runtime and this means you can check more of the inner workings of the app as opposed to a fully black-box solution.

Unfortunately Detox does not play well with Expo and even when I built a brand new React Native app from scratch I could never really get it to work so I retired that idea relatively quickly.

The next tool I looked at was Appium which doesn’t state that it works with Expo. There are ways of making it play nice enough with the Expo Client to allow you to run your automation without the need to build the standalone binary.

I’ll be covering how to run Appium against both the Expo Client and a standalone app as the only difference is the capabilities and the way to setup the client before running your tests.

Setting Up Appium

Compared to Detox, setting up Appium can feel a little daunting but the appium-doctor tool is a massive time saver as it’ll tell you if your environment is configured correctly.

I’m not going to cover how to install all the dependencies as that would be an article itself, but there are a number of blog posts kicking around the internet that help with this.

The main things you’ll want to focus on are getting Appium Desktop and either the Android emulator or iOS simulator running (I found the Android setup easier on both Linux and Mac so I’d advise starting there).

Once you have Appium Desktop working with the emulator you can use the emulator to run the Expo Client & load your app at which point you can use Appium Desktop’s Session interface (use the capabilities below) to connect to the emulator and start seeing the structure of your app’s UI.

2 "platformName": "android",
3 "deviceName": "[NAME OF YOUR EMULATOR]"

Automating the Expo Client with Appium

There’s not much information on the internet about using Appium with the Expo Client but after you learn the ‘secret trick’ it’s actually pretty easy to get it working on different platforms.

When you launch the Expo Client you may notice that the Expo apps have a exp:// link and this link is what we’ll use to launch the app under test in the Expo Client.

There’s no easy cross-platform way to do this though as iOS requires a more convoluted approach than Android but if you abstract the logic out into functions you can build re-usable commands to handle all this.

The execution flow for launching the app in the Expo Client on Android looks like this:

  1. Launch the Expo Client app
  2. Close the Expo Client app
  3. Launch the Expo Client app again
  4. Execute a mobile:deeplink to exp:// (or applicable hostname)
  5. Wait for an element on the first page of the app to be visible

Technically steps 2 and 3 could be replaced with sending the keycode 82 as that should trigger a Expo Client reload which is needed to ensure that the state of the app is reset.

For iOS there’s a bit more complexity:

  1. Launch Safari
  2. Enter exp:// (or applicable hostname) into the Safari address bar
  3. Accept the loading of the Expo Client (through automation or using the autoAcceptAlerts capability)
  4. Deal with the first run pop up if needed (press the ‘Got It’ button followed by the close button for the dev menu)
  5. Reload the Expo Client by sending a shake() command and pressing the ‘Reload’ button in the dev menu
  6. Wait for an element on the first page of the app to be visible

iOS is made even more awkward thanks to how Apple seem to change the behaviour of the Safari URL input between versions of iOS, for instance on iOS 13.3 the URL bar is a button that needs to be pressed before you can input anything but on iOS 13.6 it’s an auto-focused text field.

Once you’ve got the app under test loaded in the Expo Client and the Expo Client reloaded so the app state is reset then you can start the actual automation of your app.

Here’s the capabilities and functions I’m using for Android

1import {remote} from "webdriverio";
3const capabilities = {
4 platformName: 'android',
5 deviceName: 'AVD_NAME', // Change to the name of the AVD you're using
6 automationName: 'UiAutomator2',
7 pkg: 'host.exp.exponent',
8 intentAction: 'android.intent.action.VIEW',
9 activity: 'host.exp.exponent.experience.HomeActivity',
10 appWaitForLaunch: true,
13const options = {
14 path: '/wd/hub/',
15 port: 4723,
18async function launchExpoAndroid() {
19 const client = await remote({ ...options, capabilities});
20 await client.closeApp();
21 await client.startActivity(androidCaps.pkg, androidCaps.activity,); //Reload to force update
22 await client.execute('mobile:deepLink', { url: 'exp://', package: androidCaps.pkg });
23 return client;
26describe('Example Test', () => {
27 let client;
29 beforeAll(async () => {
30 client = await launchExpoAndroid();
31 });
33 afterAll(async () => {
34 await client.deleteSession();
35 });
37 // Add tests here
Calling launchExpoAndroid before the test gets the Android Emulator to load the Expo Client and makes it ready for automating

And the capabilities and functions I’m using for iOS

1import {remote} from "webdriverio";
3const capabilities = {
4 platformName: 'iOS',
5 platformVersion: '13.6',
6 deviceName: 'DEVICE_NAME', // Change this to the device you want to run
7 automationName: 'XCUITest',
8 bundleId: '',
9 autoAcceptAlerts: true,
12const options = {
13 path: '/wd/hub/',
14 port: 4723,
17async function expoDeepLink13_6(client) {
18 const urlFieldSelector = 'label == "Address"';
19 const urlField = await client.$(`-ios predicate string:${ urlFieldSelector }`);
20 await urlField.setValue('exp://\uE007');
23async function handleFirstLaunch(client) {
24 try {
25 const gotItSelector = 'label == "Got it"';
26 const gotIt = await client.$(`-ios predicate string:${gotItSelector}`);
27 await;
28 const reloadSelector = 'type == \'XCUIElementTypeOther\' && name CONTAINS \'Reload\'';
29 const reload = await client.$(`-ios predicate string:${ reloadSelector }`);
30 await;
31 } catch (err) {
32 console.log('No need to handle first launch');
33 }
36async function reloadExpo(client) {
37 await client.shake();
38 const reloadSelector = 'type == \'XCUIElementTypeOther\' && name CONTAINS \'Reload\'';
39 const reload = await client.$(`-ios predicate string:${ reloadSelector }`);
40 await;
43async function launchExpoIOS() {
44 const client = await remote({ ...options, capabilities});
45 await client.execute('mobile: launchApp', { bundleId: '' });
46 await expoDeepLink13_6(client);
47 await handleFirstLaunch(client);
48 await reloadExpo(client);
49 return client;
52describe('Example Test', () => {
53 let client;
55 beforeAll(async () => {
56 client = await launchExpoIOS();
57 });
59 afterAll(async () => {
60 await client.deleteSession();
61 });
63 // Add tests here
iOS requires a little more work to get things ready but if you create functions for dealing with the differences in Safari’s behaviour across iOS versions it makes things a little easier to understand

Automating a standalone binary generated by Expo

Luckily a standalone binary is a lot easier to get up and running as you can just use the capabilities to install the app and run it or have it boot an existing installation.

I’ve found the latter to be more reliable, at least on Android where I use adb install [PATH TO APK] to install the .apk on the emulator before running Appium with the following capabilities.

1import {remote} from "webdriverio";
3const capabilities = {
4 platformName: 'android',
5 deviceName: 'AVD_NAME', // Change to the name of the AVD you're using
6 avd: 'AVD_NAME', // Change to the name of the AVD you're using
7 automationName: 'UiAutomator2',
8 appPackage: 'PACKAGE_NAME', // change this to your package name in app.json
9 appActivity: 'host.exp.exponent.MainActivity',
10 appWaitForLaunch: true,
13const options = {
14 path: '/wd/hub/',
15 port: 4723,
18async function launchExpoAndroid() {
19 const client = await remote({ ...options, capabilities});
20 await client.closeApp();
21 await client.launchApp();
22 return client;
26describe('Example Test', () => {
27 let client;
29 beforeAll(async () => {
30 client = await launchExpoAndroid();
31 });
33 afterAll(async () => {
34 await client.deleteSession();
35 });
37 // Add tests here
Without the need to get the Expo Client to load the app things are a little simpler

Automating the Storybook React Native UI

As I mentioned at the start of the article my use case for Expo at the moment is to build my component library up for the app I’ll be eventually building.

The are a few reasons I chose to automate the component library and not just use the automation in the main app.

  • I’m building the automation using the Atomic Design pattern so it makes sense to have my ‘atom’, ‘molecule’ and ‘organism’ page objects in the same code base as the ‘atom’, ‘molecule’ and ‘organism’ level components that make up my app’s UI
  • If I can catch automation issues in the component library I can prevent these from making it into the main app and fix them earlier
  • The component library is used to show off the different states that components can have so it’s an ideal candidate for building the page objects against
  • This leaves the main app’s code base to be concerned only with laying out screen & handling the app’s state and likewise, the automation at that level will model the ‘template’ and ‘page’ level page objects.

There’s only a couple actions that are needed to automate the storybook UI:

  • We need to be able to select a story to load from the story list
  • We need to be able to load the preview for the selected story
  • We need to be able to search for a story, especially if the story list gets too long

Below is a snippet of a simple atomic structure for this:

1// Atom - Storybook item, basic UI element for tab and list
2class StorybookItem {
3 constructor(client, element){
4 this.client = client;
5 this.element = element;
6 }
8 press() {
10; // needed as sometimes the Story list items don't respond
11 }
13 getText() {
14 this.element.getText();
17// Molecules - Story list in navigator, tabs in tab bar
18class StoryListItems {
19 constructor(client) {
20 this.client = client;
21 }
23 async searchForItem(item) {
24 const searchBox = await this.client.$('~Storybook.ListView.SearchBar');
25 await searchBox.setValue(item);
26 }
28 async getExampleItem() {
29 await this.searchForItem('Example Item');
30 const el = await this.client.$('~Storybook.ListItem.Example Item');
31 return new StorybookItem(this.client, el);
32 }
35class StorybookTabs {
36 constructor(client) {
37 this.client = client;
38 }
39 async getNavigatorTab() {
40 const el = await this.client.$('~NAVIGATOR');
41 return new StorybookItem(this.client, el);
42 }
44 async getPreviewTab() {
45 const el = await this.client.$('~PREVIEW');
46 return new StorybookItem(this.client, el);
47 }
51// Organisms - Story List and Tab bar
52class StorybookNavigator {
53 constructor(client) {
54 this.storylist = new StoryListItems(client);
55 }
57 async goToExampleItem() {
58 const item = await this.storyList.getExampleItem();
59 await;
60 }
63class StorybookTabBar {
64 constructor(client) {
65 this.client = client;
66 }
67 async init() {
68 this.tabs = new StorybookTabs(this.client);
69 this.navigatorTab = await this.tabs.getNavigatorTab();
70 this.previewTab = await this.tabs.getPreviewTab();
71 }
73 async goToNavigator() {
74 await;
75 }
77 async goToPreview() {
78 await;
79 }
82// Page - Storybook UI
83class Storybook {
84 constructor(client) {
85 this.client = client;
86 }
88 async init() {
89 this.tabs = new StorybookTabBar(this.client);
90 await this.tabs.init();
91 this.navigator = new StorybookNavigator(this.client);
92 }
94 async goToExampleItem() {
95 await this.tabs.goToNavigator();
96 await this.navigator.goToExampleItem();
97 await this.tabs.goToPreview();
98 }
101// Example Use Case
102describe('Storybook atomic design example', () => {
103 beforeAll(async () => {
104 const client = await launchExpo();
105 const storybook = new Storybook(client);
106 await storybook.init();
107 await storybook.goToExampleItem();
108 });
109 afterAll(async () => {
110 await client.deleteSession();
111 });
112 // Tests for components shown on Example Item page
You can see the different atomic levels and their responsibilities. The Storybook class coordinates the actions the user would take to go from the preview tab to the navigator tab, select the story, click it and then go back to the preview tab to interact with the components in that story

Next Steps: Automating Android and iOS apps with Appium

Over the following weeks I’ll be posting about Appium and how I’m using it as well as any gotchas, architectural patterns and how to get it running in CI.

If you’re working with Appium or looking to start using it feel free to leave a comment below with any questions you may have and I’ll try to answer. Give me a follow if you want to read my next posts in this series.