Configuration Management — Properties, YAML, Environment Variables

9 min read

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.com vs https://myapp.com)
  • Browser selection (chrome vs firefox vs webkit)
  • 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: true

Properties 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=abc123
String password = System.getenv("ADMIN_PASSWORD");   // null if not set

4. 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.com

The 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 returns null and the next call fails with a confusing NullPointerException. All reads go through Config.
  • Committing .env files with secrets to source control. .env files are convenient locally. They become a liability the moment they contain a real password and are pushed to a shared repository. Add .env to .gitignore; document the required variables in a .env.example with placeholder values.
  • One monolithic config file for all environments. config.properties with staging.url=..., prod.url=..., and dev.url=... all together means the file must change for every new environment. Use one file per environment; select the file by an ENV variable.

🎯 Practice task

Implement a layered config system — 35 minutes.

  1. 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.
  2. Create a Config class. Implement a Config singleton (or module) with at least baseUrl(), browser(), timeoutSeconds(), and a secret reader. Implement the resolution order: CLI → env var → properties file → default.
  3. Create environment files. Create config/staging.properties (or YAML) with non-secret values for your staging environment. Add the file to source control. Add ADMIN_PASSWORD as an env var in your local shell — never in the file.
  4. 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 with ENV=staging mvn test (or the equivalent) and confirm it still works.
  5. Stretch — override test. Set BASE_URL=http://fake-url.invalid as an env var and run one test. It should fail at the navigation step with a connection error, not with a NullPointerException from 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.

// tip to track lessons you complete and pick up where you left off across devices.