In Part 1, you made every scenario independent in code — logically isolated, even though every thread still drove the same physical device. Those were Steps 1 through 3. The driver moved into a ThreadLocal, so each thread owns its own and two threads can never grab the same one (Step 1); its lifecycle moved into per-scenario @Before/@After hooks, so that driver is built and torn down on the right thread at the right moment (Step 2); and your screen objects became @ScenarioScope beans, rebuilt fresh per scenario so their element proxies always bind to the live driver (Step 3). The result is a framework with no shared state left for two threads to collide over — thread-safe at its core. This part picks the build back up at Step 4.
But thread-safe only means ready to run in parallel — not actually running in parallel. The framework still drives a single device, and the parallel switch is still off — so all that isolation from Part 1 is potential energy, stored up and doing no work yet. This part puts it to work. You'll hand each thread its own physical device with a device pool, isolate the automation ports so multiple Appium sessions coexist on one machine, flip parallel execution on, and wire up the retry safety net promised back in Article 6. By the end, two emulators light up and drive at once — and a 35-minute suite finishes in roughly half the time, dropping further (close to linearly) with every device you add.
💡 This is Article 7 (Part 2) of the Framework Series — the series finale. It assumes the isolated core from Part 1 is already in place. In particular, theScenarioHooksclass already callsdevicePool.acquire()andcreateDriver(slot)— against theDevicePoolandDeviceSlotyou're about to build here. Part 1 wrote the callers; this part builds what they call.
Step 4: Give Each Thread Its Own Device
Per-thread drivers are useless if every thread points them at the same phone. Now we make "each thread owns its device" literally true: a device pool that hands out a distinct device to each scenario and takes it back when the scenario ends.
The device slot
First, a small value object describing one runnable device — its UDID and the port its automation server will use:
package com.mobileframework.driver;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DeviceSlot {
private String udid;
private int port;
}The port matters more than it looks, and it's the first thing people miss. Every parallel Appium session on the same host needs its own automation-server port, or two sessions will try to bind the same port and one will fail to start. We'll wire that port into the driver options below.
💡 Port isolation is a same-host concern — on a cloud device farm you skip it entirely. The whole reasonportexists is that your two emulators share one host: your machine. On a cloud device farm, each session runs on the provider's own isolated infrastructure, so there's no shared port to collide over — the provider manages that internally, and you select devices through vendor capabilities instead. TheDeviceSlotyou're building is exactly the seam that absorbs this: a local slot carries aport, a cloud slot doesn't. The pool andDriverManagernever notice the difference. Pointing this exact machinery at the cloud is the whole job of the Framework Extensions opener, Integrating Your Mobile Framework with Device Farms.
The pool itself
The pool holds the available slots in a thread-safe blocking queue. When a scenario starts, it take()s a slot — and if none is free, it waits until one is returned. That blocking behavior is a feature: it's what stops you from ever running more sessions than you have devices.
package com.mobileframework.driver;
import org.springframework.stereotype.Component;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
@Component
public class DevicePool {
private final BlockingQueue<DeviceSlot> available;
public DevicePool(DevicePoolConfig config) {
this.available = new LinkedBlockingQueue<>(config.getSlots());
}
public DeviceSlot acquire() {
try {
return available.take(); // blocks until a device is free
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for a device", e);
}
}
public void release(DeviceSlot slot) {
available.offer(slot);
}
}The slots come from configuration, bound with the same typed-config pattern from Article 6:
package com.mobileframework.driver;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
@ConfigurationProperties(prefix = "device-pool")
@Getter
@Setter
public class DevicePoolConfig {
private List<DeviceSlot> slots = new ArrayList<>();
}And the pool is described in a config file — following the exact same dimension-file pattern from Article 6. Create a single config/device-pool.yml with two profile-gated documents, one per platform:
---
spring:
config:
activate:
on-profile: android
device-pool:
slots:
- udid: emulator-5554
port: 8200
- udid: emulator-5556
port: 8201
---
spring:
config:
activate:
on-profile: ios
device-pool:
slots:
- udid: 00008110-XXXXXXXXXXXX
port: 8100
- udid: 00008110-YYYYYYYYYYYY
port: 8101Then add it to the spring.config.import line in application.properties alongside the existing dimension files:
spring.config.import=optional:classpath:config/platforms.yml,\
optional:classpath:config/devices.yml,\
optional:classpath:config/environments.yml,\
optional:classpath:config/phases.yml,\
optional:classpath:config/device-pool.ymlSpring loads the android document when the android profile is active and the ios document when ios is active — the same spring.config.activate.on-profile mechanism that gates the documents in platforms.yml. One file, two documents, only the right one ever contributes. The @Before hook from Part 1 already does the rest: devicePool.acquire() leases a slot, createDriver(slot) builds a session against that device on that port, and @After returns the slot for the next scenario.
💡 This is OCP from the framework blueprint — the Open/Closed Principle, exactly as in Article 6. Scaling from 2 devices to 4 means adding two entries todevice-pool.yml. You don't touchDevicePool,DriverManager, or any test. The framework opens for extension (more devices) without reopening for modification (no code change).
⚠️ UDIDs are machine-specific — keep real ones out of the committed file. Just like the single-device setup in Article 6, the UDIDs in device-pool.yml are placeholders for the repo. Your real emulator and simulator IDs differ from your teammate's, so each developer keeps their actual values in a git-ignored local override. The committed file documents the shape of the pool; personal values stay personal.Port isolation, per platform
The port field feeds a different capability on each platform, and getting these right is what lets multiple sessions coexist on one machine. We cover Android first, then iOS — as throughout the series.
Android — appium:systemPort. The UiAutomator2 driver runs a small HTTP server on the host for each session. By default it auto-picks a free port from the 8200–8299 range, but the driver docs recommend setting it explicitly for parallel runs to avoid races. That's the .setSystemPort(slot.getPort()) line you already added to DriverManager in Part 1:
UiAutomator2Options options = new UiAutomator2Options()
.setUdid(slot.getUdid())
.setSystemPort(slot.getPort()) // unique per parallel Android session
// ...iOS — appium:wdaLocalPort. The XCUITest driver talks to WebDriverAgent over a port that defaults to 8100. Two simulators sharing 8100 will collide, so each session needs its own — the .setWdaLocalPort(slot.getPort()) line:
XCUITestOptions options = new XCUITestOptions()
.setUdid(slot.getUdid())
.setWdaLocalPort(slot.getPort()) // unique per parallel iOS session
// ...🚨 A shared automation port is the most common parallel-on-one-host failure. If your second Android session dies with a port-bind error, or your second iOS session hangs waiting for WebDriverAgent, an overlappingsystemPort/wdaLocalPortis the first thing to check. One unique port per slot, every time.
Step 5: Turn On Parallel Execution
Everything so far — across both parts — is the machinery. Flipping it on is three lines in junit-platform.properties:
# Existing Cucumber configuration
cucumber.junit-platform.naming-strategy=long
cucumber.glue=com.mobileframework.steps
cucumber.plugin=pretty
# Parallel execution
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=2cucumber.execution.parallel.enabled=trueturns on concurrent scenario execution. Off by default — without it, the other two lines do nothing.cucumber.execution.parallel.config.strategy=fixedtells the engine to use a fixed number of parallel workers. The alternative,dynamic, scales the worker count to your CPU core count — which is the wrong basis for mobile, where the real limit is devices, not cores.cucumber.execution.parallel.config.fixed.parallelism=2sets that worker count. Match it to your device-pool size. Two devices, two workers.
✅ Verify it's actually parallel. Boot two emulators (confirm with adb devices — you should see emulator-5554 and emulator-5556), make sure device-pool.yml lists both, and have at least two scenarios tagged @smoke. Then run:
mvn clean test -Dspring.profiles.active=android,local-android,smoke -Dcucumber.filter.tags="@smoke"💡 These flags are independent but all three profile groups are required.androidactivates the platform document inplatforms.yml.local-androidloads yourapplication-local-android.properties— which setsdriver.app-pathto your local.apk. Without it,app-pathis null and Appium connects to the emulator without launching the app.smokeactivates the phase document inphases.yml.cucumber.filter.tags="@smoke"is a separate Cucumber flag — the Springsmokeprofile does not filter scenarios automatically.
In the console you'll see two scenarios start before either finishes — their pretty output interleaves instead of running strictly top to bottom. On screen, both emulators light up and drive at once. A run that took two scenario-durations sequentially now takes roughly one. That interleaving is the proof the isolation works: two scenarios, two drivers, two devices, zero collisions.
What this buys you at scale
Two devices roughly halve the suite's runtime — but the real payoff shows up as you add more devices, because the speedup costs you no code at all. To go from two devices to four, you add two slots to device-pool.yml and raise parallelism in junit-platform.properties to match. That's the entire change — no test, screen object, or driver code is touched. Here's how that scales a 35-minute sequential suite:
| Devices | Approx. run time | Speedup | Realistic on |
|---|---|---|---|
| 1 (sequential) | 35 min | 1× | Any machine |
| 2 | ~18 min | ~2× | Any laptop |
| 4 | ~9 min | ~4× | A strong laptop |
| 6 | ~6.5 min | ~5× | Device farm |
| 8 | ~5 min | ~7× | Device farm |
| 10 | ~4 min | ~9× | Device farm |
The headline is that a 35-minute suite collapses to roughly 4 minutes — and you already saw what that costs: a few lines of config. Notice, though, that the speedup isn't perfectly proportional, and the gap grows the more devices you add: 10 devices gets you about 9× faster, not a clean 10×. Two things hold it back: no suite divides perfectly evenly across workers, and each device carries a fixed startup cost — booting it, spinning up its Appium session, launching the app — that stays the same no matter how little testing the device then does. The more you split the work, the smaller each device's share of testing gets, so that fixed cost stands out more against it. Read the table for the trend, not the exact seconds: every device you add buys a near-proportional cut in wall-clock time.
⚠️ You run out of laptop before you run out of scaling — which is what the right-hand column tracks. Each extra emulator is real RAM and CPU, and most machines start thrashing around 3–4 local emulators; past that, adding "devices" makes runs slower, not faster. That's why the bottom rows read Device farm: those counts assume each device has the resources to run unimpeded — true on a farm, not on a loaded laptop. Outgrowing what one machine can boot is exactly the cue to point this same pool at the cloud, which is where the Framework Extensions series (see What's Next below) picks up.
💡 The device pool is the real safety net here. Even if you setparallelismhigher than your device count by mistake, nothing crashes — the extra workers simply block onacquire()until a device frees up. You lose efficiency, not correctness. Still, keep the two numbers equal so no worker sits idle.
Step 6: Retry Failed Scenarios — and Know Where the Line Is
Back in Article 6 you defined test.retry-count in TestRunConfig and left it unwired, with a promise that retries would arrive here. They do — just not where you'd expect, and the why is worth understanding.
Retry cannot be a Spring property that your code reads. To retry a flaky scenario honestly, you have to run the whole thing again from scratch — a fresh driver, a fresh device, a fresh Spring scope, the @Before and @After hooks firing again. And it has to be the whole scenario, not just the step that failed: a scenario is a sequence that builds up state as it runs, so re-running one step in isolation would test it against whatever the earlier steps left behind — a half-loaded screen, a stale session — with no clean way to rewind the app to mid-scenario. (Cucumber has no step-level retry for exactly this reason; the scenario is its smallest re-runnable unit.) None of that can be orchestrated from inside the scenario, because the scenario is the very thing being retried. Retry belongs to the layer above test execution: the test runner.
💡 This is the same lesson as Article 6's "which tests run" split. There, scenario selection lived in Cucumber (cucumber.filter.tags), not Spring, because it's decided before the context exists. Here, scenario retry lives in the Maven test runner, not Spring, because it wraps the entire execution. Some settings simply live outside your application code — and learning to spot which ones is half the battle.There's also an honesty point worth stating plainly: the JUnit Platform engine has no scenario retry of its own. Retrying a failed test isn't the engine's job — it's delegated to the build tool, which is exactly the boundary this step is about. Maven Surefire supplies it through rerunFailingTestsCount, which re-runs failing scenarios within the same mvn test invocation — the approach the cucumber-junit-platform-engine docs point to. It leans on the cucumber.junit-platform.naming-strategy=long you already set, so Surefire can tell one scenario from another:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<rerunFailingTestsCount>${retry.count}</rerunFailingTestsCount>
</configuration>
</plugin>Drive it from a Maven property so it stays overridable, defaulting to 0:
<properties>
<retry.count>0</retry.count>
</properties>Now a regression run can ask for one retry without touching code:
mvn clean test -Dspring.profiles.active=android,local-android,regression -Dretry.count=1Back in Article 6, test.retry-count recorded how many retries you wanted — but on its own it does nothing. Surefire's retry.count is what actually re-runs the failures. Keep both set to the same number: test.retry-count is the readable statement of what you want, and Surefire is what carries it out.
⚠️ Retry is a safety net, not a cure for flaky tests. If a test fails and then passes on retry, the retry didn't fix anything — it just hid a real problem: a timing issue, a missing wait, or test data shared between scenarios. Use a single retry to ride out a rare, random glitch — a device hiccup, a brief network stall. But if a scenario only passes because it was retried, that's a bug to fix, not a test to keep re-running.
💡 What a retried test should look like in the report. A scenario that fails and then passes on retry should not be logged as a clean pass — that would bury the very flake you want to find. The right outcome is a flake: the build stays green (the retry succeeded), but the report keeps the failed attempt and surfaces it separately — Surefire, for instance, adds aFlakes:count alongside the usualTests run / Failures / Skipped. That visibility is the whole point of the rule above — the flake count is your to-fix list. (If you'd rather flakes not pass quietly, Surefire'sfailOnFlakeCountcan fail the build once they cross a threshold — check the Surefire docs for the version that introduced it.)
💡 What to do with a flake once you see it: quarantine, don't ignore. Surefire tells you which scenarios are flaky — the next move is to investigate and fix them. When you can't fix one right away, tag it@flakyin the feature file and split it out of your main gate: runnot @flakyfor the build that has to stay green, and@flakyin a separate, non-blocking run so the flaky ones stay on your radar while you work through them. Note that this is a tag you add by hand, informed by Surefire's report — the scenario can't know at runtime that it's a retry, so nothing auto-applies it. Treat@flakyas a holding pen, not a hiding place: every scenario wearing it is still a bug owed a fix.
⚠️ Known rough edges with this combination. Because this framework launches Cucumber through a@Suiteclass, retried results can be reported imperfectly — the rerun counts may not add up cleanly, and Cucumber's own report files can get overwritten when the retry pass runs. These are known interactions between Surefire's rerun and the@Suite-plus-Cucumber-engine setup, not a mistake in your config. If you need spotless retry reports, the alternative is a two-pass run: run everything once, write the failed scenarios to a rerun file, then run only those again as a separate, cleanly-reported pass — usually wired up in CI. But for most teams, a single Surefire retry to absorb the rare blip is enough.
What Runs Where Under Parallelism
A quick map of the whole finale: each part of the concurrency setup, what owns it, and how long it lives.
| Concern | Owned by | Scope / lifetime | Why |
|---|---|---|---|
| The driver | DriverManager (ThreadLocal) |
Per thread | Each worker thread drives its own session; no sharing |
| Driver lifecycle | ScenarioHooks (@Before/@After) |
Per scenario, on the scenario's thread | Create on the right thread; quit + remove() at scenario end |
| Screen objects | @ScenarioScope beans |
Per scenario | Element proxies must bind to this scenario's driver, then be discarded |
| Device assignment | DevicePool (BlockingQueue) |
Singleton, leased per scenario | One device per scenario; blocks when the pool is empty |
| Port isolation | DeviceSlot.port → systemPort / wdaLocalPort |
Per device | Each session's automation server needs a unique host port |
| Worker count | junit-platform.properties |
Whole run | fixed parallelism, matched to device-pool size |
| Retry | Maven Surefire (rerunFailingTestsCount) |
Whole run, per failing scenario | Re-running a scenario is the runner's job, above Spring |
Common Failure Points
🚨 The symptoms of broken parallelism, and where to look:
- Scenarios pass alone but fail together, randomly — a singleton is being shared across threads. Confirm your screen objects carry
@ScenarioScope, and that the driver lives in aThreadLocal, not a plain field. A shared screen object or driver is the usual culprit. SessionNotCreatedException/ port-bind error on the second session — two sessions claimed the same automation port. Check that everyDeviceSlothas a uniqueport, and that it's wired tosystemPort(Android) orwdaLocalPort(iOS).- "No driver on this thread" — a screen object was built before its scenario's
@Beforeran. Almost always this means a@Beforehook lives in the same class as steps that inject screen objects. Move hooks into a standaloneScenarioHooksclass that depends only onDevicePoolandDriverManager. - The next scenario on a thread reuses a dead session — you called
quit()but notremove(). Alwaysdriver.remove()after quitting, or the closed driver lingers in the thread'sThreadLocal. - Workers sit idle / the run is slower than expected —
fixed.parallelismis larger than your device-pool size, so surplus workers block inacquire(). Set the two equal. - A scenario hangs forever at start — the pool is empty and
acquire()is blocking with no device coming back. Usually an@Afterthat failed beforerelease(), leaking a slot out of the pool. Make surerelease()runs even when teardown hits an error.
What's Next?
That's the Framework Series core complete. Across seven articles you went from a blank directory to a multi-module framework that boots the right device from config, runs plain-English scenarios on Android and iOS, and now executes them in parallel across as many local devices as you can boot — every architectural decision traceable back to the principles from Article 1.
But every emulator eats real memory and CPU, so a laptop gets slow and unresponsive after just a few — and you can't keep a rack of physical iPhones on your desk. That's where the Framework Extensions series picks up. Its first article — Integrating Your Mobile Framework with Device Farms — aims this same pool-and-port machinery at cloud device farms, where adding a device is a config entry, not a hardware purchase. The DeviceSlot abstraction you just built is exactly what makes a cloud device and a local emulator interchangeable — the pool hands out a slot and never knows the difference.
If two emulators light up and drive in parallel from a single mvn clean test, your concurrency foundation is solid. From here you grow by adding more devices — local or cloud — not by buying a bigger machine.
Discussion