If you want to test hybrid mobile apps—or any other kind of app, for that matter—Appium is a great choice. In this article, I provide an Appium example of testing a hybrid mobile app built with React.
First, though, a few words on why Appium is a great choice for hybrid mobile app testing. Appium is an open source automation testing framework for use with native and hybrid mobile apps. It aims to be language and framework agnostic—meaning it can work with any language and testing framework of your choice.
Once you choose a language, more often than not, Appium works with it. Appium can be used to test iOS, Android and Windows applications, even if it's hybrid. Appium aims to automate any mobile app from any language and any test framework, with full access to backend APIs and DBs from test code. It does this by using the WebDriver protocol which is a control interface that enables programs to remotely instruct the behavior of web browsers.
To show how versatile Appium is, in this article we’ll use it for a scenario that is somewhat off the beaten path: Testing a React Native hybrid app using the py.test framework.
My hybrid app is written in ReactJS, and my test cases are written in Python. They all work together by communicating with the Appium WebDriver client.
This driver connects to the Appium service that runs in the background on your local machine. Appium is so robust that you can also choose to not run the service in your local machine! You can just connect to the Sauce Labs cloud-based mobile testing platform and leave it up to them to maintain all of the emulators, simulators and real devices you would like to test against.
To kick things off, let's install Appium.
1brew install libimobiledevice --HEAD2brew install carthage3npm install -g appium4npm install wd5npm install -g ios-deploy6
I'm installing Appium on MacOS with `brew` and `node` already installed.
Now let's start the Appium service with the appium command.
You should now see:
[Appium] Welcome to Appium v1.6.3 [Appium] Appium REST http interface listener started on 0.0.0.0:4723
Alright. We have Appium running. Now what?
All we have to do is write test cases in whatever language and framework we choose and connect them to Appium. I'm choosing Python and `py.test`. You can also choose Javascript and Mocha. But I prefer Python. I'll be testing a React Native hybrid mobile app compiled to both iOS (.app) and Android (.apk).
The test cases instruct Appium to fill in text boxes, click on buttons, check content on the screen, and even wait a specific amount of time for a screen to load. If at any time Appium is unable to find elements, it will throw an exception and your test fails.
Let's start testing!
You can fetch the dummy application from my GitHub.
The app contains two text fields, one for username and one for password, and also a button to “log in.” For the purposes of this article, the login function does nothing, absolutely nothing!
Let's first make sure to have `pytest` and the Python Appium Client installed. It's as simple as:
pip install pytest
pip install Appium-Python-Client
Essentially, `py.test` will connect to the Appium service and launch the application, either on the simulator or physical device. You will have to provide the Appium endpoint and the location of the `.app` or `.apk` file. You can also specify the device you'd like to test it on.
Let's create a base test class that handles high-level functions such as connecting to the Appium service and terminating the connection.
1import unittest2from appium import webdriver3class AppiumTest(unittest.TestCase):4def setUp(self):5self.driver = webdriver.Remote(6command_executor='http://127.0.0.1:4723/wd/hub',7desired_capabilities={8'app': 'PATH_OF_.APP_FILE',9'platformName': 'iOS',10'deviceName': 'iPhone Simulator',11'automationName': 'XCUITest',12})13def tearDown(self):14self.driver.quit()
How do we tell Appium to click on this button or fill in this form? We do this by identifying the elements either with classnames, IDs, XPaths, accessibility labels, or more.
We'll use both XPaths and accessibility labels here, since React Native has not yet implemented IDs.
You can find an element with two of its attributes: its selector and name. For example, if your TextView called “Welcome to Appium,” the selector would be “text” and “Welcome To Appium” would be used as the identifier.
driver.find_element_by_xpath('//*[@text="Welcome To Appium"]')
This selector looks for a DOM element that has a text attribute of “Welcome To Appium.”
It's no shocker that this might not be the only TextView element with “Welcome To Appium.” What happens when there are multiple elements? You can then use the function `find_elements_by_xpath`, which returns a list of the elements that match your query.
And it's also no shocker that these values undergo continuous changes. It would not be fun to rewrite tests for every minor change that happens in the app. That's where accessibility labels come in.
A much more stable option is to find elements using their accessibility labels. These rarely get changed during development. However, in React Native, accessibility labels can only be added to View elements. The workaround is to wrap any element you will need to test in a View element.
<View accessibilityLabel="Welcome To Appium">
<Text>Welcome To Appium</Text>
</View>
Do note that accessibility labels are read by screen readers, so make sure to name sensibly.
You can now access the element like this:
driver.find_elements_by_accessibility_id("Welcome To Appium")
The most common thing you'll see in test cases on either Appium or Selenium is the immense number of sleep statements. This is because the test cases you write will have no knowledge or binding to a screen.
Say your application has a button on one page and a form on another. And this button is used to transition from one page to another. You will want your test case to click on a button, transition the page, and then fill in a form. However, your test case will never know that it just transitioned between two pages! The webdriver protocol can only access elements on a page, and not a page itself. Because of this, sleep for an arbitrary amount of time is very common when you expect a page to transition. However, this is very rudimentary. With Appium, we can instead instruct it to 'wait_until' an element is on a page.
You can find all the test cases on my GitHub repo.
Here’s how it looks for an iOS React Native app:
1import os2import unittest3import time4from appium import webdriver5import xml6class AppiumTest(unittest.TestCase):7def setUp(self):8self.driver = webdriver.Remote(9command_executor='http://127.0.0.1:4723/wd/hub',10desired_capabilities={11'app': os.path.abspath(APP_PATH),12'platformName': 'iOS',13'deviceName': 'iPhone Simulator',14'automationName': 'XCUITest',15})16def tearDown(self):17self.driver.quit()18def repl(self):19import pdb; pdb.set_trace()20def dump_page(self):21with open('appium_page.xml', 'w') as f:22raw = self.driver.page_source23if not raw:24return25source = xml.dom.minidom.parseString(raw.encode('utf8'))26f.write(source.toprettyxml())27def _get(self, text, index=None, partial=False):28selector = "name"29if text.startswith('#'):30elements = self.driver.find_elements_by_accessibility_id(text[1:])31elif partial:32elements = self.driver.find_elements_by_xpath('//*[contains(@%s, "%s")]' % (selector, text))33else:34elements = self.driver.find_elements_by_xpath('//*[@%s="%s"]' % (selector, text))35if not elements:36raise Exception()37if index:38return elements[index]39if index is None and len(elements) > 1:40raise IndexError('More that one element found for %r' % text)41return elements[0]42def get(self, text, *args, **kwargs):43''' try to get for X seconds; paper over loading waits/sleeps '''44timeout_seconds = kwargs.get('timeout_seconds', 10)45start = time.time()46while time.time() - start < timeout_seconds:47try:48return self._get(text, *args, **kwargs)49except IndexError:50raise51except:52pass53# self.wait(.2)54time.sleep(.2)55raise Exception('Could not find text %r after %r seconds' % (56text, timeout_seconds))57def wait_until(self, *args, **kwargs):58# only care if there is at least one match59return self.get(*args, index=0, **kwargs)60class ExampleTests(AppiumTest):61def test_loginError(self):62self.dump_page()63self.wait_until('Login', partial=True)64self.get('Please enter your email').send_keys('foo@example.com\n')65self.get('Please enter your password').send_keys('Password1')66self.driver.hide_keyboard()67self.get('Press me to submit', index=1).click()68self.wait_until('Please check your credentials')69assert True70def test_loginSuccess(self):71self.dump_page()72self.wait_until('Login', partial=True)73self.get('Please enter your email').send_keys('dummyemail@example.com\n')74self.get('Please enter your password').send_keys('121212')75self.driver.hide_keyboard()76self.get('Press me to submit', index=1).click()77self.wait_until('Login Successful')78assert True79def test_loginEmptyEmail(self):80self.dump_page()81self.wait_until('Login', partial=True)82self.get('Please enter your email').send_keys('\n')83self.get('Please enter your password').send_keys('121212')84self.driver.hide_keyboard()85self.get('Press me to submit', index=1).click()86self.wait_until('Please enter your email ID')87assert True88def test_loginEmptyPassword(self):89self.dump_page()90self.wait_until('Login', partial=True)91self.get('Please enter your email').send_keys('dummyemail@example.com\n')92self.get('Please enter your password').send_keys('')93self.driver.hide_keyboard()94self.get('Press me to submit', index=1).click()95self.wait_until('Please enter your password')96assert True97
And here’s how it looks for an Android React Native app:
1import os2import unittest3import time4from appium import webdriver5import xml6class AppiumTest(unittest.TestCase):7def setUp(self):8abs_path = os.path.abspath(APK_PATH)9self.driver = webdriver.Remote(10command_executor='http://127.0.0.1:4723/wd/hub',11desired_capabilities={12'app': os.path.abspath(abs_path),13'platformName': 'Android',14'deviceName': 'Nexus 6P API 25',15})16def tearDown(self):17self.driver.quit()18def repl(self):19import pdb; pdb.set_trace()20def dump_page(self):21with open('appium_page.xml', 'w') as f:22raw = self.driver.page_source23if not raw:24return25source = xml.dom.minidom.parseString(raw.encode('utf8'))26f.write(source.toprettyxml())27def _get(self, text, index=None, partial=False):28selector = "content-desc"29if text.startswith('#'):30elements = self.driver.find_elements_by_accessibility_id(text[0:])31elif partial:32elements = self.driver.find_elements_by_xpath('//*[contains(@%s, "%s")]' % (selector, text))33else:34elements = self.driver.find_elements_by_xpath('//*[@%s="%s"]' % (selector, text))35if not elements:36raise Exception()37if index:38return elements[index]39if index is None and len(elements) > 1:40raise IndexError('More that one element found for %r' % text)41return elements[0]42def get(self, text, *args, **kwargs):43timeout_seconds = kwargs.get('timeout_seconds', 10)44start = time.time()45while time.time() - start < timeout_seconds:46try:47return self._get(text, *args, **kwargs)48except IndexError:49raise50except:51pass52# self.wait(.2)53time.sleep(.2)54raise Exception('Could not find text %r after %r seconds' % (55text, timeout_seconds))56def wait_until(self, *args, **kwargs):57# only care if there is at least one match58return self.get(*args, index=0, **kwargs)59class ExampleTests(AppiumTest):60def test_loginError(self):61time.sleep(5)62self.dump_page()63self.wait_until('Please enter your email', partial=False)64self.get('Please enter your email').send_keys('foo@example.com\n')65self.get('Please enter your password').send_keys('Password1')66self.driver.hide_keyboard()67self.get('Press me to submit', index=0).click()68self.wait_until('Please check your credentials')69assert True70def test_loginSuccess(self):71time.sleep(5)72self.dump_page()73self.wait_until('Please enter your email', partial=False)74self.get('Please enter your email').send_keys('dummyemail@example.com\n')75self.get('Please enter your password').send_keys('121212')76self.driver.hide_keyboard()77self.get('Press me to submit', index=0).click()78self.wait_until('Login Successful')79assert True80def test_loginEmptyEmail(self):81time.sleep(5)82self.dump_page()83self.wait_until('Please enter your email', partial=False)84self.get('Please enter your email').send_keys('\n')85self.get('Please enter your password').send_keys('121212')86self.driver.hide_keyboard()87self.get('Press me to submit')88