In Part 1, you replaced the flat properties file with a typed DriverConfig object and split configuration into per-dimension profile files — a platform file, a device file, and your personal git-ignored local file. You can now switch platform and device purely by name:
mvn clean test -Dspring.profiles.active=android,pixel8,local-androidThat's the foundation. This part extends the exact same pattern to three more dimensions — the backend your test code talks to, how a run behaves, and the secrets a cloud run needs — then wires the whole thing into CI so a pipeline picks any combination by name, with nothing in src/ changing between runs.
💡 This is Article 6 (Part 2) of the Framework Series. It continues directly from Part 1, which built the typedDriverConfigobject and the platform/device/local profile files. Everything here assumes that structure is in place: the baseapplication.propertieswith itsspring.config.importline,config/platforms.yml,config/devices.yml, and your git-ignored local files.
Step 1: Add Environment Profiles
Before creating these files, it's worth being clear about what an environment profile actually does — because it's easy to misunderstand.
The environment profile does not configure what server your app talks to. That's baked into the app binary at build time. A staging .apk already points to the staging backend; a prod .ipa already points to prod. Your test framework can't change that.
What the environment profile configures is what server your test code talks to — directly, independently of the app. This matters when your tests need to make API calls themselves:
- Test data setup — creating a user account via API before running a login test, rather than tapping through a registration flow every time
- Test data teardown — deleting test records after a test to leave the environment clean
- State verification — asserting that a UI action triggered the right backend change by checking the API response directly
If you're writing pure UI tests that only interact with the app and assert on screen state, you don't need this yet. The demo app used through this series has no real backend, so env.api-base-url has no consumer here. The pattern is introduced now so the configuration dimension exists when your tests grow to need it.
Following the dimension-file pattern, create config/environments.yml with one document per backend:
# Environment profiles: which backend your test code talks to
spring:
config:
activate:
on-profile: staging
env:
api-base-url: https://staging-api.example.com
---
spring:
config:
activate:
on-profile: prod
env:
api-base-url: https://api.example.com(environments.yml is already in the spring.config.import line from Step 3 — no extra wiring.)
Bind these with the same typed-config pattern. Create core/src/main/java/com/mobileframework/config/EnvironmentConfig.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 = "env")
@Getter
@Setter
public class EnvironmentConfig {
private String apiBaseUrl;
}Now combine dimensions. Activate a device profile and an environment profile together:
mvn clean test -Dspring.profiles.active=ios,iphone17,local-ios,stagingSpring loads all the active documents. ios sets the platform and app; iphone17 sets the device placeholder; local-ios applies your personal UDID and app path; staging sets the backend URL your test code will use. They each own different keys, so they layer without conflict — exactly the disjoint-dimension design from earlier.
Each profile is independent and optional — you activate one only for a dimension you care about that run. If a run doesn't touch a backend, drop staging and run the device dimensions on their own:
mvn clean test -Dspring.profiles.active=ios,iphone17,local-iosThe only effect is that no environment overlay loads, so env.api-base-url stays unset — which is fine here, since nothing reads it yet. You'd add an environment profile precisely when a test starts needing that URL.
⚠️ When your staging and prod builds are different.apk/.ipafiles. It's tempting to setdriver.app-pathin thestagingdocument so the right build loads automatically. It won't work — your local file setsdriver.app-pathand is auto-discovered, so it always outranks anything set in an imported file likeenvironments.yml. The environment value is silently ignored. To point at a different build, update your local file directly, or pass-Ddriver.app-path=...on the command line, which beats every file.
Step 2: Add Test Profiles — and Know Where the Line Is
A test profile controls how tests behave — retries, screenshot policy, timeouts. But "which tests run" is a separate question owned by a different mechanism entirely, and being precise about the split saves you a confusing afternoon.
Each phase document sets different run behavior — a smoke run fails fast with no retries, while a regression run tolerates one retry for flaky tests. Create config/phases.yml with one document per phase:
# Test phase profiles: how the run behaves
spring:
config:
activate:
on-profile: smoke
test:
retry-count: 0
screenshot-on-failure: true
---
spring:
config:
activate:
on-profile: regression
test:
retry-count: 1
screenshot-on-failure: trueBind it the same way — core/src/main/java/com/mobileframework/config/TestRunConfig.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 = "test")
@Getter
@Setter
public class TestRunConfig {
private int retryCount = 0;
private boolean screenshotOnFailure = true;
}⚠️TestRunConfigbinds today, but nothing readsretryCountorscreenshotOnFailureyet. The retry mechanism is built in the parallel execution article and the screenshot-on-failure hook in the visual testing article. We define the configuration here so the shape is in place; the behavior plugs in later. That's deliberate — config-first, then the feature that consumes it.
Which scenarios run is a different mechanism entirely. Cucumber selects scenarios by tag, and that selection is owned by the Cucumber JUnit Platform engine — not by Spring. It reads cucumber.filter.tags from junit-platform.properties, so a Spring profile can't set it.
Tag your scenarios in the feature files:
@smoke
Scenario: User logs in with valid credentials
...Then control selection through Cucumber's own configuration — either set a default in junit-platform.properties:
cucumber.filter.tags=@smoke…or override it at launch with the matching system property:
mvn clean test -Dcucumber.filter.tags="@smoke"💡 Two separate switches, usually set together.spring.profiles.active=...,smoketells Spring how the run should behave — zero retries, screenshots on failure.cucumber.filter.tags="@smoke"tells Cucumber which scenarios to run. A CI smoke job always sets both: one without the other either runs the wrong scenarios or runs them with the wrong behavior.
Step 3: Activate Profiles from the CLI and CI — the Payoff
Locally, pass the profile combination as a system property:
mvn clean test -Dspring.profiles.active=staging,ios,iphone17,local-ios,smoke -Dcucumber.filter.tags="@smoke"In CI, prefer the SPRING_PROFILES_ACTIVE environment variable over a -D flag. The reason: Maven forks a separate JVM to run tests, and -D system properties aren't always forwarded to that fork reliably. Environment variables always are. Spring Boot recognises SPRING_PROFILES_ACTIVE automatically — no extra configuration needed:
# Example CI step (provider-agnostic)
env:
SPRING_PROFILES_ACTIVE: "prod,ios,iphone17,regression"
steps:
- run: mvn clean test -Dcucumber.filter.tags="@regression"That's the promise from Part 1 delivered. The pipeline picks the environment, the device, and the test phase by name. Nothing in src/ changes between an Android smoke run on staging and an iOS regression run on prod — only the profile list differs.
Step 4: Keep Secrets Out of the Repo
Cloud device farms and most real backends need credentials — an access key, an API token. Those must never be committed.
Secrets resolve from environment variables at runtime. Create config/application-cloud.properties — it follows the same Spring naming convention as the local files, so it's auto-discovered when the cloud profile is active. Reference secrets as ${...} placeholders; Spring substitutes the real value from the environment when the property is read:
# config/application-cloud.properties
cloud.username=${CLOUD_USERNAME}
cloud.access-key=${CLOUD_ACCESS_KEY}The file is safe to commit — it contains references, never the secrets themselves. CI injects the real values as environment variables, and locally you export them in your shell. We'll use this exact pattern when wiring up device farms.
🚨 Never commit a real access key, token, or password to any properties file — not even briefly. Git history is forever; a key pushed once and deleted in the next commit is still a key you must now rotate. Always use a ${ENV_VAR} placeholder.Configuration Map: Which Dimension Owns What
With both parts in place, here's the complete picture — every dimension, the file that holds it, the keys it owns, and how it's switched on:
| 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 |
| Environment | config/environments.yml (staging / prod docs) |
env.api-base-url |
spring.profiles.active |
| Test (behavior) | config/phases.yml (smoke / regression docs) |
test.retry-count, test.screenshot-on-failure |
spring.profiles.active |
| Test (selection) | junit-platform.properties |
cucumber.filter.tags |
cucumber.filter.tags system property |
| Secrets | config/application-cloud.properties |
${ENV_VAR} placeholders only |
spring.profiles.active + OS environment variables |
💡 Reminder on precedence (covered in Part 1): a local.propertiesfile is auto-discovered and outranks any imported.ymldocument; an environment variable beats every file; a-Dsystem property beats everything. If an imported value "won't take," something higher in that order is overriding it — usually the same key set in two dimensions.
What's Next?
The next article — Parallel Test Execution for Mobile: How to Scale Without Breaking Everything — takes the configuration system you just built and runs many of these combinations at once. Thread-local drivers, test isolation, and the test.retry-count value you defined here but haven't wired up — that's where it comes alive. Parallel execution is where a single shared DriverManager quietly becomes a bug, and the profile-driven config you built today is exactly what lets each parallel thread know which device it owns.
Each article picks up exactly where the last one left off. If mvn clean test -Dspring.profiles.active=android,pixel8,local-android still passes on your emulator, the configuration foundation is solid — your CI pipeline will never need a file edit between runs, and neither will your routine local runs.
Get the Code
The complete, runnable workspace is available as a free download — everything built across Articles 2 through 6, with the full configuration system (both parts) wired together and ready to open in your IDE.
What's inside:
- The full Maven multi-module project — root POM,
coremodule,team-testsmodule DriverManagerfrom Article 3, refactored to inject the new typedDriverConfigHomeScreenandLoginScreenwith dual@AndroidFindBy/@iOSXCUITFindByannotations from Article 5- Cucumber wiring from Article 4 —
RunCucumberTest,CucumberSpringConfiguration,login.feature,junit-platform.properties - The new
configpackage —DriverConfig,EnvironmentConfig,TestRunConfig - A base
application.propertiesthat imports the four dimension files —platforms.yml,devices.yml,environments.yml,phases.yml— each ready to edit - A
.gitignoreset up forapplication-local*.properties
💡 Before running, createsrc/test/resources/config/application-local-android.propertiesand/orconfig/application-local-ios.properties(both git-ignored). Setdriver.udidto your emulator or simulator UDID anddriver.app-pathto your local app location. The committed platform profiles contain no personal values — everything machine-specific lives in your local file. Runmvn clean testfrom the project root —spring.profiles.active=android,pixel8,local-androidis the default, so an Android emulator run works out of the box.
✅ Subscribe below to download the workspace .zip — free, just your email, and you'll get every new Framework Series article as it's published.