Chapter 14Lesson 02~225 minutes

Test Data, Parameterization, Fixtures, and Environment Configuration: Guided Hands-On Workflow

The mental model becomes useful only when cleanup survives both success and failure. This lesson builds a loopback registration AUT, seeds/resets it through a lower-layer API, runs the same browser behavior over named synthetic rows, passes browser/environment inputs explicitly, and proves state is clean after every case.

ParameterizationunittestAPI resetFresh sessionsLoopback AUT

Learning objectives

  • Run a disposable in-memory application fixture bound only to loopback.
  • Parameterize one registration workflow over multiple named synthetic cases.
  • Use an API reset as fixture setup/teardown while keeping registration itself in the browser.
  • Select the browser through explicit configuration and record returned session/capability evidence.
  • Prove cleanup executes after both passing and intentionally failing assertions.

1. Scenario and safety boundary

The lab has one synthetic registration page and an in-memory API. No real account, database, cloud service, profile, or external host is used. The app binds to 127.0.0.1 on an ephemeral port, so each run receives its own URL.

Safety: all names/emails use example.test; reset deletes only the in-memory list owned by this process.

2. Build the disposable application fixture

The following example makes the Build the disposable application fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import json

HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Synthetic Registration Lab</title>
<style>body{font-family:system-ui,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem}label{display:block;margin:.75rem 0}input,select,button{padding:.45rem .65rem}#result{margin-top:1rem;font-weight:700}</style></head>
<body><h1>Registration Lab</h1><form id="signup">
<label>Name <input data-testid="name" required></label>
<label>Email <input data-testid="email" type="email" required></label>
<label>Role <select data-testid="role"><option>viewer</option><option>editor</option><option>admin</option></select></label>
<button data-testid="submit" type="submit">Create synthetic user</button></form><p id="result" data-testid="result">ready</p>
<script>
const form=document.querySelector('#signup');
form.addEventListener('submit',async e=>{e.preventDefault();const payload={name:document.querySelector('[data-testid=name]').value,email:document.querySelector('[data-testid=email]').value,role:document.querySelector('[data-testid=role]').value};const r=await fetch('/api/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const body=await r.json();document.querySelector('#result').textContent=r.ok?`created:${body.email}`:`error:${body.error}`;});
</script></body></html>"""

class Handler(BaseHTTPRequestHandler):
    state = {"users": []}

    def log_message(self, fmt, *args):
        return

    def _json(self, status, payload):
        raw = json.dumps(payload).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def do_GET(self):
        if self.path == "/":
            raw = HTML.encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(raw)))
            self.end_headers()
            self.wfile.write(raw)
        elif self.path == "/health":
            self._json(200, {"ok": True})
        elif self.path == "/api/state":
            self._json(200, self.state)
        else:
            self._json(404, {"error": "not found"})

    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length) if length else b"{}"
        if self.path == "/api/reset":
            self.state = {"users": []}
            type(self).state = self.state
            self._json(200, {"reset": True})
            return
        if self.path == "/api/users":
            payload = json.loads(body or b"{}")
            email = payload.get("email", "")
            if any(x["email"] == email for x in self.state["users"]):
                self._json(409, {"error": "duplicate email"})
                return
            record = {"name": payload.get("name", ""), "email": email, "role": payload.get("role", "viewer")}
            self.state["users"].append(record)
            self._json(201, record)
            return
        self._json(404, {"error": "not found"})

@contextmanager
def running_app():
    Handler.state = {"users": []}
    server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    try:
        yield f"http://{host}:{port}/"
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=2)

running_app() owns server start and shutdown. Its state is process-local and begins empty. Using port 0 asks the OS for an available loopback port, avoiding hard-coded port collisions in parallel local runs.

3. Reuse a small UI abstraction

The following example makes the Reuse a small UI abstraction behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select, WebDriverWait

class RegistrationPage:
    NAME = (By.CSS_SELECTOR, "[data-testid='name']")
    EMAIL = (By.CSS_SELECTOR, "[data-testid='email']")
    ROLE = (By.CSS_SELECTOR, "[data-testid='role']")
    SUBMIT = (By.CSS_SELECTOR, "[data-testid='submit']")
    RESULT = (By.CSS_SELECTOR, "[data-testid='result']")

    def __init__(self, driver, base_url):
        self.driver = driver
        self.base_url = base_url

    def open(self):
        self.driver.get(self.base_url)
        WebDriverWait(self.driver, 3).until(lambda d: d.title == "Synthetic Registration Lab")

    def submit(self, row):
        name = self.driver.find_element(*self.NAME)
        email = self.driver.find_element(*self.EMAIL)
        name.clear(); name.send_keys(row["name"])
        email.clear(); email.send_keys(row["email"])
        Select(self.driver.find_element(*self.ROLE)).select_by_visible_text(row["role"])
        self.driver.find_element(*self.SUBMIT).click()
        WebDriverWait(self.driver, 3).until(
            lambda d: d.find_element(*self.RESULT).text != "ready"
        )
        return self.driver.find_element(*self.RESULT).text

The page object owns locators and browser synchronization only. It does not choose the environment, create synthetic users, reset the application, or own the driver lifecycle.

4. Lower-layer setup/reset helpers

The following example makes the Lower-layer setup/reset helpers behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

import json
from urllib.request import Request, urlopen


def api_post(base_url, path, payload=None):
    raw = json.dumps(payload or {}).encode()
    req = Request(base_url.rstrip("/") + path, data=raw,
                  headers={"Content-Type": "application/json"}, method="POST")
    with urlopen(req, timeout=2) as response:
        return json.loads(response.read())


def api_state(base_url):
    with urlopen(base_url.rstrip("/") + "/api/state", timeout=2) as response:
        return json.loads(response.read())


def api_reset(base_url):
    return api_post(base_url, "/api/reset")

The API does not replace the UI action under test. It provides deterministic prerequisite and cleanup state. That separation keeps browser work focused on user behavior.

5. Explicit browser/environment configuration

The following example makes the Explicit browser/environment configuration behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

import os
from dataclasses import dataclass
from selenium import webdriver

@dataclass(frozen=True)
class Settings:
    browser: str
    environment: str

    @classmethod
    def from_env(cls):
        return cls(
            browser=os.getenv("SELENIUM_BROWSER", "chrome").lower(),
            environment=os.getenv("TEST_ENV", "local"),
        )


def new_driver(browser):
    if browser == "chrome":
        return webdriver.Chrome()
    if browser == "firefox":
        return webdriver.Firefox()
    raise ValueError(f"unsupported local teaching browser: {browser}")

Only browser selection is configurable here; the mandatory target URL comes from the loopback fixture. A later checkpoint adds an explicit URL override with a guard that refuses non-loopback targets.

6. Named deterministic rows

The following example makes the Named deterministic rows behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

CASES = [
    {"id": "viewer-valid", "name": "Ava Example", "email": "ava.viewer@example.test", "role": "viewer"},
    {"id": "editor-valid", "name": "Omar Example", "email": "omar.editor@example.test", "role": "editor"},
    {"id": "admin-valid", "name": "Lin Example", "email": "lin.admin@example.test", "role": "admin"},
]

No case reads another case’s result. The rows are intentionally boring: deterministic synthetic data is easier to diagnose than novelty-driven randomness.

7. A per-case fixture that survives failure

The following example makes the A per-case fixture that survives failure behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from contextlib import contextmanager

@contextmanager
def isolated_case(base_url, browser):
    api_reset(base_url)
    driver = new_driver(browser)
    try:
        yield driver
    finally:
        try:
            driver.quit()
        finally:
            api_reset(base_url)

The nested finally structure means the AUT reset is attempted even if quit() itself fails. In production infrastructure you would preserve both failures rather than swallowing them, but the ownership order remains: case → driver → AUT reset.

8. Run the browser workflow over every row

The following example makes the Run the browser workflow over every row behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

import unittest
import selenium

class RegistrationTests(unittest.TestCase):
    def test_parameterized_registration(self):
        settings = Settings.from_env()
        with running_app() as base_url:
            for row in CASES:
                with self.subTest(case=row["id"]):
                    with isolated_case(base_url, settings.browser) as driver:
                        page = RegistrationPage(driver, base_url)
                        page.open()
                        observed = page.submit(row)
                        self.assertEqual(observed, f"created:{row['email']}")
                        state = api_state(base_url)
                        self.assertEqual(state["users"], [{
                            "name": row["name"], "email": row["email"], "role": row["role"]
                        }])
                        print({
                            "case": row["id"],
                            "selenium": selenium.__version__,
                            "session_id": driver.session_id,
                            "browser": driver.capabilities.get("browserName"),
                            "browser_version": driver.capabilities.get("browserVersion"),
                            "url": driver.current_url,
                        })

if __name__ == "__main__":
    unittest.main(verbosity=2)

Each row receives fresh browser state and freshly reset AUT state even though unittest.subTest keeps the behavior compact. The returned API state independently verifies that the UI created exactly one expected record.

9. Prove cleanup after a failure path

Temporarily change the expected result for only editor-valid to created:wrong@example.test. The assertion should fail, but the context manager must still quit that session and reset the AUT before the next subtest. Preserve the traceback, then restore the correct expectation.

Expected diagnostic shape:
FAIL: ... (case='editor-valid')
AssertionError: 'created:omar.editor@example.test' != 'created:wrong@example.test'

Next case still starts with:
GET /api/state -> {"users": []} before browser mutation

10. Before/after state matrix

The following table organizes the key choices and evidence for Before/after state matrix. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.

Step Browser/session state AUT state Evidence
before case no case driver yet users=[] case ID + settings
driver created new session ID/capabilities users=[] session provenance
form submitted DOM result changes one synthetic user URL/result/API state
assertion fails or passes same session until fixture exits same one user trace/result
fixture exits driver quit users=[] again cleanup confirmation

11. Challenge: choose the correct control

You need five different user roles for a permissions scenario, but the role-assignment UI is not itself under test. Which layer should create those users? Use a deterministic synthetic data builder plus a disposable lower-layer API fixture, then use Selenium only for the permission behavior. If role assignment is the feature under test, move that action back into the browser scenario.

12. Cleanup

The context managers stop the HTTP server, quit every browser, and reset the in-memory users. No persistent files are required. If you copied the snippets into a lab directory, remove that directory after preserving only non-sensitive test output needed for review.

13. Summary and next step

Parameterization is reliable when every row has a diagnostic ID, a fresh browser/AUT boundary, explicit configuration, and failure-safe cleanup. Lower-layer reset keeps browser work focused on the behavior under test.

Knowledge check

Why does the lab reset the AUT both before and after each case?

Why use an ephemeral loopback port?

Does subTest automatically isolate browser state?

What proves the UI mutation caused the expected AUT state?

Where should SELENIUM_BROWSER be interpreted?

Next lesson

Choose the right data/configuration pattern

Lesson 3 compares inline versus external data, UI versus API setup, fixture scope, deterministic generation, config files/environment variables, and matrix breadth.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was rechecked against Selenium primary documentation on 2026-08-28. Mandatory examples pin Selenium Python 4.47.0 and Python 3.10+, use Python standard-library unittest for parameterization/fixture examples, use a supported locally installed Chromium-family browser with Selenium Manager, and target only loopback synthetic applications. Parameterization, fixture scope, environment parsing, and secret injection are test-runner/application-infrastructure concerns rather than WebDriver capabilities. The mandatory path deliberately uses Python standard-library unittest rather than requiring pytest. Selenium guidance is applied by keeping tests independent, using fresh browser sessions, and moving repetitive prerequisite/reset work to a disposable lower-layer API when that setup is not the UI behavior under test.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.