Playwright Intro - discussion (2022-07-09)
We're trying to get serious about route-flow testing and we keep bouncing between "test like a user" and "assert the contract" styles.
If your app renders signals (nav, fetch, cache lanes), do you assert on those directly in E2E tests? How do you keep Playwright tests stable when the UI changes but behavior shouldn't? And what do you do to debug flake (traces, screenshots, deterministic data, signals)?
Comments (10)
Back to latestIf it's flaky, it's usually data. I try to make data deterministic before I fight selectors.
I assert on signals for the "why", not the "what".
Example: assert the user sees the right screen using roles/text, then assert navIntent.reason or cacheLane to prove the contract path.
We borrowed the pattern from Testing and Debugging.
Our most stable tests do both: user-facing assertions + signal assertions.
ts
test('checkout route contract', async ({ page }) => {
await page.goto('/checkout');
await expect(page.getByRole('heading', { name: /checkout/i })).toBeVisible();
const nav = await page.getAttribute('[data-signal="navIntent"]', 'data-value');
expect(JSON.parse(nav ?? 'null')).toMatchObject({ to: 'checkout' });
});
If the UI changes, the heading assertion might need updates, but the signal assertion catches accidental behavior drift.
Counterpoint: signals can turn tests into "assert internal state" and you stop noticing broken UX.
We require at least one focus/a11y assertion per flow (focus trap, modal close, keyboard nav), then signals for routing/fetch behavior.
That makes sense. The failures we're seeing are mostly "why did it refetch twice" and "why did it redirect", not "button label changed".
So signals feel like the right tool for the hard part, but we shouldn't lose UX coverage.
Yep. Use signals to explain behavior, not as the only truth you test.
Playwright traces are the only thing that made us stop arguing about flake.
But you still need contracts to make the trace explainable.
We reduced selector fragility by leaning on roles, but the big win was stable signals.
If fetchSignal says status=ready, we don't have to guess whether a spinner was supposed to disappear yet.
We had a back/forward bug that only reproduced in E2E because it depended on a popstate path no one clicked manually.
Once we rendered navIntent and historySignal, the test failures became actionable instead of spooky.
If you can, build a single test helper that reads signals, so tests don't import internal modules.
Keeping it as DOM data makes it feel more like behavior than implementation.
When a test fails, a screenshot + signals should already tell you what happened. If it can't, add one signal, don't add ten waits.
Treat your signal schema like API and tests like clients.
If you change signal shape casually, you just moved the instability from selectors to signals.