Your test suite runs on five machines: your laptop, a colleague's laptop, a GitHub Actions runner, a Jenkins agent, and a staging-dedicated CI box. All five need different base URLs. Two of them use different credentials. The CI machines run headless. Your laptop doesn't. If any of these values are hardcoded in test files, you can't run the suite on all five machines without editing code — which means you can't reliably build, commit, and trust that CI will reproduce what you ran locally. Configuration management is the practice of separating values that differ across environments, machines, and runs from the code that uses them. This lesson covers the sources of configuration, how to layer them for maximum flexibility, and the Config singleton pattern that gives every part of the framework a single, consistent way to read values.
What counts as configuration
Configuration is any value that is true in one environment and false in another, or that changes from run to run without the test logic changing:
- Base URLs (
https://staging.myapp.comvshttps://myapp.com) - Browser selection (
chromevsfirefoxvswebkit) - Headless mode (off locally, on in CI)
- Timeouts (generous locally, tighter in CI for speed)
- Credentials and API keys (different per environment, never in source code)
- Feature flags (some features only enabled in staging)
- Parallel thread count
If a value never changes across any environment or run, it's a constant in code, not configuration.
The four sources, in precedence order
Configuration comes from four places. Later sources override earlier ones — so you can set sensible defaults and override precisely where needed:
1. Hardcoded defaults in code — the fallback when nothing else specifies the value. Should be safe and local: base.url = http://localhost:3000, timeout = 10.
2. Properties files — environment-specific bundles of values. One file per environment is common:
# config/staging.properties
base.url=https://staging.myapp.com
browser=chrome
timeout=10
admin.email=admin@staging.myapp.com# config/staging.yml (YAML for nested structure)
environments:
staging:
baseUrl: https://staging.myapp.com
timeout: 10
browsers:
default: chrome
headless: trueProperties files live in source control. They contain non-secret values. Secret values (passwords, API keys) go in environment variables, never in files.
3. Environment variables — the standard mechanism for secrets and CI-injected values. CI systems (GitHub Actions, Jenkins) inject these at runtime:
# Set in CI pipeline or locally before running tests
export BASE_URL=https://staging.myapp.com
export ADMIN_PASSWORD=s3cr3t
export API_KEY=abc123String password = System.getenv("ADMIN_PASSWORD"); // null if not set4. System properties (JVM) / CLI arguments — highest precedence, for one-off overrides on a single run:
# Maven — override browser and URL for this run only
mvn test -Dbase.url=https://prod.myapp.com -Dbrowser=firefox
# pytest — via pytest.ini or command line
pytest --base-url=https://staging.myapp.comThe Config singleton
Every layer of the framework that needs a config value should read from one place — a Config singleton. Not from System.getenv() inline in page objects, not from a properties file loaded in each test class. One class, one source of truth.
public class Config {
private static final Properties props = loadProperties();
private Config() {}
// Resolution order: system property → env var → properties file → hardcoded default
public static String baseUrl() {
return resolve("base.url", "BASE_URL", "https://localhost:3000");
}
public static String browser() {
return resolve("browser", "BROWSER", "chrome");
}
public static int timeoutSeconds() {
return Integer.parseInt(resolve("timeout", "TIMEOUT_SECONDS", "10"));
}
public static String adminPassword() {
// Secrets never have a file default — they must come from env
String value = System.getenv("ADMIN_PASSWORD");
if (value == null || value.isBlank()) {
throw new IllegalStateException("ADMIN_PASSWORD environment variable is required but not set");
}
return value;
}
private static String resolve(String sysProp, String envVar, String fallback) {
String fromSys = System.getProperty(sysProp);
if (fromSys != null) return fromSys;
String fromEnv = System.getenv(envVar);
if (fromEnv != null) return fromEnv;
String fromFile = props.getProperty(sysProp);
if (fromFile != null) return fromFile;
return fallback;
}
private static Properties loadProperties() {
Properties p = new Properties();
String env = System.getProperty("env", System.getenv().getOrDefault("ENV", "staging"));
try (InputStream in = Config.class.getResourceAsStream("/config/" + env + ".properties")) {
if (in != null) p.load(in);
} catch (Exception e) {
// Properties file is optional; env vars and system properties still work
}
return p;
}
}Usage anywhere in the framework:
// In page objects
driver.get(Config.baseUrl() + "/login");
// In DriverFactory
if (Config.headless()) opts.addArguments("--headless=new");
// In BaseTest
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(Config.timeoutSeconds()));The equivalent in Python and TypeScript:
# Python — config module
import os
from configparser import ConfigParser
_config = ConfigParser()
_config.read(f"config/{os.getenv('ENV', 'staging')}.ini")
def base_url() -> str:
return os.getenv("BASE_URL") or _config.get("DEFAULT", "base_url", fallback="http://localhost:3000")
def browser() -> str:
return os.getenv("BROWSER") or _config.get("DEFAULT", "browser", fallback="chromium")
def admin_password() -> str:
value = os.getenv("ADMIN_PASSWORD")
if not value:
raise EnvironmentError("ADMIN_PASSWORD environment variable is required")
return value// TypeScript — config module
const ENV = process.env.ENV ?? "staging";
const fileConfig = loadYaml(`config/${ENV}.yml`); // or dotenv
export const config = {
baseUrl: process.env.BASE_URL ?? fileConfig.baseUrl ?? "http://localhost:3000",
browser: process.env.BROWSER ?? fileConfig.browser ?? "chromium",
headless: process.env.CI === "true" || fileConfig.headless === true,
adminPassword: () => {
const v = process.env.ADMIN_PASSWORD;
if (!v) throw new Error("ADMIN_PASSWORD env var required");
return v;
},
};What never belongs in configuration files
Passwords and secrets. A config/staging.properties with admin.password=s3cr3t checked into Git is a security incident waiting to happen. All secrets go in environment variables — injected at runtime by CI secrets managers (GitHub Actions secrets, AWS Secrets Manager, HashiCorp Vault).
Test logic. A properties file with run.login.tests=false and conditional logic that reads it is configuration leaking into the framework's test selection. Use test groups, tags, or CI matrix configuration instead.
Dynamic state. The test run ID, the current timestamp, the session ID of the driver. These are runtime values, not configuration.
⚠️ Common mistakes
- Reading
System.getenv()inline in page objects and test classes. Every inline env read is an independent config lookup with no consistent precedence. When the env var name is misspelled, it silently returnsnulland the next call fails with a confusingNullPointerException. All reads go throughConfig. - Committing
.envfiles with secrets to source control..envfiles are convenient locally. They become a liability the moment they contain a real password and are pushed to a shared repository. Add.envto.gitignore; document the required variables in a.env.examplewith placeholder values. - One monolithic config file for all environments.
config.propertieswithstaging.url=...,prod.url=..., anddev.url=...all together means the file must change for every new environment. Use one file per environment; select the file by anENVvariable.
🎯 Practice task
Implement a layered config system — 35 minutes.
- Audit current config usage. Grep your project for hardcoded URLs,
System.getenv()calls in non-Config classes, and credentials in any file. Count how many places would need updating to switch environments. That number is your "config debt" before this task. - Create a Config class. Implement a
Configsingleton (or module) with at leastbaseUrl(),browser(),timeoutSeconds(), and a secret reader. Implement the resolution order: CLI → env var → properties file → default. - Create environment files. Create
config/staging.properties(or YAML) with non-secret values for your staging environment. Add the file to source control. AddADMIN_PASSWORDas an env var in your local shell — never in the file. - Wire it in. Replace every hardcoded URL and browser string in page objects, drivers, and base tests with
Config.*calls. Run the suite locally. Then run withENV=staging mvn test(or the equivalent) and confirm it still works. - Stretch — override test. Set
BASE_URL=http://fake-url.invalidas an env var and run one test. It should fail at the navigation step with a connection error, not with aNullPointerExceptionfrom a missing config value. The graceful failure confirms your config is being read correctly and all values flow through one path.
Next lesson: logging strategy — how to give every CI failure a readable, queryable audit trail using SLF4J, Log4j2, and structured logging.