Building a Cross-Platform Mobile Test Framework with Appium, Java, and LambdaTest
How we automated two B2B food-distribution apps on real devices, without duplicating our brains twice.
Building a Cross-Platform Mobile Test Framework with Appium, Java, and LambdaTest

How we automated two B2B food-distribution apps on real devices, without duplicating our brains twice.
When you’re testing a mobile app that runs on both Android and iOS, the temptation is to just write two separate test suites and move on. It works, for about a month. Then a locator changes, a business flow shifts, and you’re maintaining the same logic in two places that have quietly drifted apart.
TL;DR: Two apps, two platforms, one framework pattern. We built two Maven + TestNG projects with identical architecture, running on real devices via LambdaTest, so a fix or a lesson learned in one app transfers to the other almost for free.
This is the story of how we built a single, coherent automation framework for a B2B platform connecting distributors and their business customers, across two apps (a Distributor app and an Operator app) and two platforms, without doubling our maintenance burden.
The shape of the problem
We had two apps to cover, and they weren’t twins:
- Distributor app: the heavier of the two, with more complex ordering workflows and inventory/catalog management.
- Operator app: a leaner companion app, covering the same core ordering flows with a reduced feature set.
Both needed Android and iOS coverage. Both needed to run reliably in CI, not just on a laptop. And both needed to plug into the same reporting and test-management pipeline, so a QA lead could look at one dashboard and understand the health of either app.
The answer: two Maven projects that share an identical architecture, tooling, and CI pattern. Different artifacts, same DNA.
Project structure

Both projects have identical internal shape (a suites module and a driver-config module), and both drain into the same three shared services. The duplication is in the boilerplate around that shape, not the shape itself.
The stack
Nothing exotic here, and that’s the point. Boring, well-supported tools mean fewer 2 a.m. debugging sessions:
- Java 21 (LTS) as the language, built with Maven and run through TestNG
- Appium Java Client 8.3.0 driving both UiAutomator2 (Android) and XCUITest (iOS)
- LambdaTest for real-device cloud execution (Pixel 8 Pro on Android 15, iPhone 15 Pro on iOS 18)
- ExtentReports for dark-themed HTML reporting with screenshots on failure
- Rest Assured for any API-level setup or verification
- AIO Test Management synced via REST so manual test cases and automation results live in one system
- CircleCI for scheduling BVT and nightly regression runs
Sticking to the current LTS Java release (21, not a short-lived non-LTS build like 22 or 23) keeps this list honest to its own philosophy: every dependency here (Appium, TestNG, Maven plugins) gets its widest and most stable compatibility support on LTS Java. There’s no upside to trading that stability for a newer point release.
Local vs. cloud, on purpose
One of the more useful decisions was making device execution a runtime switch rather than a hardcoded choice. A single Maven command decides everything:
mvn clean test -PAndroid-bvt -Dtest.platform="Android" -Dtest.mode="Lambda" -Dtest.env=uat
Flip test.mode to Local, and the same test suite spins up a local Appium server on 127.0.0.1:4723 and installs an APK or .app bundle straight from the repo.
No cloud credentials, no CI access needed. An engineer debugging a flaky test reproduces the failure on their own machine, with the exact same test code, then flips back to Lambda mode once it's fixed.
All of this driver logic (device capabilities, app IDs, session creation) lives in a single Constants.java class. One file to check when "how the driver gets created" needs to change, instead of hunting through test classes.
Why two projects instead of one
It’s fair to ask why the Distributor and Operator apps aren’t just one project with a flag. The honest answer: they started that way conceptually. But the app differences (different package names, different bundle IDs, different feature sets between the two apps) made a shared-but-branching structure more confusing than two parallel projects with identical folder layouts.
The tradeoff is real duplication. Right now, a fix to a shared utility class (say, a wait helper or a report formatter) has to be applied in both repos by hand, which is exactly the kind of thing that quietly drifts the same way two independent test suites would.
The fix, and the next concrete step for this framework: pull the shared pieces (driver factory, Constants.java, reporting helpers, AIO/Slack integration code) into a small shared library module or a common parent POM that both projects depend on, instead of two projects that merely look alike. That keeps the "two projects, one DNA" idea true at the dependency level, not just the folder-structure level.
What ties it together
The interesting part isn’t any single tool, it’s how they connect:
- TestNG groups map directly to AIO test case IDs (
@Test(groups = "APP-TC-34")), so a failed test method isn't just a red X in a report, it's a status update in the test management system a product manager might actually look at. - Every suite run posts a pass/fail summary to Slack, with links back to the CircleCI pipeline, so failures surface where the team already spends its day, not buried in a Jenkins-style dashboard nobody opens.
- ExtentReports captures screenshots at the moment of assertion failure, which turns “the test failed” into “here’s exactly what the screen looked like when it failed.”
None of these integrations are individually complicated. What makes the framework useful is that they’re consistent across both apps and both platforms. A distributor Android failure and an operator iOS failure show up looking and behaving the same way to whoever’s triaging them.
What we scoped for later, on purpose
Shipping two working frameworks fast meant deliberately deferring a few things rather than trying to perfect everything up front:
- Credentials and LambdaTest keys currently sit alongside test code; moving them into environment variables or a secrets manager is a planned hardening step, not a blocker for the framework to work.
- A top-level README wasn’t the priority while the two projects were still taking shape; the plan is to add one now that the structure has stabilized.
- LambdaTest app IDs are set directly rather than pulled from a per-environment config, a deliberate simplification for the initial rollout that a config-driven approach will replace.
- The two projects intentionally started as separate, parallel builds to move fast; consolidating their shared pieces into a common library module is the natural next step now that the pattern has proven itself.
None of this is unfinished work by accident, it’s the normal sequencing of building something real: get the pattern working and proven across both apps first, then invest in the shared infrastructure around it. That second phase is already scoped and understood.
The takeaway
A cross-platform mobile framework doesn’t need to be clever. It needs three things:
- A driver layer that treats “local” and “cloud” as configuration, not code.
- A consistent structure across apps that differ in scope.
- Integrations that turn test runs into information people actually see, rather than logs nobody reads.
Get those three right, and the rest (more test cases, more platforms, more apps) is just more of the same pattern. Not a new problem to solve.
메타데이터
- post_id
- 438871ffb985
- slug
- building-a-cross-platform-mobile-test-framework-with-appium-java-and-lambdatest-438871ffb985
- url
- https://medium.com/@virangabandara97/building-a-cross-platform-mobile-test-framework-with-appium-java-and-lambdatest-438871ffb985
- canonical_url
- https://medium.com/@virangabandara97/building-a-cross-platform-mobile-test-framework-with-appium-java-and-lambdatest-438871ffb985
- author_url
- https://medium.com/@virangabandara97
- status
- ok
- fetched_at
- 2026-07-08 21:34:33