Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 070 / 100

Contract between frontend and E2E tests, explained simply

Make tests resilient to UI changes.

Expona Admin7 min read

Contract between frontend and E2E tests, explained simply

Your E2E tests are brittle. You change a button's color in CSS, and suddenly three tests fail. You refactor a form component, and the entire checkout flow breaks. The real problem isn't your test logic—it's that your frontend and tests have no agreed contract. When both sides know what to expect from each other, tests survive UI changes and actually protect your product.

A contract between frontend and E2E tests is simple: the frontend commits to stable, meaningful selectors and predictable navigation; the tests commit to validating behavior, not implementation details. This separation keeps tests resilient while letting designers and engineers ship faster without fear of breaking automation.

Accessible names — Buttons and inputs with clear labels

The strongest contract starts with accessibility. When your buttons and inputs have proper labels, both screen readers and E2E tests have a stable way to find them. This isn't a nice-to-have; it's the foundation of resilient test automation.

Consider a common checkout flow. Your frontend team builds a "Pay now" button. Without a contract, your test might query by class name:

typescript
// ❌ Brittle: breaks if CSS class changes
await page.click('.btn-primary.checkout-action');

With a contract, your button has a proper accessible name via a label or aria-label:

typescript
// ✅ Resilient: finds by accessible name
await page.click('button:has-text("Pay now")');

Better still, use getByRole, which queries by semantic role and accessible name:

typescript
// ✅ Best: semantic and accessible
await page.getByRole('button', { name: 'Pay now' }).click();

For form inputs, the contract is equally clear. Every input should have an associated label:

typescript
// HTML
<label htmlFor="email-input">Email address</label>
<input id="email-input" type="email" />

// Test
await page.getByLabel('Email address').fill('test@example.com');

This works because the label text is part of the accessible name. When QA or a new engineer reads the test, they immediately understand what field is being filled. When the designer changes the input's width or padding, the test still works.

The contract here is: if it's interactive, it has a meaningful, stable accessible name. That name might come from a label, button text, aria-label, or aria-labelledby. The test queries by that name, not by class, id, or DOM structure.

Test ids policy — Add data-testid for complex widgets

Accessible names handle most cases. But some UI components don't have natural text labels. A star-rating widget, a carousel navigation button, or a custom dropdown selector might be semantically correct but lack a clear accessible name.

For these, introduce a data-testid as a second-line contract. The policy is simple: use data-testid sparingly, only for widgets where getByRole or getByLabel won't work reliably.

typescript
// HTML: custom star-rating component
<div data-testid="product-rating" role="slider" aria-valuenow="4">
  <span>★★★★☆</span>
</div>

// Test
const rating = await page.locator('[data-testid="product-rating"]');
await expect(rating).toHaveAttribute('aria-valuenow', '4');

The contract here is: data-testid is explicit, never changes without developer awareness, and lives only on non-standard components. A button always uses getByRole. A text input always uses getByLabel. But a custom carousel, date picker, or chart gets a data-testid that both frontend and QA agree on.

To enforce this, document your policy in your testing guidelines or a shared README:

data-testid Policy: Use data-testid only for custom components or interactive elements without a semantic role. Always pair it with role and aria attributes. Rename data-testid only if you update the corresponding test. Prefer getByRole and getByLabel first.

Many teams make the mistake of adding data-testid to every element. This defeats the purpose. Your tests become brittle again because they're coupled to implementation details. Instead, require developers to ask: "Does this element have a natural accessible name?" If yes, use getByRole or getByLabel. If no, and only then, add data-testid.

Stable routes — Avoid changing URLs without reason

Navigation is another pillar of the contract. Your app's routes should be stable, meaningful, and predictable. When you change a URL arbitrarily, you break both user bookmarks and E2E tests.

A common scenario: your product evolves from /dashboard to /workspace/123/dashboard. Both tests and users have to update their links. But if your original route was semantically clear—/workspace/:id/dashboard instead of /app/:token/view—you signal stability and intent.

The contract is: routes are semantic, versioned if needed, and only change with documentation and a migration period.

typescript
// ✅ Stable route structure
// /settings → /account/settings
// Users and tests navigate to predictable paths

// ❌ Unstable route structure
// /admin?view=settings&user=123 → /panel/configuration
// Both users and tests break silently

// Test example
// Good: route names follow feature structure
await page.goto('/orders/123/invoice');

// Risky: route changes without warning
await page.goto('/api/v2/orders?id=123&format=pdf');

When you must change a route—say, refactoring from /orders/:id to /account/:id/orders/:id for multi-tenant support—plan for it:

  1. Update tests first to use the new route, noting the migration in a comment.
  2. Add a redirect from the old route to the new one (301 for permanent, 307 for temporary).
  3. Document the change in your release notes and testing guide.
  4. Wait at least one release cycle before removing the redirect.

This way, old tests and old links still work while the frontend gradually shifts over. Your E2E suite isn't a surprise casualty of a refactor.

Review together — Developers own tests for their features

The final part of the contract is process. Tests aren't owned by QA alone. Developers who build features should write and review the E2E tests for those features. This ensures the contract is honored from day one.

When a developer writes a form component, they should also write or review the test that validates it. They ask themselves: "What stable selectors am I providing? What accessible names? What data-testid might be needed?" This mindset shifts the focus from "let's add more tests" to "let's build testable features."

In your pull request template, include:

typescript
## E2E Test Coverage
- [ ] New feature has corresponding E2E test(s)
- [ ] Selectors use getByRole/getByLabel where possible
- [ ] Any data-testid additions are documented and necessary
- [ ] Tests pass locally and in CI
- [ ] QA/test engineer has reviewed the test approach

The contract here is: every feature ships with a test, and the developer who built the feature ensures the test is resilient and maintainable.

Real-world impact: at one e-commerce team, developers wrote their own E2E tests. When a designer wanted to replace a button class name, they checked the tests first. They saw that tests used getByRole, so the change was safe. No test failure, no last-minute panic. When a QA engineer suggested adding a data-testid to a complex dropdown, the developer explained the existing accessible name and role attributes were sufficient. The team aligned on the approach before writing code.

Common mistakes

Over-relying on CSS selectors. Tests that query by .btn-primary or #submit-btn fail the moment a designer tweaks the design system. Always prefer semantic queries first.

Forgetting aria attributes. A button might not have text content if it only shows an icon. Add aria-label or aria-labelledby so getByRole finds it reliably: await page.getByRole('button', { name: 'Close menu' }).

Misusing data-testid as a catch-all. Adding data-testid to every interactive element defeats the contract and makes tests brittle again. Reserve it for truly non-standard widgets.

Changing URLs without a migration plan. A seemingly small route refactor can break dozens of tests and user workflows. Always redirect old URLs for at least one release cycle.

Writing tests in isolation. If QA writes tests without involving the developer who built the feature, you'll discover accessibility gaps and poor selectors too late. Code review the test as seriously as you code review the feature.

Key takeaways

  • A contract between frontend and E2E tests means both sides agree on stable, meaningful selectors and predictable behavior. Tests query by accessible names; the frontend commits to providing them.
  • Use getByRole and getByLabel as your primary queries. They're resilient because they depend on semantics, not implementation. Reserve data-testid for custom components only.
  • Keep routes semantic and stable. Avoid arbitrary URL changes. When refactoring, add redirects and give users and tests time to migrate.
  • Developers own tests for their features. Review E2E tests in pull requests with the same rigor as production code. If a test is brittle, that's feedback to improve the feature's testability.
  • A well-designed contract reduces flaky tests, speeds up feature development, and makes your product more accessible. Everyone wins.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles