10 · Project — Test Plan + Automated Suite¶
This project combines everything in Level 1: you'll write a real test plan and test cases for a live web page (manual testing, Modules 1–5), then automate the regression-worthy parts with pytest and Selenium (Modules 6–9), and produce a traceability matrix and test summary report that tie the two together.
The application under test¶
We'll use the Selenium project's own practice page — publicly available, stable, and rich enough to exercise every technique:
https://www.selenium.dev/selenium/web/web-form.html
It contains: a text input, a password input, a textarea, a disabled input, a
readonly input, a dropdown (<select>), a datalist, a file input, checkboxes,
radio buttons, a colour picker, a date picker, a range slider, and a submit
button that navigates to a confirmation state showing "Received!".
Treat it as if it were a production form, and these were the requirements:
| ID | Requirement |
|---|---|
| REQ-FORM-01 | The form page loads with the title "Web form" and a visible heading |
| REQ-FORM-02 | The text input accepts free text up to 255 characters |
| REQ-FORM-03 | The password input masks entered characters |
| REQ-FORM-04 | The textarea accepts multi-line input |
| REQ-FORM-05 | The disabled input cannot be edited |
| REQ-FORM-06 | The readonly input displays a value that cannot be changed |
| REQ-FORM-07 | The dropdown offers exactly three selectable options |
| REQ-FORM-08 | Checkboxes and radio buttons are independently selectable |
| REQ-FORM-09 | Submitting the form displays the confirmation message "Received!" |
| REQ-FORM-10 | Submitted values appear as query parameters in the resulting URL |
Deliverable 1 — The test plan¶
Write docs/test-plan.md. This is a real document; keep it to two pages.
Required sections¶
# Test Plan — Web Form
## 1. Introduction
Purpose of this document and the feature under test.
## 2. Scope
### In scope
List what will be tested (functional behaviour of each form control,
submission, confirmation, URL parameters).
### Out of scope
List what will not, **and why** (e.g. performance testing — no SLA defined;
cross-browser beyond Chrome — single-browser mandate for this phase;
accessibility audit — separate specialist engagement).
## 3. Test approach
Which techniques from Module 5 you will apply to which controls, and the
manual/automation split with your justification for each side of the line.
## 4. Test environment
Browser and version, OS, screen resolution, URL, Python and package versions.
## 5. Entry and exit criteria
### Entry criteria
e.g. page reachable, environment configured, test cases reviewed.
### Exit criteria
e.g. 100% of P1 cases executed, 0 open S1/S2 defects, ≥95% pass rate,
all automated tests green in two consecutive runs.
## 6. Test deliverables
Test cases, RTM, automated suite, defect reports, test summary report.
## 7. Risks and mitigations
At least four, in a table: risk · likelihood · impact · mitigation.
e.g. "Public practice page may change without notice" ·
Medium · High · "Pin expectations to stable attributes; monitor daily run".
## 8. Schedule and effort
Phase-by-phase estimate in hours.
## 9. Roles and responsibilities
Who writes cases, who executes, who fixes, who signs off.
Deliverable 2 — Manual test cases¶
Write docs/test-cases.md with at least 20 test cases using the full
template from Module 2 — every field, numbered steps, specific and observable
expected results.
Coverage requirements:
| Requirement | Minimum cases | Techniques to apply |
|---|---|---|
| REQ-FORM-01 | 2 | Smoke |
| REQ-FORM-02 | 5 | Equivalence partitioning + BVA (0, 1, 254, 255, 256 chars) |
| REQ-FORM-03 | 2 | Functional + negative |
| REQ-FORM-04 | 2 | Functional (multi-line, special characters) |
| REQ-FORM-05/06 | 2 | Negative |
| REQ-FORM-07 | 3 | Equivalence partitioning (each option) |
| REQ-FORM-08 | 2 | State transition (toggle on/off, radio exclusivity) |
| REQ-FORM-09/10 | 4 | Functional + integration |
At least four of your cases must be negative cases, and at least four must be boundary cases. Assign a priority (P1–P4) to every case and mark the five you would automate first.
Deliverable 3 — The automated suite¶
Project structure¶
web-form-tests/
├── .gitignore
├── requirements.txt
├── pytest.ini
├── conftest.py
├── README.md
├── docs/
│ ├── test-plan.md
│ ├── test-cases.md
│ ├── traceability-matrix.md
│ └── test-summary-report.md
├── tests/
│ ├── __init__.py
│ ├── test_form_load.py
│ ├── test_text_inputs.py
│ ├── test_selection_controls.py
│ └── test_form_submission.py
└── reports/
└── screenshots/
pytest.ini¶
[pytest]
testpaths = tests
addopts = -v --strict-markers --tb=short -r a
markers =
smoke: build-verification checks
regression: full regression suite
ui: drives a real browser
boundary: boundary value analysis cases
negative: negative test cases
conftest.py¶
import os
from pathlib import Path
import pytest
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
FORM_PATH = "/selenium/web/web-form.html"
@pytest.fixture(scope="session")
def base_url():
return os.environ.get("APP_BASE_URL", "https://www.selenium.dev")
@pytest.fixture(scope="session")
def form_url(base_url):
return f"{base_url}{FORM_PATH}"
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
setattr(item, f"report_{report.when}", report)
@pytest.fixture
def driver(request):
options = webdriver.ChromeOptions()
if os.environ.get("HEADLESS", "1") == "1":
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
driver = webdriver.Chrome(options=options)
driver.set_page_load_timeout(30)
yield driver
report = getattr(request.node, "report_call", None)
if report is not None and report.failed:
folder = Path("reports/screenshots")
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"{request.node.name}.png"
driver.save_screenshot(str(path))
print(f"\n[screenshot] {path}")
driver.quit()
@pytest.fixture
def wait(driver):
return WebDriverWait(driver, timeout=10)
@pytest.fixture
def form_page(driver, form_url, wait):
"""Navigate to the form and return the driver, ready for interaction."""
driver.get(form_url)
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
wait.until(EC.visibility_of_element_located((By.NAME, "my-text")))
return driver
tests/test_form_load.py¶
"""REQ-FORM-01 — page loads correctly."""
import pytest
from selenium.webdriver.common.by import By
@pytest.mark.ui
@pytest.mark.smoke
def test_page_title_is_web_form(form_page):
"""TC-FORM-001 · REQ-FORM-01"""
assert form_page.title == "Web form"
@pytest.mark.ui
@pytest.mark.smoke
def test_page_heading_is_visible(form_page):
"""TC-FORM-002 · REQ-FORM-01"""
heading = form_page.find_element(By.TAG_NAME, "h1")
assert heading.is_displayed()
assert heading.text == "Web form"
@pytest.mark.ui
@pytest.mark.smoke
def test_all_primary_controls_are_present(form_page):
"""TC-FORM-003 · REQ-FORM-01"""
expected = ["my-text", "my-password", "my-textarea", "my-select"]
for name in expected:
element = form_page.find_element(By.NAME, name)
assert element.is_displayed(), f"{name} is not displayed"
tests/test_form_load.py::test_page_title_is_web_form PASSED [ 33%]
tests/test_form_load.py::test_page_heading_is_visible PASSED [ 66%]
tests/test_form_load.py::test_all_primary_controls_are_present PASSED [100%]
============================== 3 passed in 3.71s ===============================
tests/test_text_inputs.py¶
Boundary value analysis, executed:
"""REQ-FORM-02 to 06 — text-based inputs."""
import pytest
from selenium.webdriver.common.by import By
@pytest.mark.ui
@pytest.mark.boundary
@pytest.mark.parametrize(
"length",
[
pytest.param(0, id="empty"),
pytest.param(1, id="single_char"),
pytest.param(50, id="typical"),
pytest.param(254, id="max_minus_one"),
pytest.param(255, id="max"),
pytest.param(256, id="max_plus_one"),
],
)
def test_text_input_accepts_boundary_lengths(form_page, length):
"""TC-FORM-004..009 · REQ-FORM-02"""
value = "a" * length
field = form_page.find_element(By.NAME, "my-text")
field.clear()
field.send_keys(value)
assert field.get_attribute("value") == value
@pytest.mark.ui
@pytest.mark.parametrize(
"value",
[
pytest.param("plain text", id="plain"),
pytest.param("with spaces ", id="trailing_spaces"),
pytest.param("special!@#$%^&*()", id="special_chars"),
pytest.param("unicode — naïve café", id="unicode"),
pytest.param("<script>alert(1)</script>", id="script_tag"),
],
)
def test_text_input_accepts_various_characters(form_page, value):
"""TC-FORM-010..014 · REQ-FORM-02"""
field = form_page.find_element(By.NAME, "my-text")
field.clear()
field.send_keys(value)
assert field.get_attribute("value") == value
@pytest.mark.ui
def test_password_field_masks_input(form_page):
"""TC-FORM-015 · REQ-FORM-03"""
field = form_page.find_element(By.NAME, "my-password")
field.send_keys("Secret#123")
assert field.get_attribute("type") == "password"
assert field.get_attribute("value") == "Secret#123"
@pytest.mark.ui
def test_textarea_accepts_multiline_input(form_page):
"""TC-FORM-016 · REQ-FORM-04"""
field = form_page.find_element(By.NAME, "my-textarea")
field.send_keys("line one\nline two\nline three")
assert field.get_attribute("value").count("\n") == 2
@pytest.mark.ui
@pytest.mark.negative
def test_disabled_input_is_not_editable(form_page):
"""TC-FORM-017 · REQ-FORM-05"""
field = form_page.find_element(By.NAME, "my-disabled")
assert not field.is_enabled()
@pytest.mark.ui
@pytest.mark.negative
def test_readonly_input_retains_original_value(form_page):
"""TC-FORM-018 · REQ-FORM-06"""
field = form_page.find_element(By.NAME, "my-readonly")
original = field.get_attribute("value")
field.send_keys("attempted change")
assert field.get_attribute("value") == original
tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[empty] PASSED
tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[single_char] PASSED
tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[typical] PASSED
tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[max_minus_one] PASSED
tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[max] PASSED
tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[max_plus_one] PASSED
tests/test_text_inputs.py::test_text_input_accepts_various_characters[plain] PASSED
...
tests/test_text_inputs.py::test_readonly_input_retains_original_value PASSED
============================= 15 passed in 21.30s ==============================
tests/test_selection_controls.py¶
"""REQ-FORM-07/08 — dropdown, checkboxes, radio buttons."""
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select
@pytest.mark.ui
@pytest.mark.parametrize(
"option_text, expected_value",
[("One", "1"), ("Two", "2"), ("Three", "3")],
)
def test_dropdown_options_are_selectable(form_page, option_text, expected_value):
"""TC-FORM-019..021 · REQ-FORM-07"""
dropdown = Select(form_page.find_element(By.NAME, "my-select"))
dropdown.select_by_visible_text(option_text)
assert dropdown.first_selected_option.get_attribute("value") == expected_value
@pytest.mark.ui
def test_dropdown_offers_exactly_three_selectable_options(form_page):
"""TC-FORM-022 · REQ-FORM-07"""
dropdown = Select(form_page.find_element(By.NAME, "my-select"))
selectable = [o for o in dropdown.options if o.get_attribute("value")]
assert len(selectable) == 3
@pytest.mark.ui
def test_checkbox_toggles_independently(form_page):
"""TC-FORM-023 · REQ-FORM-08 — state transition"""
checked = form_page.find_element(By.ID, "my-check-1")
unchecked = form_page.find_element(By.ID, "my-check-2")
assert checked.is_selected()
assert not unchecked.is_selected()
unchecked.click()
assert unchecked.is_selected()
assert checked.is_selected(), "toggling one checkbox must not affect the other"
unchecked.click()
assert not unchecked.is_selected()
@pytest.mark.ui
def test_radio_buttons_are_mutually_exclusive(form_page):
"""TC-FORM-024 · REQ-FORM-08 — state transition"""
first = form_page.find_element(By.ID, "my-radio-1")
second = form_page.find_element(By.ID, "my-radio-2")
assert first.is_selected()
second.click()
assert second.is_selected()
assert not first.is_selected(), "selecting a radio must deselect its sibling"
tests/test_selection_controls.py::test_dropdown_options_are_selectable[One-1] PASSED
tests/test_selection_controls.py::test_dropdown_options_are_selectable[Two-2] PASSED
tests/test_selection_controls.py::test_dropdown_options_are_selectable[Three-3] PASSED
tests/test_selection_controls.py::test_dropdown_offers_exactly_three_selectable_options PASSED
tests/test_selection_controls.py::test_checkbox_toggles_independently PASSED
tests/test_selection_controls.py::test_radio_buttons_are_mutually_exclusive PASSED
============================== 6 passed in 9.12s ===============================
tests/test_form_submission.py¶
"""REQ-FORM-09/10 — submission and confirmation."""
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
@pytest.mark.ui
@pytest.mark.smoke
def test_submitting_form_shows_confirmation(form_page, wait):
"""TC-FORM-025 · REQ-FORM-09"""
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button"))).click()
message = wait.until(EC.visibility_of_element_located((By.ID, "message")))
assert message.text == "Received!"
@pytest.mark.ui
@pytest.mark.regression
def test_submitted_text_appears_in_url(form_page, wait):
"""TC-FORM-026 · REQ-FORM-10"""
form_page.find_element(By.NAME, "my-text").send_keys("QA automation")
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button"))).click()
wait.until(EC.visibility_of_element_located((By.ID, "message")))
assert "my-text=QA+automation" in form_page.current_url
@pytest.mark.ui
@pytest.mark.regression
def test_all_entered_values_are_submitted(form_page, wait):
"""TC-FORM-027 · REQ-FORM-10 — integration of every control"""
from selenium.webdriver.support.ui import Select
form_page.find_element(By.NAME, "my-text").send_keys("regression")
form_page.find_element(By.NAME, "my-password").send_keys("Secret123")
form_page.find_element(By.NAME, "my-textarea").send_keys("notes")
Select(form_page.find_element(By.NAME, "my-select")).select_by_visible_text("Two")
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button"))).click()
wait.until(EC.visibility_of_element_located((By.ID, "message")))
url = form_page.current_url
for expected in ["my-text=regression", "my-password=Secret123",
"my-textarea=notes", "my-select=2"]:
assert expected in url, f"missing {expected} in {url}"
@pytest.mark.ui
@pytest.mark.negative
def test_empty_form_still_submits(form_page, wait):
"""TC-FORM-028 · REQ-FORM-09 — no fields are mandatory"""
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button"))).click()
message = wait.until(EC.visibility_of_element_located((By.ID, "message")))
assert message.text == "Received!"
Running it¶
pytest # everything
pytest -m smoke # build verification only
pytest -m "boundary or negative"
pytest -m ui --durations=5
HEADLESS=0 pytest -m smoke # watch it run in a visible browser
============================= test session starts ==============================
platform darwin -- Python 3.11.9, pytest-8.3.2, pluggy-1.5.0
rootdir: /Users/you/web-form-tests
configfile: pytest.ini
testpaths: tests
collected 28 items
tests/test_form_load.py ... [ 10%]
tests/test_form_submission.py .... [ 25%]
tests/test_selection_controls.py ...... [ 46%]
tests/test_text_inputs.py ............... [100%]
============================= slowest 5 durations ==============================
2.41s call tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[max_plus_one]
2.38s call tests/test_text_inputs.py::test_text_input_accepts_boundary_lengths[max]
1.62s setup tests/test_form_load.py::test_page_title_is_web_form
...
============================= 28 passed in 47.16s ==============================
Deliverable 4 — Traceability matrix¶
docs/traceability-matrix.md, in the format from Module 2:
| Req ID | Requirement | Manual cases | Automated tests | Coverage | Status |
|---|---|---|---|---|---|
| REQ-FORM-01 | Page loads with correct title | TC-FORM-001..003 | test_form_load.py (3) |
Full | Pass |
| REQ-FORM-02 | Text input up to 255 chars | TC-FORM-004..014 | test_text_inputs.py (11) |
Full | Pass |
| REQ-FORM-03 | Password masked | TC-FORM-015 | test_password_field_masks_input |
Full | Pass |
| … | … | … | … | … | … |
Fill in all ten requirements, and explicitly flag any with zero coverage.
Deliverable 5 — Test summary report¶
docs/test-summary-report.md:
# Test Summary Report — Web Form
## 1. Overview
Feature, build/date tested, environment, who executed.
## 2. Execution summary
| Metric | Value |
|---|---|
| Test cases planned | 28 |
| Executed | 28 |
| Passed | 26 |
| Failed | 1 |
| Blocked | 0 |
| Skipped | 1 |
| Pass rate | 92.9% |
| Automated | 28 of 28 (100%) |
| Total execution time | 47.2 s |
## 3. Requirement coverage
Summarise the RTM: X of 10 requirements fully covered, any gaps named.
## 4. Defects
| ID | Title | Severity | Priority | Status |
|---|---|---|---|---|
Full bug reports (Module 4 template) as an appendix.
## 5. Exit criteria assessment
Each criterion from the test plan, with met/not-met and evidence.
## 6. Risks and recommendation
Outstanding risks, and a clear go / no-go recommendation with justification.
## 7. Lessons learned
What you'd do differently, including anything about testability of the page.
How It Actually Works¶
Pulling this project's pieces together (pytest collection, fixture scoping, Selenium's WebDriver protocol) into one suite surfaces an interaction that doesn't show up in any single-topic lesson: fixture teardown order interacting with browser process lifecycle.
A scope="session" WebDriver fixture is cached for the entire pytest run, but its
yield-based teardown (driver.quit()) is only guaranteed to run when pytest's
fixture-finalizer stack unwinds — normally at the end of the session, but also
immediately if an earlier fixture in the same dependency chain raises during setup.
If your suite's browser fixture setup fails halfway (browser launched, but a
subsequent driver.get(url) throws), pytest still calls any already-registered
finalizers for that fixture instance, which is why driver.quit() reliably runs even
on setup failure only if you used yield (registering teardown as you go) rather
than a plain return with cleanup coded separately at the end of the test — a
return-based fixture has no mechanism to run cleanup when the fixture function
itself raises before reaching that cleanup code.
The suite's overall exit code is the logical OR of every collected item's outcome —
pytest's session object accumulates a testsfailed counter across every Item, and
the process exits non-zero if that counter is nonzero, regardless of how deep in the
Selenium/fixture/parametrize stack the failure occurred. This single integer is the
entire contract your CI pipeline (Level 3) relies on to gate a merge.
Acceptance criteria¶
Your project is complete when:
- [ ]
docs/test-plan.mdcovers all nine required sections, with at least four risks - [ ]
docs/test-cases.mdhas ≥20 fully-specified cases, including ≥4 negative and ≥4 boundary - [ ] Every case has a priority and a requirement ID
- [ ] The automated suite has ≥25 collected tests and passes cleanly twice in a row
- [ ] Every test has a docstring citing its test case ID and requirement ID
- [ ] Fixtures handle all setup and teardown — no
driver.quit()inside a test body - [ ] No
time.sleep()anywhere; all waits are explicit - [ ] Markers work:
pytest -m smoke,-m boundary,-m negativeeach select the right subset - [ ] Screenshot-on-failure is proven working
- [ ]
requirements.txtis pinned and the suite runs from a clean venv - [ ]
docs/traceability-matrix.mdmaps all ten requirements - [ ]
docs/test-summary-report.mdis complete with real numbers from your run - [ ]
README.mdexplains how to install and run in under five commands
Extension challenges¶
- Cross-browser. Convert the
driverfixture into a parametrized fixture over Chrome and Firefox, and run the whole suite against both. - HTML report.
pip install pytest-html, thenpytest --html=reports/report.html --self-contained-html. Attach it to your summary report. - Data-driven from a file. Move the boundary values into
data/text_input_cases.jsonand load them in the parametrize decorator, so a non-programmer can add cases. - Deliberate defect hunt. Test the file upload, colour picker, date picker and range slider — controls this suite ignores. Write proper bug reports for anything that surprises you, including usability issues an automated test could never catch.
- CI. Add
.github/workflows/tests.ymlrunning the suite on every push. You'll do this properly in Level 3, but attempting it now shows you what Level 3 solves.
Keep this project. A working suite with a real test plan and traceability matrix is a stronger portfolio piece than any certificate, because it demonstrates both halves of the job: the thinking and the automation.