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.
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.
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?
Before establishes a known precondition; after prevents the case from contaminating the next one and still runs after an assertion failure.
Why use an ephemeral loopback port?
It avoids fixed-port collisions and makes each fixture instance own a distinct disposable endpoint.
Does subTest automatically isolate browser state?
No. The explicit isolated_case fixture creates and quits a fresh WebDriver for each parameter row.
What proves the UI mutation caused the expected AUT state?
The visible result is asserted in the browser and the synthetic API state independently shows exactly the expected record.
Where should SELENIUM_BROWSER be interpreted?
In test infrastructure/configuration, not inside a Page Object or business assertion.
Official references and version notes
- Selenium 4.47 release notes — stable binding/Grid baseline pinned for this chapter.
- Selenium downloads — current stable Selenium client and Server/Grid versions.
- Overview of Test Automation — keep browser setup/actions/evaluation small and use lower layers where they provide the right signal.
- Avoid sharing state — isolate test data and prefer a new WebDriver instance per test.
- Fresh browser per test — start from a clean known browser state.
- Test independency — do not make one scenario depend on another scenario's state.
- Generating application state — repetitive application setup is usually more stable through a lower-layer API than through browser UI.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.