The browser is often the easiest part of an end-to-end test.
Consider a test that verifies a user can complete an overdue task. The visible interaction is small: sign in, find the task, click Complete, and observe the new status. Before any of that can happen, the test needs a workspace, a user, a project, and an overdue task assigned to that user. Those records must agree with the application’s domain rules and coexist with every other test running at the same time.
For teams that own the backend, database, and browser suite, I treat test-data setup as the primary design problem. It often takes more code and maintenance than the browser interaction, so it should shape the choice of testing tools.
When a capable browser tool exists in the backend’s language or ecosystem, I prefer to start there. Sharing a language gives the suite access to domain models, creation helpers, persistence tools, and application lifecycle code. That might mean Python Playwright for FastAPI or Playwright’s C# bindings for ASP.NET Core. An earlier Spin post about a unified TypeScript testing stack describes the same data-seeding pressure.
AI coding agents make this feedback loop more demanding. Slow or unpredictable end-to-end tests give an agent fewer chances to check its work before handing a change back to a developer.
The example project
The companion example project is a small todo application built with React, TypeScript, FastAPI, Pydantic, SQLAlchemy, PostgreSQL 17, Docker Compose, Python Playwright, pytest-xdist, Faker, uv, and mise.
The browser tests use Python so setup can use the application’s SQLAlchemy models and database session directly.
Keep meaningful setup inline
Test setup is documentation. A reader should understand the world surrounding a test without following a chain of scenario builders.
def test_user_sees_and_completes_their_overdue_task(
parallel_todo: ParallelTodo,
) -> None:
# Create user data
workspace = create_workspace()
user = create_user(workspace=workspace)
# Create task data
project = create_project(workspace=workspace)
task = create_task(
assignee=user,
due_date=date.today() - timedelta(days=3),
project=project,
)
The relationships that explain the test are visible. The user and project share a workspace, the task belongs to both, and its due date is three days ago.
This is shorter, but it hides those relationships:
scenario = create_overdue_task_scenario()
Shared scenario helpers also tend to accumulate options:
scenario = create_task_scenario(
authenticated=True,
completed=False,
include_another_workspace=True,
overdue=True,
)
Tests that appear independent become coupled through one abstraction and its defaults. A direct model field such as completed belongs on create_task(). A switch such as include_another_workspace changes the entire scenario graph and belongs in the test’s inline composition.
Extract how a model is created. Keep why the scenario exists inline.
Build small creation helpers
A recent Atomic post on test builders describes the value of realistic defaults and explicit overrides. These creation helpers apply the same idea to persisted SQLAlchemy models instead of in-memory objects.
The database module exposes a test-data session that each helper imports:
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
db_session = SessionLocal()
The user helper supplies generated defaults while accepting relationships and values that matter to the test:
from example.database import db_session
from tests.creation_helpers.fake import fake, unique_email
def create_user(
email: str | None = None,
name: str | None = None,
workspace: Workspace | None = None,
) -> User:
user = User(
email=email or unique_email(),
name=name or fake.name(),
workspace=workspace or create_workspace(),
)
db_session.add(user)
db_session.commit()
return user
Override a Faker default only when its value affects behavior. Otherwise, assert against user.name instead of inventing and repeating a fixed name. Keep relationships explicit when they explain the scenario.
Center each helper on one model. Create only required related data, accept direct fields, persist the result, and return it. Avoid scenario switches and large implicit graphs.
Helpers must respect domain rules, calling password hashing, events, or services when valid state requires them. Direct construction is appropriate only for valid application state.
Committing inside each helper is deliberate. FastAPI runs in another process and sees only committed setup. Every returned model is immediately visible, at the cost of a round trip per helper and possible partial scenarios. A larger suite may flush inside helpers and commit once after setup.
Sharing models couples setup to the persistence layer. A model refactor can break setup even when user-facing behavior has not changed. I accept that cost when the team owns the backend and direct setup saves more maintenance than it creates. A black-box client should arrange data through a public or test-control API instead.
Make the test read like user intent
After the inline setup, the test should read as user behavior:
parallel_todo.login.sign_in(user.email)
task_card = parallel_todo.tasks.task(task.title)
task_card.should_belong_to_project(project.name)
task_card.should_be_overdue()
task_card.complete()
task_card.should_be_completed()
parallel_todo.tasks.should_announce_completed(task.title)
The parallel_todo fixture is named after the application and provides one entry point to its page and reusable UI objects:
@pytest.fixture
def parallel_todo(page: Page, app_url: str) -> ParallelTodo:
return ParallelTodo(page, app_url)
class ParallelTodo:
def __init__(self, page: Page, base_url: str) -> None:
self.login = LoginPage(page, base_url)
self.tasks = TaskList(page)
LoginPage, TaskList, and TaskCard own Playwright locators, waiting, and assertions at useful UI boundaries. They expose signing in and completing a task instead of generic clicking and selector methods.
def sign_in(self, email: str) -> None:
self._page.goto(self._base_url)
self._page.get_by_label("Email address").fill(email)
self._page.get_by_role("button", name="Sign in").click()
Accessible names let the UI objects locate controls through the accessibility tree instead of CSS classes or DOM structure. Dedicated accessibility audits are still necessary.
Design test data for parallel execution
The browser tests share one local PostgreSQL database. Docker Compose starts a disposable instance, so developers on separate machines do not touch the same database. Within one pytest invocation, every worker connects to the same service and cannot assume its records are alone.
Each test therefore:
- Uses UUID-backed defaults for fields with uniqueness constraints.
- Keeps references to the exact models it creates.
- Signs in as its own user.
- Queries through workspace and assignment boundaries.
- Asserts against a specific task rather than global counts.
Faker’s unique provider tracks values only for the lifetime of one Faker instance. It does not coordinate across xdist workers. Fields with database uniqueness constraints combine Faker with a UUID:
from uuid import uuid4
def unique_email() -> str:
return f"{fake.user_name()}.{uuid4().hex}@{fake.free_email_domain()}"
The pytest invocation resets the disposable schema once at startup. Tests leave committed records behind so a failure preserves the database state for debugging. The next pytest invocation starts clean. This example expects one invocation at a time within a checkout.
pytest-xdist gives each worker its own process and imported db_session, while all workers share PostgreSQL. SQLAlchemy sessions cannot be used concurrently across threads, so a thread-based runner needs a different session scope. The suite closes its process-local session after each test to release the connection and recover from failed transactions without deleting committed records:
@pytest.fixture(autouse=True)
def close_db_session_after_test() -> Iterator[None]:
yield
db_session.close()
Application queries enforce the same ownership assumptions. The task API filters by both assignment and workspace:
.where(
Task.assignee_id == user.id,
Task.project.has(Project.workspace_id == user.workspace_id),
)
create_task() rejects an assignee and project from different workspaces. A focused server test bypasses that helper and verifies that the API still hides malformed cross-workspace data.
Adding CI workers cannot make a stateful test parallel-safe. It only reveals assumptions such as:
- The database starts empty.
- The first item in a list belongs to this test.
- A shared default user can be modified freely.
- Truncating the database or clearing Redis is safe cleanup.
The primary parallel lane has four Playwright tests, which pytest-xdist runs across four workers. A fifth Playwright test runs in the serial reporting lane. Each worker that receives a browser test keeps one FastAPI process on an available port. All tests write to the same PostgreSQL service while ownership keeps their scenarios isolated.
A production project can allocate a database, schema, or container per worker. That provides stronger isolation but consumes more resources and can hide shared-state assumptions. I prefer scenario-level isolation first because it exposes those assumptions.
Separate tests that require global state
Reporting, migrations, retention jobs, and system-wide batch processing may genuinely require the complete database. The example runs its task report test through a separate command:
mise run test:serial
That exception does not force ordinary workflows to run sequentially. The serial lane still needs an owner and an explicit CI policy so it does not become a collection of tests nobody runs.
Fast feedback is a feature
The primary path starts one PostgreSQL service, builds React once, starts FastAPI in each worker that receives a browser test, and runs browser workflows concurrently. Service, integration, and component tests cover permutations and edge cases faster, leaving browser tests focused on assembled-system journeys.
With PostgreSQL running, this example builds React and runs ten parallel tests plus the serial test in about five seconds on my machine. I have seen suites run 500 end-to-end tests in under five minutes. That scale comes from throughput: workers reuse infrastructure while each test owns its data and avoids global cleanup.
As coding agents shorten implementation time, verification becomes the bottleneck. A recent Spin post about quality assurance in agentic development makes the same broader point. Developers and agents need a deterministic test command cheap enough to run after every meaningful change.
When the heuristic loses
Prefer the backend’s language when the access pays off. Choose another language when:
- Its browser tooling is substantially more capable.
- Frontend engineers are the primary test owners.
- The system is polyglot and has no meaningful backend language.
- Tests run against an external black box.
- A test-control API already makes scenario construction easy.
- Mobile or platform-specific tooling dictates the language.
Matching the backend’s syntax has little value on its own. Access to backend models and test tools is what makes the choice useful.
Run the example
Install the project and browser:
mise install
mise run install
Docker must be running. The test task starts PostgreSQL through Docker Compose and waits for its health check automatically.
Run the parallel Playwright suite:
mise run test:e2e
Run the complete parallel and serial suite:
mise run test:all
Run static checks:
mise run check
Stop PostgreSQL when you are finished:
mise run db:down
I might go even further and suggest that build scripts should be in the same language.
However, there are good cases for testing in a different language. For some of my projects, I’m using markdown for human understandable and editable testing, and something to appear in documentation. This is usually higher level tests such as E2E or web.