Cross-Platform Suites and Parallel Execution via testng.xml

7 min read

Parallel execution is where TestNG outperforms JUnit 5 for mobile suites out of the box. One XML configuration file controls parallelism across tests, classes, and methods — and combined with testng.xml parameter injection, it's the standard way to run the same tests on Android and iOS simultaneously.

Parallelism levels

TestNG supports four parallel modes:

ModeWhat runs in parallel
suiteMultiple <suite-files> in a master suite
testsMultiple <test> blocks within one suite
classesMultiple test classes within a <test> block
methodsIndividual test methods across classes

For mobile cross-platform testing, parallel="tests" is the most useful — each <test> block represents a platform (Android / iOS), and they run concurrently.

Cross-platform parallel configuration

<suite name="Mobile Suite" parallel="tests" thread-count="2">
 
  <test name="Android Regression">
    <parameter name="platform" value="Android"/>
    <parameter name="deviceName" value="emulator-5554"/>
    <classes>
      <class name="com.example.tests.LoginTest"/>
      <class name="com.example.tests.CheckoutTest"/>
      <class name="com.example.tests.SearchTest"/>
    </classes>
  </test>
 
  <test name="iOS Regression">
    <parameter name="platform" value="iOS"/>
    <parameter name="deviceName" value="iPhone 15"/>
    <classes>
      <class name="com.example.tests.LoginTest"/>
      <class name="com.example.tests.CheckoutTest"/>
      <class name="com.example.tests.SearchTest"/>
    </classes>
  </test>
 
</suite>

thread-count="2" means two <test> blocks run simultaneously — Android in one thread, iOS in another. Set this equal to the number of <test> blocks for maximum parallelism.

Device matrix — multiple devices per platform

Run the same tests on multiple Android versions simultaneously:

<suite name="Mobile Suite" parallel="tests" thread-count="4">
 
  <test name="Android API 30">
    <parameter name="platform" value="Android"/>
    <parameter name="deviceName" value="Pixel_4_API_30"/>
    <parameter name="platformVersion" value="11"/>
    <classes><class name="com.example.tests.LoginTest"/></classes>
  </test>
 
  <test name="Android API 33">
    <parameter name="platform" value="Android"/>
    <parameter name="deviceName" value="Pixel_7_API_33"/>
    <parameter name="platformVersion" value="13"/>
    <classes><class name="com.example.tests.LoginTest"/></classes>
  </test>
 
  <test name="iOS 16">
    <parameter name="platform" value="iOS"/>
    <parameter name="deviceName" value="iPhone 14"/>
    <parameter name="platformVersion" value="16.4"/>
    <classes><class name="com.example.tests.LoginTest"/></classes>
  </test>
 
  <test name="iOS 17">
    <parameter name="platform" value="iOS"/>
    <parameter name="deviceName" value="iPhone 15"/>
    <parameter name="platformVersion" value="17.2"/>
    <classes><class name="com.example.tests.LoginTest"/></classes>
  </test>
 
</suite>

DriverManager receiving parameters

BaseTest receives testng.xml parameters through @Parameters in @BeforeTest:

@BeforeTest
@Parameters({"platform", "deviceName", "platformVersion"})
public void setUp(String platform, String deviceName,
                  @Optional("latest") String platformVersion) {
    DriverManager.initDriver(platform, deviceName, platformVersion);
}

@Optional("latest") provides a default value for optional parameters — if platformVersion isn't in testng.xml, it defaults to "latest".

Parallel methods within a test

For tests that don't share device state, parallel="methods" within a <test> block runs each test method in a separate thread on the same device:

<suite name="Mobile Suite" parallel="methods" thread-count="3">
  <test name="Android">
    <parameter name="platform" value="Android"/>
    <classes>
      <class name="com.example.tests.LoginTest"/>
    </classes>
  </test>
</suite>

This requires a separate driver per method — the ThreadLocal<AppiumDriver> must be initialised at @BeforeMethod scope, not @BeforeTest:

@BeforeMethod
@Parameters("platform")
public void setUp(String platform) {
    DriverManager.initDriver(platform); // creates a new driver for this thread
}
 
@AfterMethod
public void tearDown() {
    DriverManager.quitDriver();
}

Method-level parallelism requires multiple connected devices or emulator instances — each method's driver is a separate Appium session.

Smoke vs regression split

Use separate testng.xml files for different CI stages:

testng-smoke.xml — fast subset, runs on every PR:

<suite name="Smoke" parallel="tests" thread-count="2">
  <test name="Android Smoke">
    <parameter name="platform" value="Android"/>
    <groups><run><include name="smoke"/></run></groups>
    <classes><class name="com.example.tests.LoginTest"/></classes>
  </test>
  <test name="iOS Smoke">
    <parameter name="platform" value="iOS"/>
    <groups><run><include name="smoke"/></run></groups>
    <classes><class name="com.example.tests.LoginTest"/></classes>
  </test>
</suite>

Mark tests with @Test(groups = "smoke") to include them in the smoke run.

testng-regression.xml — full suite, runs nightly:

<suite name="Regression" parallel="tests" thread-count="4">
  <!-- all test blocks -->
</suite>

Running from Maven

<!-- pom.xml -->
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.2.5</version>
      <configuration>
        <suiteXmlFiles>
          <suiteXmlFile>${suiteFile}</suiteXmlFile>
        </suiteXmlFiles>
      </configuration>
    </plugin>
  </plugins>
</build>
# Run smoke suite
mvn test -DsuiteFile=testng-smoke.xml
 
# Run regression suite
mvn test -DsuiteFile=testng-regression.xml

The ${suiteFile} property lets CI pass the file path as a command-line argument without modifying the POM.

// tip to track lessons you complete and pick up where you left off across devices.