Reference implementation
Python Playwright
A complete, open-source test automation platform in Python: Playwright-driven UI tests across every browser your machine has, a typed REST API client with schema-validating assertions, MongoDB seeding and state verification, and one full-stack scenario that chains all three. This guide takes you from a fresh clone to writing your own tests at each layer. Every code example on this page runs and passes against the project's dockerized sample stack.
Introduction
The platform tests applications at three layers. Knowing which layer a check belongs to is the single most useful skill this guide teaches.
| Layer | Test suite | Verifies | Built on |
|---|---|---|---|
| UI | tests/e2e/ | What a user sees and does in the browser | Playwright, page objects |
| API | tests/integration/ | REST contracts: status codes, payloads, schemas | httpx client, assertion helpers |
| Database | tests/integration/ | Stored state behind the application | pymongo target, seeding, state assertions |
A fourth pattern, the full-stack scenario, chains all three: act in the UI, verify through the API, confirm the document in MongoDB. The repository ships a working one in tests/e2e/test_full_stack.py.
You will need Python 3.14+, PDM, GNU Make, Docker Desktop, and the Allure CLI — the README lists install commands for each. Then the first run is five commands:
make install # dependencies (PDM)
make install-browsers # detect browsers, write config/browsers.json
make docker-up # start the sample stack: React app, REST API, MongoDB
make test # run everything: unit, integration, e2e
make report # browse the Allure HTML reportUI Testing, Step by Step
UI tests are written against page objects, never raw selectors. The fixtures handle browsers, isolation, and failure evidence for you.
Three steps to a new UI test:
- Create a page object under
tests/e2e/pages/extendingBasePage. Implementpath(the route) andready_locator(an element visible exactly when the page is usable — this is how the platform avoids sleeps). Expose locators and actions as attributes and methods. - Write the test as a plain functiontaking the page-object fixture, and assert with Playwright's
expect, which waits intelligently. - Create unique data and clean it up — the
track_itemfixture deletes whatever your test created, even when the test fails.
class SignInPage(BasePage):
@property
def path(self) -> str:
return "/sign-in"
@property
def ready_locator(self) -> Locator:
return self.heading # visible exactly when the page is usableA complete UI test
from collections.abc import Callable
from uuid import uuid4
from playwright.sync_api import expect
from tests.e2e.pages.sample_app import SampleAppPage
def test_new_item_is_listed(
sample_app: SampleAppPage, track_item: Callable[[str], str]
) -> None:
name = track_item(f"guide-{uuid4().hex[:8]}")
sample_app.open()
sample_app.add_item(name)
expect(sample_app.items().filter(has_text=name)).to_have_count(1)Run it with make docker-up then make test-e2e. It executes once per available browser. If it fails, a full-page screenshot and a Playwright trace land in test-artifacts/, named after the test.
API Testing, Step by Step
API tests use a typed httpx client and assertion helpers whose failure messages carry the response body — diagnosable from the test report alone.
Take the api fixture (an ApiClient bound to TP_API_BASE_URL), make calls with get/post/request, and assert with assert_status, assert_json, assert_json_contains, or assert_matches_schema (JSON Schema 2020-12, reporting every violation at once). Delete what you create.
A complete API test
from testplatform.api import ApiClient
from testplatform.assertions import assert_json_contains, assert_status
def test_item_lifecycle(api: ApiClient) -> None:
created = api.post("/items", json_body={"name": "guide item"})
assert_status(created, 201)
item_id = created.json()["id"]
fetched = api.get(f"/items/{item_id}")
assert_status(fetched, 200)
assert_json_contains(fetched, {"name": "guide item"})
assert_status(api.request("DELETE", f"/items/{item_id}"), 204)
assert_status(api.get(f"/items/{item_id}"), 404)Database Testing, Step by Step
Database Testing, Step by Step
Database tests seed their own state, verify documents directly in MongoDB, and remove exactly what they seeded.
The mongo_target fixture connects to TP_MONGO_URL and fails fast with guidance when the database is down. The seeder fixture inserts documents and deletes them afterwards — by id, never by dropping collections, so it is safe against a database holding data your test does not own. Tag seeded documents with a unique marker and scope queries to it.
A complete database test
from uuid import uuid4
from testplatform.assertions import assert_collection_count, assert_field_values
from testplatform.db import MongoSeeder, MongoTarget
def test_seeded_order_state(mongo_target: MongoTarget, seeder: MongoSeeder) -> None:
tag = f"guide-{uuid4().hex[:8]}"
seeder.seed(
"orders", {"tag": tag, "status": "new"}, {"tag": tag, "status": "paid"}
)
assert_collection_count(mongo_target, "orders", 2, {"tag": tag})
assert_field_values(
mongo_target, "orders", {"tag": tag, "status": "paid"}, {"status": "paid"}
)Designing Tests for the Framework
Designing Tests for the Framework
The rules that keep a growing suite fast, trustworthy, and pleasant to work in.
| Principle | What it means in practice |
|---|---|
| Choose the lowest layer that proves the behavior | A payload contract belongs in an API test, not behind a browser. A stored side effect belongs in a database assertion. A user journey belongs in the UI suite. |
| Isolation is non-negotiable | Every test creates its own data with unique names, cleans up what it creates, and never depends on another test having run. Whole-list equality against shared state is forbidden; filter to your own data. |
| Page objects own locators; tests own assertions | If a test reaches for page.get_by_* directly, the locator belongs in a page object. If a page object asserts, that judgment belongs in the test. |
| Configuration comes from the environment | Tests read targets through load_settings(), never hardcoded URLs. Remote targets require every TP_*_URL explicitly — the platform refuses to point a remote run at localhost defaults. |
| Names state behavior | test_added_item_appears_and_input_clears tells the reader what broke without opening the file. |
Troubleshooting
The failures you will actually hit, each with its cause and fix. All of these were encountered for real while building the platform.
| Symptom | Cause | Fix |
|---|---|---|
sample API not reachable / MongoDB not reachable | The dockerized stack is down | make docker-up and wait for healthy |
| Every e2e test skipped: no usable browser inventory | Missing or invalid config/browsers.json | make install-browsers |
| Coverage failure when running one test file | The 90% gate is calibrated for the full suite | Add --no-cov for spot runs |
ValidationError naming TP_*_URL variables | Remote mode refuses defaulted localhost URLs | Set every named variable explicitly |
port is already allocated | Another process owns 3100, 8100, or 27100 | Copy .env.example to .env; change the port and its matching URL together |
| A test failed — where is the evidence? | Failure artifacts are captured automatically | Screenshots and traces in test-artifacts/; open traces with playwright show-trace; make report for Allure |
Windows: make not found | GNU Make is not installed by default | choco install make |