08 · Environment & Test Data Management¶
The problem: secrets and environment drift¶
Tests need URLs, credentials, and API keys that differ between local,
staging, and CI — and must never contain real secrets committed to git.
.env files plus a loader, read once at session start, is the standard
Python answer.
# .env (gitignored — never committed)
BASE_URL=https://staging.example.com
TEST_USER_EMAIL=qa+staging@example.com
TEST_USER_PASSWORD=correct-horse-battery-staple
API_KEY=sk_test_abc123
# conftest.py
import os
import pytest
from dotenv import load_dotenv
load_dotenv() # populates os.environ from .env if present
@pytest.fixture(scope="session")
def config():
return {
"base_url": os.environ["BASE_URL"],
"user_email": os.environ["TEST_USER_EMAIL"],
"user_password": os.environ["TEST_USER_PASSWORD"],
}
# no output — load_dotenv() is a no-op if .env is absent (e.g. in
# CI, where real env vars are injected by the pipeline directly
# instead of a file), so the same code path works both places
Commit a .env.example with the keys but placeholder values, so a new
teammate knows exactly what to fill in without ever seeing a real secret.
Never hardcode credentials in test files¶
# BAD
def test_login(page):
page.get_by_label("Email").fill("real.person@company.com")
page.get_by_label("Password").fill("hunter2")
# GOOD
def test_login(page, config):
page.get_by_label("Email").fill(config["user_email"])
page.get_by_label("Password").fill(config["user_password"])
# no output — the good version reads no different in CI vs. local,
# and rotating the real password only means updating one secret,
# not grepping test files for where it was pasted
CI secrets injection¶
# .github/workflows/e2e.yml (excerpt)
env:
BASE_URL: https://staging.example.com
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
steps:
- run: pytest
# no output — GitHub Actions injects secrets as environment
# variables at run time; os.environ["TEST_USER_PASSWORD"] in
# conftest.py sees the real value without it ever appearing in
# the workflow file or logs (Actions also auto-masks secret values
# that happen to be printed, replacing them with ***)
Test data: fixtures vs. factories vs. API seeding¶
Static JSON fixtures are simplest for stable reference data.
# fixtures/products.json
[
{"name": "Wireless Mouse", "price": 24.99, "sku": "WM-100"},
{"name": "Mechanical Keyboard", "price": 89.00, "sku": "MK-200"}
]
import json
import pytest
@pytest.fixture
def sample_products():
with open("fixtures/products.json") as f:
return json.load(f)
# no output — sample_products() returns a plain list of dicts
# any test can iterate over without hitting a database
Dynamic data that must be unique per test run (an email, an order ID) should be generated, not fixed, so parallel/repeated runs never collide.
import uuid
import pytest
from faker import Faker
fake = Faker()
@pytest.fixture
def new_user_data():
return {
"email": f"qa+{uuid.uuid4().hex[:8]}@example.com",
"name": fake.name(),
"company": fake.company(),
}
def test_signup_creates_account(page, new_user_data):
page.goto("/signup")
page.get_by_label("Full name").fill(new_user_data["name"])
page.get_by_label("Email").fill(new_user_data["email"])
page.get_by_role("button", name="Create account").click()
Faker (the faker package) generates realistic-looking but fake names,
addresses, and companies — better test coverage than "Test User 1" for
catching UI truncation/formatting bugs, and the uuid suffix on the email
guarantees no two test runs (or two parallel workers) ever collide on a
uniqueness constraint the real backend enforces.
Seeding data via API instead of the UI¶
For anything a test needs to exist before it starts (an order to
cancel, a document to delete), creating it through the UI wastes time and
couples an unrelated test to the signup flow's stability. Seed via the
backend's own API instead (Level 3 covers this with Playwright's
APIRequestContext in depth).
def test_cancel_existing_order(page, api_context, config):
order = api_context.post(
f"{config['base_url']}/api/orders",
data={"item": "Wireless Mouse", "qty": 1},
).json()
page.goto(f"/orders/{order['id']}")
page.get_by_role("button", name="Cancel order").click()
from playwright.sync_api import expect
expect(page.get_by_text("Order cancelled")).to_be_visible()
Cleaning up after tests¶
import pytest
@pytest.fixture
def temp_order(api_context, config):
order = api_context.post(f"{config['base_url']}/api/orders", data={}).json()
yield order
api_context.delete(f"{config['base_url']}/api/orders/{order['id']}")
# no output — the delete call after yield runs even if the test
# body fails an assertion, keeping the environment from
# accumulating orphaned test data across thousands of CI runs
How It Actually Works¶
None of .env loading, Faker, or API seeding touch Playwright's
automation layer at all — they operate purely in your Python process, one
level above the browser. What's worth understanding mechanically is why
API seeding (rather than UI seeding) is the better default once a suite
grows: api_context.post(...) uses Playwright's APIRequestContext
(Level 3 covers it fully), which sends a plain HTTP request directly from
the Node driver process — no browser, no page, no CDP navigation or
rendering involved at all. It shares the same cookie jar as a
BrowserContext you create it from, though, which is why an
API-authenticated session and a UI-driven page in the same context see
each other's login state without any extra wiring: the session cookie set
by the API call is visible to subsequent page.goto() calls in that
context, and vice versa, because both are reading and writing the same
underlying context-scoped cookie store the browser process maintains.
The yield-based teardown pattern for temp_order relies on the exact same
pytest guarantee discussed in Module 2: the code after yield runs in a
finally-equivalent block pytest manages, independent of whether the test
body passed, failed an assertion, or raised — which is what makes it safe
to rely on for cleaning up real backend state (an order, a seeded resource)
rather than a plain return plus a manual cleanup call a developer could
forget to add to every test.
Exercise¶
- Add
python-dotenvandfakerto your project, create a.envwith aBASE_URLand dummy credentials, and a matching.env.examplewith placeholders; confirm.envis gitignored. - Write a
configsession fixture reading fromos.environ, and refactor one existing test to use it instead of a hardcoded URL/credential. - Write a
new_user_datafixture usingfakeranduuidproducing a unique name/email each call; use it in a signup test, running the test twice in a row to confirm it doesn't collide on a "email already registered" error. - Write a
temp_resourcefixture that creates something via a public API (e.g.https://jsonplaceholder.typicode.com/posts), yields the created object's id, and deletes it in teardown; add a print statement in the teardown so you can see it actually running after the test body.