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.
Appium’s Versatility: Mobile Testing and Beyond
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 Mobile App
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.
Getting Started with Appium
To kick things off, let’s install Appium.
``` brew install libimobiledevice --HEAD brew install carthage npm install -g appium npm install wd npm install -g ios-deploy ```
I’m installing Appium on MacOS with `brew` and `node` already installed.
Now let’s start the Appium service.
``` appium ```
You should now see:
``` [Appium] Welcome to Appium v1.6.3 [Appium] Appium REST http interface listener started on 0.0.0.0:4723 ```
Connecting the Puzzle
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!
CREATING A REACT NATIVE HYBRID MOBILE APP
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!
WRITING TESTS
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.
CREATING A BASE TEST CLASS
Let’s create a base test class that handles high-level functions such as connecting to the Appium service and terminating the connection.
``` import unittest from appium import webdriver class AppiumTest(unittest.TestCase): def setUp(self): self.driver = webdriver.Remote( command_executor='http://127.0.0.1:4723/wd/hub', desired_capabilities={ 'app': 'PATH_OF_.APP_FILE', 'platformName': 'iOS', 'deviceName': 'iPhone Simulator', 'automationName': 'XCUITest', }) def tearDown(self): self.driver.quit() ```
SELECTING ELEMENTS
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.
Finding Elements Using XPaths
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.
Finding Elements Using Accessibility Labels
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.
`````` Welcome To Appium
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")
Transitioning Between Pages
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.
Finally, Let’s Write Some Tests!
You can find all the test cases on my GitHub repo.
Here’s how it looks for an iOS React Native app:
``` import os import unittest import time from appium import webdriver import xml class AppiumTest(unittest.TestCase): def setUp(self): self.driver = webdriver.Remote( command_executor='http://127.0.0.1:4723/wd/hub', desired_capabilities={ 'app': os.path.abspath(APP_PATH), 'platformName': 'iOS', 'deviceName': 'iPhone Simulator', 'automationName': 'XCUITest', }) def tearDown(self): self.driver.quit() def repl(self): import pdb; pdb.set_trace() def dump_page(self): with open('appium_page.xml', 'w') as f: raw = self.driver.page_source if not raw: return source = xml.dom.minidom.parseString(raw.encode('utf8')) f.write(source.toprettyxml()) def _get(self, text, index=None, partial=False): selector = "name" if text.startswith('#'): elements = self.driver.find_elements_by_accessibility_id(text[1:]) elif partial: elements = self.driver.find_elements_by_xpath('//*[contains(@%s, "%s")]' % (selector, text)) else: elements = self.driver.find_elements_by_xpath('//*[@%s="%s"]' % (selector, text)) if not elements: raise Exception() if index: return elements[index] if index is None and len(elements) > 1: raise IndexError('More that one element found for %r' % text) return elements[0] def get(self, text, *args, **kwargs): ''' try to get for X seconds; paper over loading waits/sleeps ''' timeout_seconds = kwargs.get('timeout_seconds', 10) start = time.time() while time.time() - start < timeout_seconds: try: return self._get(text, *args, **kwargs) except IndexError: raise except: pass # self.wait(.2) time.sleep(.2) raise Exception('Could not find text %r after %r seconds' % ( text, timeout_seconds)) def wait_until(self, *args, **kwargs): # only care if there is at least one match return self.get(*args, index=0, **kwargs) class ExampleTests(AppiumTest): def test_loginError(self): self.dump_page() self.wait_until('Login', partial=True) self.get('Please enter your email').send_keys('[email protected]\n') self.get('Please enter your password').send_keys('Password1') self.driver.hide_keyboard() self.get('Press me to submit', index=1).click() self.wait_until('Please check your credentials') assert True def test_loginSuccess(self): self.dump_page() self.wait_until('Login', partial=True) self.get('Please enter your email').send_keys('[email protected]\n') self.get('Please enter your password').send_keys('121212') self.driver.hide_keyboard() self.get('Press me to submit', index=1).click() self.wait_until('Login Successful') assert True def test_loginEmptyEmail(self): self.dump_page() self.wait_until('Login', partial=True) self.get('Please enter your email').send_keys('\n') self.get('Please enter your password').send_keys('121212') self.driver.hide_keyboard() self.get('Press me to submit', index=1).click() self.wait_until('Please enter your email ID') assert True def test_loginEmptyPassword(self): self.dump_page() self.wait_until('Login', partial=True) self.get('Please enter your email').send_keys('[email protected]\n') self.get('Please enter your password').send_keys('') self.driver.hide_keyboard() self.get('Press me to submit', index=1).click() self.wait_until('Please enter your password') assert True ```
And here’s how it looks for an Android React Native app:
``` import os import unittest import time from appium import webdriver import xml class AppiumTest(unittest.TestCase): def setUp(self): abs_path = os.path.abspath(APK_PATH) self.driver = webdriver.Remote( command_executor='http://127.0.0.1:4723/wd/hub', desired_capabilities={ 'app': os.path.abspath(abs_path), 'platformName': 'Android', 'deviceName': 'Nexus 6P API 25', }) def tearDown(self): self.driver.quit() def repl(self): import pdb; pdb.set_trace() def dump_page(self): with open('appium_page.xml', 'w') as f: raw = self.driver.page_source if not raw: return source = xml.dom.minidom.parseString(raw.encode('utf8')) f.write(source.toprettyxml()) def _get(self, text, index=None, partial=False): selector = "content-desc" if text.startswith('#'): elements = self.driver.find_elements_by_accessibility_id(text[0:]) elif partial: elements = self.driver.find_elements_by_xpath('//*[contains(@%s, "%s")]' % (selector, text)) else: elements = self.driver.find_elements_by_xpath('//*[@%s="%s"]' % (selector, text)) if not elements: raise Exception() if index: return elements[index] if index is None and len(elements) > 1: raise IndexError('More that one element found for %r' % text) return elements[0] def get(self, text, *args, **kwargs): timeout_seconds = kwargs.get('timeout_seconds', 10) start = time.time() while time.time() - start < timeout_seconds: try: return self._get(text, *args, **kwargs) except IndexError: raise except: pass # self.wait(.2) time.sleep(.2) raise Exception('Could not find text %r after %r seconds' % ( text, timeout_seconds)) def wait_until(self, *args, **kwargs): # only care if there is at least one match return self.get(*args, index=0, **kwargs) class ExampleTests(AppiumTest): def test_loginError(self): time.sleep(5) self.dump_page() self.wait_until('Please enter your email', partial=False) self.get('Please enter your email').send_keys('[email protected]\n') self.get('Please enter your password').send_keys('Password1') self.driver.hide_keyboard() self.get('Press me to submit', index=0).click() self.wait_until('Please check your credentials') assert True def test_loginSuccess(self): time.sleep(5) self.dump_page() self.wait_until('Please enter your email', partial=False) self.get('Please enter your email').send_keys('[email protected]\n') self.get('Please enter your password').send_keys('121212') self.driver.hide_keyboard() self.get('Press me to submit', index=0).click() self.wait_until('Login Successful') assert True def test_loginEmptyEmail(self): time.sleep(5) self.dump_page() self.wait_until('Please enter your email', partial=False) self.get('Please enter your email').send_keys('\n') self.get('Please enter your password').send_keys('121212') self.driver.hide_keyboard() self.get('Press me to submit', index=0).click() self.wait_until('Please enter your email ID') assert True def test_loginEmptyPassword(self): time.sleep(5) self.dump_page() self.wait_until('Please enter your email', partial=False) self.get('Please enter your email').send_keys('[email protected]\n') self.get('Please enter your password').send_keys('') self.driver.hide_keyboard() self.get('Press me to submit', index=0).click() self.wait_until('Please enter your password') assert True ```
Running Tests
This is the fun part! All you have to do now is:
- Build the React Native code into both an iOS app and an Android app
- CD into the “Testing” folder and run `py.test test-ios.py` and `py.test test-android.py`