Every time you switched platforms in the last article, you had two options. Open application.properties and rewrite the values by hand:
test.platform=ios
device.udid=06B194C0-BC7E-4A1C-9828-A434DDA12569
app.path=/Users/you/apps/wdiodemoapp.appOr pass them as raw flags on the command line:
mvn clean test -Dtest.platform=ios -Ddevice.udid=06B194C0-BC7E-4A1C-9828-A434DDA12569 -Dapp.path=/Users/you/apps/wdiodemoapp.appBoth work on your laptop. Both fall apart the moment you hand the framework to a CI pipeline.
Neither approach scales. A CI pipeline can't edit a file before each run. And raw -D flags have no structure — a single typo silently runs the wrong thing, and the list only grows as you add devices and device farms. Credentials are a harder problem still: a cloud device farm access key should never end up in your shell history or a CI log sitting next to your device UDID.
This two-part article fixes all of it. Instead of one flat file with everything mixed together, you'll organize configuration by dimension — a small YAML file per concern, each holding the named profiles for that dimension as separate documents. A personal local file holds your machine-specific UDID and app path. An environment file holds the backend URL for staging or prod. A test-phase file controls things like retries and screenshot behavior. To run the smoke suite against staging on an iPhone, you activate the profiles that match:
SPRING_PROFILES_ACTIVE=staging,ios,iphone17,smokeSpring loads the right files, the values compose automatically, and nothing in src/ changes between runs. Your CI pipeline sets that one variable — no file edits, no flag lists to maintain.
💡 This is Article 6 (Part 1) of the Framework Series. It picks up directly from One Test, Two Platforms: Cross-Platform Mobile Testing with Page Object Model. The DriverManager from Article 3, the Cucumber wiring from Article 4, and the dual-annotation screen objects from Article 5 are all in place. This article changes how those pieces are configured — the driver logic and screen objects themselves don't move.What Configuration Management Actually Means
Configuration is everything your framework needs that isn't code — which platform to target, which device to run on, which app build to install, which backend to hit, which subset of tests to run. Right now all of it lives in one flat file: mixed together, changed by hand, and one missed edit away from running the wrong thing.
The goal is to separate configuration from code and then separate the kinds of configuration from each other. A mobile framework varies along three independent axes: environment (which backend your test code hits), device profile (where the test physically runs), and test profile (how tests behave — retries, screenshot policy).
You might run the smoke suite against staging on a pixel8 for a quick pre-merge check, and the full regression suite against staging on an iphone17 overnight. After a deployment, you run smoke against prod to confirm the release is healthy — read-only, no test data created. With one flat file, every combination means another round of manual edits. With profiles, each combination is a list of names you pass at launch.
Spring Boot has this mechanism built in — and because Article 4 wired the test context with @SpringBootTest(classes = TestApplication.class), the full Spring configuration system is already loaded in your test run. You're not adding a library. You're using a capability you already have.
How Spring Boot Loads Configuration
Spring Boot reads configuration from several sources and layers them in a defined order. The pieces in this framework are:
application.properties— the base file. Always loaded. Holds the handful of defaults that apply to every run, plus the one line that pulls in everything else.- Dimension files —
platforms.yml,devices.yml,environments.yml,phases.yml. Each holds several profiles as separate YAML documents, and a document only contributes when its profile is active. The base file imports all four withspring.config.import. - Local files —
application-local-{platform}.properties. Your personal, git-ignored UDID and app path.
Each dimension owns a distinct set of keys — platform, device, environment, and test phase never write the same key. When you activate android, pixel8, staging, and smoke together, each profile contributes something the others don't touch. There's nothing to resolve — the four profiles simply add up to one complete configuration.
There is exactly one deliberate exception: driver.udid. When you run with android,pixel8,local-android, the pixel8 document in devices.yml sets it to a placeholder, and your personal local file sets it to your real UDID. Both profiles are active and both write the same key — so Spring has to pick one.
Spring treats two categories of files differently. Files it discovers automatically — because they sit in a standard search location like config/ — are loaded at higher precedence than files pulled in explicitly through spring.config.import. Your application-local-android.properties is auto-discovered. The dimension .yml files are explicitly imported. So your real UDID always beats the committed placeholder, no matter where local-android appears in the profile list.
💡 A common misconception: listinglocal-androidbeforepixel8inspring.profiles.activehas no effect on which value wins. Precedence is determined by how Spring found the file — imported vs. auto-discovered — not by the order profiles are declared.
Here's the full precedence order, lowest to highest:
| Priority | Source | Example |
|---|---|---|
| Lowest | application.properties base defaults |
driver.appium-url |
| ↑ | Imported dimension documents (platforms/devices/environments/phases.yml) |
driver.platform, env.api-base-url |
| ↑ | Standard-location profile files (application-local-*.properties) |
your real driver.udid, driver.app-path |
| ↑ | OS environment variables | SPRING_PROFILES_ACTIVE=staging |
| Highest | Command-line system properties (-D) |
-Ddriver.platform=android |
A -D flag overrides everything; an environment variable overrides any file; a standard-location file overrides anything imported. This layering is what makes CI clean: the pipeline sets SPRING_PROFILES_ACTIVE and nothing in src/ changes between runs.
💡 This is OCP from the framework blueprint — the Open/Closed Principle. Adding a new device, a new environment, or a new test phase means adding a new document to the relevant dimension file. You never reopen DriverManager or any existing code to extend the framework's reach.
Step 1: Replace Scattered @Value Annotations with a Typed Config Object
Right now, DriverManager reads six properties through scattered @Value annotations — one per field, no structure. There's no single place in the codebase that describes what a driver configuration is, no grouping that makes the keys discoverable, and a mistyped property key either throws a resolver error at startup or silently uses a default, depending on how each annotation was written.
@ConfigurationProperties fixes the grouping problem. Declare a class, give it a prefix, and Spring binds every matching key to the right field automatically. The config package from Article 3 was left empty exactly for this.
Before creating it, add Lombok to core/pom.xml. Spring's binding mechanism needs getters and setters on every config field — Lombok generates them at compile time so you don't have to write them by hand:
<dependencies>
<!-- existing dependencies -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>💡 No version is needed — the Spring Boot BOM already manages Lombok's version. The <optional>true</optional> flag means Lombok is a compile-time-only tool; it won't be packaged into any downstream artifacts.Now create core/src/main/java/com/mobileframework/config/DriverConfig.java:
package com.mobileframework.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "driver")
@Getter
@Setter
public class DriverConfig {
private String platform;
private String appiumUrl = "http://127.0.0.1:4723";
private String udid;
private String appPath;
private String appPackage = "";
private String appActivity = "";
}A few things to understand about how this works:
prefix = "driver"tells Spring which properties belong to this class. Any key that starts withdriver.gets picked up —driver.platformmaps to theplatformfield,driver.udidmaps toudid, and so on. Properties with a different prefix are ignored.- Dashes in property names are fine. Spring automatically matches
driver.app-pathto the Java fieldappPath, anddriver.appium-urltoappiumUrl. You don't need to name your properties in a specific way — Spring handles the conversion. - Default values are set directly on the fields.
appiumUrlstarts ashttp://127.0.0.1:4723(your local Appium server) andappPackage/appActivitystart as empty strings. If a property file doesn't set them, these defaults are used. If a property file does set them, the file value wins. @Getter @Setteris Lombok doing the grunt work. Spring needs to be able to read and write each field to bind the properties — normally that means writing agetPlatform(),setPlatform(), and so on for every field. Lombok generates all of that for you at build time so you don't have to.@Componenttells Spring to manage this class as a bean, so it's available to inject wherever you need it.
💡 This is SRP from the framework blueprint. DriverConfig has exactly one reason to change: the shape of the driver's configuration. The driver's logic lives elsewhere and stays put.💡 Want your IDE to suggest property keys as you type? This requires IntelliJ IDEA Ultimate — the Spring Boot plugin that reads property metadata is not included in Community edition. If you're on Ultimate, addspring-boot-configuration-processorto the same<annotationProcessorPaths>block as Lombok — it must go there, not as a separate<dependency>. Once<annotationProcessorPaths>is explicitly defined, Maven only picks up processors listed inside it; anything added as a regular dependency is silently ignored for annotation processing:
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
</annotationProcessorPaths>No version needed — the BOM manages it. After adding it, run mvn compile (or Build → Rebuild Project in IntelliJ) to generate the metadata file. Once it exists in target/, IntelliJ reads it and starts suggesting driver.platform, driver.udid, and the rest as you type in application.properties. This has no effect on whether binding works — it's purely an IDE convenience.
Step 2: Refactor DriverManager to Use It
DriverManager now asks for one collaborator — the DriverConfig bean — instead of reading six raw strings. Replace the field injections with constructor injection:
package com.mobileframework.driver;
import com.mobileframework.config.DriverConfig;
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import org.springframework.stereotype.Component;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
@Component
public class DriverManager {
private final DriverConfig config;
@Getter
private AppiumDriver driver;
public DriverManager(DriverConfig config) {
this.config = config;
}
@PostConstruct
public void initialize() {
try {
URL serverUrl = new URI(config.getAppiumUrl()).toURL();
if ("android".equalsIgnoreCase(config.getPlatform())) {
UiAutomator2Options options = new UiAutomator2Options()
.setUdid(config.getUdid())
.setApp(config.getAppPath())
.setAppPackage(config.getAppPackage())
.setAppActivity(config.getAppActivity());
driver = new AndroidDriver(serverUrl, options);
} else if ("ios".equalsIgnoreCase(config.getPlatform())) {
XCUITestOptions options = new XCUITestOptions()
.setUdid(config.getUdid())
.setApp(config.getAppPath());
driver = new IOSDriver(serverUrl, options);
} else {
throw new IllegalArgumentException(
"Unknown platform: '" + config.getPlatform()
+ "'. Set driver.platform to 'android' or 'ios'.");
}
} catch (URISyntaxException | MalformedURLException e) {
throw new IllegalStateException("Invalid Appium server URL: " + config.getAppiumUrl(), e);
}
}
@PreDestroy
public void quit() {
if (driver != null) {
driver.quit();
}
}
}The Appium logic is identical — same options builders, same @PostConstruct lifecycle, same quit(). Two things change: where the values come from (a typed object instead of six annotations), and the getDriver() accessor is now generated by Lombok's field-level @Getter instead of being written by hand.
💡@Getteron the field, not the class. A field-level@Gettergenerates onlygetDriver(). A class-level one would also exposegetConfig()— and the injectedDriverConfigis an internal collaborator that nothing outsideDriverManagershould reach into.
💡 This is DIP from the framework blueprint — the Dependency Inversion Principle. DriverManager no longer reaches out and pulls raw strings from the environment. It receives a configuration abstraction through its constructor. The driver doesn't know whether those values came from a base file, a profile, an environment variable, or a CI override — and that's exactly the point.Step 3: Split the Flat File into Base + Dimension Files
Now restructure team-tests/src/test/resources/application.properties. Strip it down to the shared defaults, the import that pulls in the dimension files, and the active profile selection:
# Base configuration: shared defaults for every run
# Appium server: overridable, but the same for most local runs
driver.appium-url=http://127.0.0.1:4723
# Pull in the per-dimension profile documents. Each file holds multiple
# profiles as on-profile YAML documents; only the active ones contribute.
spring.config.import=optional:classpath:config/platforms.yml,optional:classpath:config/devices.yml,optional:classpath:config/environments.yml,optional:classpath:config/phases.yml
# Default profiles when nothing is passed at launch.
# Override from the CLI or CI (see Step 6): these are just the local fallback.
spring.profiles.active=android,pixel8,local-android💡 Theoptional:prefix on each import means a file that doesn't exist yet is silently skipped rather than failing the run. That's why all four files are listed here even thoughenvironments.ymlandphases.ymlaren't created until Part 2 — the import line is final, and the two remaining files simply slot in when you add them.
Configuration now lives in one file per dimension, each holding the named profiles for that dimension as separate YAML documents:
- The platform file (
platforms.yml) holds everything shared across all devices on that platform — the driver identifier, app package, and app activity. - The device file (
devices.yml) holds only what's unique to one device — a placeholder UDID. - A local file (git-ignored, never committed) holds your personal machine-specific values — your real UDID and app path — and overrides the placeholder.
The final structure looks like this:
src/test/resources/
├── application.properties ← base, always loaded; declares the imports
└── config/
├── platforms.yml ← android + ios documents
├── devices.yml ← pixel8, pixel9, iphone16, iphone17 documents
├── environments.yml ← staging, prod documents (Step 4)
├── phases.yml ← smoke, regression documents (Step 5)
├── application-local-android.properties ← git-ignored, your Android UDID + app path
├── application-local-ios.properties ← git-ignored, your iOS UDID + app path
└── application-cloud.properties ← safe to commit, ${ENV_VAR} placeholders only (Step 7)⚠️ Keep the local files directly insideconfig/— never in a subfolder. The dimension.ymlfiles are loaded by explicit path in thespring.config.importline, so they'd technically work anywhere you point that line. But yourapplication-local-.properties files areauto-discovered by Spring, and auto-discovery only scans the classpath root andclasspath:/config/— one level, no deeper. Spring'sconfig/*/subdirectory scanning works only on the filesystem, never on the classpath (and test resources are on the classpath). A local file inconfig/ios/would be silently ignored. Keep everything flat inconfig/.
Start with the platform dimension. Create config/platforms.yml — one document per platform, separated by —--:
# Platform profiles: shared per-platform configuration
spring:
config:
activate:
on-profile: android
driver:
platform: android
app-package: com.wdiodemoapp
app-activity: com.wdiodemoapp.MainActivity
---
spring:
config:
activate:
on-profile: ios
driver:
platform: iosEach document is gated by spring.config.activate.on-profile: the android document contributes only when the android profile is active, the ios document only when ios is. Now the device dimension — config/devices.yml, one document per physical device or emulator/simulator, each committed with a placeholder UDID:
# Device profiles: placeholder UDIDs only — real values go in your local file
spring:
config:
activate:
on-profile: pixel8
driver:
udid: <your-emulator-udid>
---
spring:
config:
activate:
on-profile: pixel9
driver:
udid: <your-emulator-udid>
---
spring:
config:
activate:
on-profile: iphone16
driver:
udid: <your-simulator-udid>
---
spring:
config:
activate:
on-profile: iphone17
driver:
udid: <your-simulator-udid>UDIDs are machine-specific — they don't belong in a committed file. Your emulator UDID is not the same as your teammate's. Instead, each developer creates two personal files in config/ — one per platform — and sets their own UDIDs there. These stay .properties files (small and personal), and neither is ever committed:
# application-local-android.properties — never committed
driver.udid=emulator-5554
driver.app-path=/Users/you/apps/android.wdio.native.app.v2.0.0.apk# application-local-ios.properties — never committed
driver.udid=A1B2C3D4-BC7E-4A1C-9828-A434DDA12569
driver.app-path=/Users/you/apps/iOS.Simulator.NativeDemoApp.v2.0.0.app💡 Why YAML for the dimension files but.propertiesfor the local ones? The dimension files need to hold multiple profiles in a single file — YAML's---separator is the only format that supports that. The local files are just two or three flat values that each developer edits by hand, and.propertiesis safer for that: no indentation to get wrong, and no YAML type-coercion quirks where an unquotednoor a bare version number gets silently misread as a boolean or number.
When you activate both iphone17 and local-ios, both set driver.udid — the placeholder from devices.yml and your real value from the local file. Your local value wins, because (as covered above) Spring loads its auto-discovered config/ files at a higher precedence than anything imported via spring.config.import. The device document is imported; your local file is auto-discovered — so the real UDID always beats the placeholder, no matter where local-ios sits in the activation list. The committed device documents contain no personal values at all.
Add the git-ignore entry in your root .gitignore to cover both files with a wildcard:
**/application-local*.propertiesTo run, you activate the platform, device, and local profiles together:
# Android
mvn clean test -Dspring.profiles.active=android,pixel8,local-android# iOS
mvn clean test -Dspring.profiles.active=ios,iphone17,local-iosAdding another device — say a Pixel 9 Pro — means adding one new document to devices.yml with a placeholder UDID, then updating application-local-android.properties with that device's real UDID. Once that's done, you switch between devices purely by changing the profile name — no other files to touch.
💡 The iOS platform document sets nodriver.app-packageordriver.app-activity— those default to empty strings inDriverConfigand are ignored by the iOS branch ofDriverManager, exactly as in Article 5.
Migrating from the Article 5 Workspace
If you're upgrading the workspace from the last article, the property keys changed when they moved under the typed driver.* prefix. Quick map:
| Article 5 key | Article 6 key | Now lives in |
|---|---|---|
test.platform | driver.platform | platforms.yml |
device.udid | driver.udid | Local file (git-ignored) |
app.path | driver.app-path | Local file (git-ignored) |
app.package | driver.app-package | platforms.yml |
app.activity | driver.app-activity | platforms.yml |
appium.server.url | driver.appium-url | Base file |
🚨 Common failure points after this change:
platformis null / "Unknown platform" error — no platform document is contributing, sodriver.platformwas never set. Confirmspring.profiles.activeincludes both a platform profile (androidorios) and a device profile, thatplatforms.ymlis listed in thespring.config.importline, and that each document'sspring.config.activate.on-profilematches the profile name you're activating.- A whole dimension file is silently ignored — it's missing from the
spring.config.importline, or the path is wrong. Imported files are loaded by explicit path only; there's no auto-scan. Also check each document is separated by a line containing exactly—--. - An imported value "won't take" no matter what you put in it — something higher in the precedence order is overriding it. Remember a local
.propertiesfile (auto-discovered) outranks any imported.ymldocument, and a-Dsystem property or environment variable beats every file. This is by design fordriver.udidanddriver.app-path; if it surprises you elsewhere, you're probably setting the same key in two dimensions. - Binding silently does nothing — the key prefix doesn't match the
@ConfigurationProperties(prefix = ...), or the field has no setter. Relaxed binding handlesapp-path→appPath, but it can't bind to a field that has no public setter.
What You've Built So Far
Three of the framework's configuration dimensions are now in place:
| Dimension | Example profile file | Owns these keys | Activated by |
|---|---|---|---|
| Base (always loaded) | application.properties |
driver.appium-url, spring.config.import, default spring.profiles.active |
n/a — always applied |
| Platform | config/platforms.yml (android / ios docs) |
driver.platform, driver.app-package, driver.app-activity |
spring.profiles.active |
| Device | config/devices.yml (pixel8, iphone17, … docs) |
driver.udid (placeholder only) |
spring.profiles.active |
| Local (personal, git-ignored) | config/application-local-android.properties |
driver.udid, driver.app-path |
spring.profiles.active |
What's Next?
You've replaced the flat file with a typed config object and a clean, per-dimension profile structure — and you can already switch platform and device by name, with no edits to anything in src/. That's the harder half of the work, and the rest reuses the exact same pattern.
The next article — Configuration Management for Mobile Tests, Part 2: Run Any Combination from CI — adds three more dimensions on top of this foundation: the backend your test code talks to, how a run behaves (retries, screenshots), and the credentials a cloud run needs — then ties the whole thing into CI so the pipeline picks any combination by name. That's where the SPRING_PROFILES_ACTIVE=staging,ios,iphone17,smoke promise from the top of this article is fully delivered.
If mvn clean test -Dspring.profiles.active=android,pixel8,local-android still passes on your emulator, the foundation is solid — and Part 2 plugs straight into it.
Get the Code
The complete, runnable workspace for the configuration system ships as a single free download with Part 2.
✅ Subscribe below — free, just your email. It unlocks the workspace download on Part 2 and gets you every new Framework Series article the day it's published.
Discussion