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.app

Or 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.app

Both 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,smoke

Spring 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 filesplatforms.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 with spring.config.import.
  • Local filesapplication-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: listing local-android before pixel8 in spring.profiles.active has 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 with driver. gets picked up — driver.platform maps to the platform field, driver.udid maps to udid, and so on. Properties with a different prefix are ignored.
  • Dashes in property names are fine. Spring automatically matches driver.app-path to the Java field appPath, and driver.appium-url to appiumUrl. You don't need to name your properties in a specific way — Spring handles the conversion.
  • Default values are set directly on the fields. appiumUrl starts as http://127.0.0.1:4723 (your local Appium server) and appPackage/appActivity start 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 @Setter is Lombok doing the grunt work. Spring needs to be able to read and write each field to bind the properties — normally that means writing a getPlatform(), setPlatform(), and so on for every field. Lombok generates all of that for you at build time so you don't have to. @Component tells 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, add spring-boot-configuration-processor to 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.

💡 @Getter on the field, not the class. A field-level @Getter generates only getDriver(). A class-level one would also expose getConfig() — and the injected DriverConfig is an internal collaborator that nothing outside DriverManager should 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
💡 The optional: 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 though environments.yml and phases.yml aren'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 inside config/ — never in a subfolder. The dimension .yml files are loaded by explicit path in the spring.config.import line, so they'd technically work anywhere you point that line. But your application-local-.properties files areauto-discovered by Spring, and auto-discovery only scans the classpath root and classpath:/config/ — one level, no deeper. Spring's config/*/ subdirectory scanning works only on the filesystem, never on the classpath (and test resources are on the classpath). A local file in config/ios/ would be silently ignored. Keep everything flat in config/.

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: ios

Each 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 .properties for 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 .properties is safer for that: no indentation to get wrong, and no YAML type-coercion quirks where an unquoted no or 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*.properties

To 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-ios

Adding 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 no driver.app-package or driver.app-activity — those default to empty strings in DriverConfig and are ignored by the iOS branch of DriverManager, 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.platformdriver.platformplatforms.yml
device.udiddriver.udidLocal file (git-ignored)
app.pathdriver.app-pathLocal file (git-ignored)
app.packagedriver.app-packageplatforms.yml
app.activitydriver.app-activityplatforms.yml
appium.server.urldriver.appium-urlBase file

🚨 Common failure points after this change:

  • platform is null / "Unknown platform" error — no platform document is contributing, so driver.platform was never set. Confirm spring.profiles.active includes both a platform profile (android or ios) and a device profile, that platforms.yml is listed in the spring.config.import line, and that each document's spring.config.activate.on-profile matches the profile name you're activating.
  • A whole dimension file is silently ignored — it's missing from the spring.config.import line, 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 .properties file (auto-discovered) outranks any imported .yml document, and a -D system property or environment variable beats every file. This is by design for driver.udid and driver.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 handles app-pathappPath, 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.