Test Data, Config and Environments
On this page
Our page objects still contain two things that do not belong there: the site's URL and, in the tests, the literal "student" / "Password123". The day you need to run the same suite against staging, or a developer's local build, or with a different account, both have to move. This chapter adds the layer that holds them.
Three kinds of values#
Split everything a test needs from outside itself into three buckets, because each has different rules:
| Kind | Examples | Where it lives |
|---|---|---|
| Environment config | base URL, which browser, local vs remote grid | Per-environment file, overridable from the command line |
| Secrets | passwords, API keys, grid credentials | Environment variables or your CI's secret store, never in git |
| Test data | the user to log in as, the wrong password to try | Code (builders, data providers), derived from config |
The practice site publishes its demo password, so committing it is harmless here. For any real environment, the password goes in the second bucket, and the config file only names the account.
The suite must log in to staging as qa-student. Where does that account's password belong?
Secrets never go in git. The config file says which account; the password arrives from the environment, which is why users.student.password maps to USERS_STUDENT_PASSWORD.
Java: a Config class with a clear precedence#
Put one properties file per environment in src/test/resources/env/:
# src/test/resources/env/practice.properties
baseUrl=https://practicetestautomation.com
target=local
browser=chrome
users.student.username=student
users.student.password=Password123
# src/test/resources/env/staging.properties
baseUrl=https://staging.example.com
target=testingbot
browser=chrome
users.student.username=qa-student
# no password here: supply USERS_STUDENT_PASSWORD from the CI secret store
Then a Config class that resolves every key in a fixed order: a -D system property wins, then an environment variable, then the file.
package com.example.framework;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.Properties;
public final class Config {
private static final String ENV =
System.getProperty("env", System.getenv().getOrDefault("TEST_ENV", "practice"));
private static final Properties FILE = load(ENV);
private Config() {}
/** -Dkey=value, then KEY_IN_CAPS env var, then env/<ENV>.properties. */
public static String get(String key) {
String fromSysProp = System.getProperty(key);
if (fromSysProp != null) return fromSysProp;
String envName = key.replace('.', '_').toUpperCase();
String fromEnv = System.getenv(envName);
if (fromEnv != null) return fromEnv;
String fromFile = FILE.getProperty(key);
if (fromFile != null) return fromFile;
throw new IllegalStateException(
"Missing config '" + key + "' for env '" + ENV + "' (or env var " + envName + ")");
}
public static String baseUrl() { return get("baseUrl"); }
public static String target() { return get("target"); }
public static String browser() { return get("browser"); }
private static Properties load(String env) {
String path = "env/" + env + ".properties";
Properties props = new Properties();
try (InputStream in = Config.class.getClassLoader().getResourceAsStream(path)) {
if (in == null) throw new IllegalStateException("No config file " + path);
props.load(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return props;
}
}
Two details are deliberate. A missing key throws immediately with the key, the environment and the env var name in the message; silently defaulting a password to an empty string produces a confusing login failure five minutes into a CI run. And users.student.password maps to USERS_STUDENT_PASSWORD, so CI can inject the secret without anyone editing a file.
LoginPage.open() now uses driver.get(Config.baseUrl() + "/practice-test-login/"). Switching environments is mvn test -Denv=staging.
Java: test data as types#
Tests should not pass loose strings around. A small record gives the data a name and lets you derive variations:
package com.example.data;
import com.example.framework.Config;
public record User(String username, String password) {
public static User student() {
return new User(
Config.get("users.student.username"),
Config.get("users.student.password"));
}
public User withPassword(String newPassword) {
return new User(username, newPassword);
}
}
The negative login cases become one data-driven test:
package com.example.tests;
import static org.testng.Assert.assertEquals;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import com.example.data.User;
import com.example.pages.LoginPage;
public class LoginErrorsTest extends BaseTest {
@DataProvider(name = "badCredentials", parallel = true)
public Object[][] badCredentials() {
User student = User.student();
return new Object[][] {
{ student.withPassword("wrong-password"), "Your password is invalid!" },
{ new User("not-a-user", student.password()), "Your username is invalid!" },
};
}
@Test(dataProvider = "badCredentials")
public void badCredentialsShowError(User user, String expectedError) {
LoginPage login = new LoginPage(driver())
.open()
.loginExpectingError(user.username(), user.password());
assertEquals(login.errorMessage(), expectedError);
}
}
Note what did not change: the page object. loginExpectingError(String, String) takes whatever data the test hands it. Page objects that read config or pick their own test users are hard to reuse; keep data flowing in from the test.
A teammate adds LoginPage.loginAsDefaultUser(), which reads the username and password from Config itself. What is the problem?
Keep the page object about the page. The test (or a data builder) decides who logs in, so the same method serves the student, an admin and the wrong-password case.
Python: a settings fixture#
The same structure in Python uses a TOML file per environment (tomllib is in the standard library from 3.11) and a pytest command-line option:
# config/practice.toml
base_url = "https://practicetestautomation.com"
[users.student]
username = "student"
password = "Password123"
# settings.py
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class User:
username: str
password: str
@dataclass(frozen=True)
class Settings:
base_url: str
users: dict[str, User]
def load_settings(env: str) -> Settings:
path = Path(__file__).parent / "config" / f"{env}.toml"
data = tomllib.loads(path.read_text())
users = {}
for name, u in data.get("users", {}).items():
# a secret in the environment beats the file: USERS_STUDENT_PASSWORD
password = os.getenv(f"USERS_{name.upper()}_PASSWORD", u.get("password", ""))
users[name] = User(u["username"], password)
return Settings(base_url=os.getenv("BASE_URL", data["base_url"]), users=users)
# conftest.py (additions)
import os
import pytest
from settings import Settings, load_settings
def pytest_addoption(parser):
parser.addoption("--env", default=os.getenv("TEST_ENV", "practice"),
help="name of the file in config/ to load")
@pytest.fixture(scope="session")
def settings(request) -> Settings:
return load_settings(request.config.getoption("--env"))
@pytest.fixture
def base_url(settings) -> str:
return settings.base_url
Because base_url is now derived from settings, every test from chapter 01 picks up the environment switch without changes. Run with pytest --env practice. The option is named --env rather than --base-url to avoid clashing with the pytest-base-url plugin if someone installs it later.
Data-driven negatives use parametrize:
import pytest
from pages.login_page import LoginPage
from settings import User
@pytest.mark.parametrize(
"make_user, expected",
[
(lambda s: User(s.username, "wrong-password"), "Your password is invalid!"),
(lambda s: User("not-a-user", s.password), "Your username is invalid!"),
],
ids=["bad-password", "bad-username"],
)
def test_bad_credentials_show_error(driver, settings, make_user, expected):
user = make_user(settings.users["student"])
login = LoginPage(driver, settings.base_url).open()
login.login_expecting_error(user.username, user.password)
assert login.error_message == expected
The parameters are functions of the configured user rather than hard-coded strings, so the same cases work against staging with a different account.
Playwright: option fixtures#
In Playwright, baseURL already lives in the config. Put the environment table in a module the config and the fixtures can both import:
// config/env.ts
export type User = { username: string; password: string };
type Env = { baseURL: string; users: Record<'student', User> };
const envs: Record<string, Env> = {
practice: {
baseURL: 'https://practicetestautomation.com',
users: { student: { username: 'student', password: 'Password123' } },
},
staging: {
baseURL: 'https://staging.example.com',
users: {
student: { username: 'qa-student', password: process.env.STUDENT_PASSWORD ?? '' },
},
},
};
const name = process.env.TEST_ENV ?? 'practice';
const selected = envs[name];
if (!selected) throw new Error(`Unknown TEST_ENV "${name}"`);
export const env: Env = selected;
In playwright.config.ts, set baseURL: env.baseURL. Then expose the user as an option fixture, a fixture with a default value that any file or describe block can override with test.use():
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { env, type User } from '../config/env';
type Options = { user: User };
type Pages = { loginPage: LoginPage };
export const test = base.extend<Options & Pages>({
user: [env.users.student, { option: true }],
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
},
});
export { expect } from '@playwright/test';
// tests/login-errors.spec.ts
import { test, expect } from './fixtures';
const cases = [
{ name: 'bad password', change: { password: 'wrong-password' }, error: 'Your password is invalid!' },
{ name: 'bad username', change: { username: 'not-a-user' }, error: 'Your username is invalid!' },
];
for (const c of cases) {
test(`${c.name} shows an error`, async ({ loginPage, user }) => {
const attempt = { ...user, ...c.change };
await loginPage.loginAs(attempt.username, attempt.password);
await expect(loginPage.error).toHaveText(c.error);
});
}
TEST_ENV=staging npx playwright test switches everything at once. Option fixtures can also be set per project in playwright.config.ts, which is how you would run the same specs as two different user roles.
You want the same Playwright specs to run once as a student and once as an admin. With the user option fixture, what is the cleanest way?
Option fixtures have a default that a project, file or describe block can override, so one set of specs runs under both roles without changes.
Data that survives parallel runs#
Read-only data, like our login, can be shared by every test. Data a test creates or changes cannot. Two tests that both edit "the test user's profile" at the same time will fail intermittently, and chapter 07 is about to run everything at once. Give each test its own records (a unique suffix such as a UUID in the name), create them through an API rather than the UI where you can, and clean up in the teardown of the same fixture or @AfterMethod that created them. Our article on handling test flakiness covers shared state as a source of flaky tests in more detail, and the Python test frameworks overview compares how other runners handle fixtures and parametrisation.