Testing always plays a big role when developing a product for customers. By testing in different stages of development we make sure that bugs are found as soon as possible and customers get a great product. The testing level that makes sure the product has met the business requirements and customer experience will be pleasant is called Acceptance testing.

Acceptance tests are often done manually, so a human can validate that not only the functionality works as expected, but also verify that there are no visual bugs, inconsistencies or annoyances in general. However, when doing big suites of repetitive tests, manual testing is not perfect because human error is inevitable. We can reduce amount of these manual repetitive tasks and improve acceptance tests overall by automating the most crucial ones and those which take less time for a machine to validate.

Automating tests takes a lot of time. For example, if you have an Android or iOS app it’s twice the work. If resources and time are limited you might have to choose only one system’s tests to automate. But we have a solution to greatly reduce the cost of automating acceptance tests in a single project for both – Android and iOS.

Our solution is to use the Appium testing tool. It’s free, powerful and, from the few known tools that support iOS and Android operating systems, the only one that allows us to create a single project for testing both, as the setup and interaction with elements is carried out very similarly.

Does it mean any application from these two operating systems can be tested in a single project? Not if they are very different from each other, as the result will be almost the same as automating them separately. What we want is to not have to write whole testing logic twice for an application that has a very similar functionality list and workflows on both platforms. Let’s go over technical details.

Project structure

We write tests in Ruby and use Appium together with Cucumber. Cucumber is a behavior-driven development tool that makes scenarios much more readable to anyone. This is the tool’s classic project structure.

Cucumber framework's classic project structure
Cucumber framework’s classic project structure

It works great for small projects, but once there is a need to add more scenarios and parallel test execution from two different operating systems it becomes much harder to maintain the code with this structure. Therefore, we added some additional steps to make it more object-oriented.

Example of a project structure for identical applications
Example of a project structure for identical applications

This graph illustrates the unusual situation when applications are so identical, that even IDs and XPaths to elements are the same. But this is rare and more often you’ll see these types of structures.

2018-01-26_1139
Example of a project structure where screen classes are separated

In this type of project workflows for different functionalities are mainly the same, only element identifiers or some other nuances differ for each application, so only screen objects are created separately.

2018-01-26_1140
Example of a project structure where test objects and screen classes are separated

Although similar to the previous graph, OS separation already happens on test object level. This means that for specific functionality testing each of the respective test objects can call different methods from different screens.

How we united both platforms in some phases

Environment setup:

  • Reinstall the application;
  • Start the Appium server;
  • Create a hash with desired capabilities (input device info that was stored in Device helpers);
  • Start Appium driver with these capabilities.
#env.rb
if ENV['platform'] == 'Android'
  options = {
    'port' => ENV['port'],
    'portboot' => ENV['boot_port'],
    'sn' => ENV['curdevice'],
    'app' => ENV['apk'],
    'appPackage' => 'com.package'
  }
  desired_capabilities = {
    'deviceName' => options['sn'],
    'platformName' => 'Android',
    'appActivity' => 'com.package.activities,
    'appPackage' => options['appPackage'],
    'noReset' => 'True'
  }
  device_identifier = options['sn']

elsif ENV['platform'] = 'iOS'
  options = {
    'port' => ENV['port'],
    'portboot' => ENV['boot_port'],
    'sn' => ENV['curdevice'],
    'app' => ENV['ipa'],
  }
  filepath = IOS.get_app_path(options['app'])
  device_name = IOS.device_name(options['sn'])
  device_version = IOS.device_version(options['sn'])
  desired_capabilities = {
    "automationName" => "XCUITest",
    "platformName" => "iOS",
    "platformVersion" => device_version,
    'wdaLocalPort' => options['portboot'],
    "udid" => options['sn'],
    "app" => filepath,
    "bundleID" => 'com.package,
    "deviceName" => device_name,
    "xcodeOrgId" => "J7812FK9A0",
    "xcodeSigningId" => "iPhone Distributor",
    'noReset' => false,
    "updatedWDABundleId" => "com.bundle.id",
    "showXcodeLog" => true,
    "useNewWDA" => true
  }
  device_identifier = device_version.tr(".", "_")
else
  raise "Wrong platform! #{ENV['platform']}"
end

Desired capabilities differ for operating systems, but we previously stored the info of the desired platform and device information.

Before do
  if ENV['platform'] == 'Android'
    Device.set_data(info: desired_capabilities, platform: 'Android')
    @screens = Screens.new
    @api_calls = ApiCalls.new
    @tests = TestObjectsAndroid.new(@screens, @api_calls)
  elsif ENV['platform'] == 'iOS'
    Device.set_data(info: desired_capabilities, platform: 'iOS')
    @screens = ScreensIOS.new
    @api_calls = ApiCalls.new
    @tests = TestObjectsIOS.new(@screens, @api_calls)
  end
end

In the same env.rb file we define class instance variable @tests and pass it two arguments, that will allow test objects to access screen classes and API helper methods.

Scenarios

Very similar to how manual test scenarios are defined in Jira tickets, here we define our test scenarios:

  • Given – preconditions;
  • When – test objective;
  • Then – expected result.
#settings.feature
Feature: Settings
  Ability to change
  personal information
  on settings screen

  @profile_picture @smoke_android @smoke_ios @5261 @4889
  Scenario: Changing user's first name
    Given I am logged in
    When I go to settings
    And change my first name
    Then my first name has been changed

The best part of Cucumber framework – feature file. As mentioned before, here we define the main cause behind each scenario and test steps, that it will follow – all in human readable language.

We also add tags to later execute specific groups of scenarios, for example, smoke tests, full regression test suite or just a specific scenario when debugging.

Step definitions

Here we make human readable text into executable code by specifying what each of those lines mean using Regex. Each step calls respective test object methods.

#settings_steps.rb
Given(/^I am logged in$/) do
  @tests.test_registration.log_in_with_default_user
end

When(/^I go to settings$/) do
  @tests.test_settings.go_to_settings
end

And(/^change my first name$/) do
  @tests.test_settings.change_first_name
end

Then(/^my first name has been changed$/) do
  @tests.test_settings.validate_first_name_changed
end

After automating few first acceptance tests, some steps can be re-used in other cases. For example, many scenarios might have a precondition “I am logged in” where it does not matter what credentials you used, so method “log_in_with_default_user” is used.

Data is usually randomly generated to not conflict between parallel threads and created users are later deleted through API.

Test objects

  • Test objects unite different parts of our project:
  • It calls Rest API methods to prepare some preconditions or to validate processes on backend (things like registering the user are faster through the API instead of UI if it’s only a precondition and not the main test objective);
  • It takes generated or static data from data objects, that will be used in the scenario (ex. user information);
    Calls methods from Screen objects, that interact with elements in the app, as well as pass the necessary information, that will be inputted in different fields.
#Android/test_settings.rb
class TestSettingsAndroid
  def initialize(screens, api_calls)
    @screens = screens
    @api_calls = api_calls
    @default_user = Users.default
    @new_user = Users.new
  end

  def go_to_settings
    @screens.home.go_to_settings
  end

  def change_first_name
    @screens.settings.configure_profile
    @screens.settings.change_first_name(@new_user['first_name'])
  end

  def validate_first_name_changed
    current_first_name = @screens.settings.get_current_first_name
    old_first_name = @default_user['first_name']
    raise 'First name was not changed' if current_first_name == old_first_name
  end
end

Here you see user data that was taken from Users module and passed to a screen class method “change_first_name”. This is how we connect data helpers with screen classes in test objects. Therefore, we can create more generic methods in screen classes and pass different data from different test objects.

Screens

  • Screens are objects that store element IDs or XPaths on a specific screen;
  • These elements are used to find, fill, click, count, validate and otherwise interact with elements.
#Android/settings_screen.rb
class SettingsScreenAndroid
  def initialize
    @configure_profile_button = Elements.new(:id, 'configureProfile')
    @first_name_field = Elements.new(:id, 'firstName')
    @first_name_title = "First Name:"
    @user_first_name = Elements.new(:xpath, "//android.widget.TextView[contains(@text,'#{@first_name_title}')]")
  end

  def configure_profile
    @configure_profile_button.click
  end

  def change_first_name(first_name)
    @first_name_field.set(first_name)
  end

  def get_current_first_name
    return @user_first_name.text.slice(@first_name_title)
  end
end

An example of settings screen class for Android. The corresponding one for iOS can have differently defined element IDs and paths or methods that interact with them in a peculiar way.

Summary

The only thing that will always remain separated is how we list connected devices and their OS versions, serial numbers and other information. For Android it’s done with ADB command line utility but for iOS – libimobiledevice library. As well as provisioning certificates, that are needed to install an application on iOS.

Different applications might need different approaches. To avoid duplicating code just because one feature is different for each platform, test objects or screen classes can be divided only for that specific feature, instead of copying all code and only changing it slightly in one specific place.

Hopefully this blog post has given you some insight on how we approach acceptance test automation on two different systems in a single project instead of two. If you have a similar application for these two mobile operating systems and would like us to automate your acceptance tests, or you have some additional questions, be sure to contact TestDevLab, your partner in software quality assurance!

P.S. Props to Ulvis Goldbergs, the writer of the previous blog post “How to Simultaneously Run iOS Acceptance Tests on a Single Physical Computer?” and my mentor of test automation.