Playwright on a Laravel + Vue App: Browser Tests That Don't Flake
A Laravel feature test can prove the endpoint works and still miss the bug that matters, because the client side is where a modern app actually breaks: the modal that never closes after a failed save, the form that submits twice on a double click, the page that renders an empty list while the request is still in flight. Browser tests catch that class of bug. The catch is that badly written ones catch it randomly, which is how E2E testing gets a bad reputation and gets deleted three months after it was added.
Test the critical handful
End-to-end tests are slow compared to feature tests, at seconds per test rather than milliseconds, so the budget is small. Pick the paths where a breakage costs money or an account:
- Signup, login, password reset.
- The money path: checkout, subscription change, the one action the product exists for.
- The settings save, and whatever deletes data.
For a typical SaaS that is somewhere between five and fifteen scenarios. Everything else belongs in feature tests, which are cheaper to write and quicker to fail.
Put state where it is cheap
The most common beginner mistake is driving the whole world through the UI: registration via the signup form, plan setup via four clicks of onboarding, then finally the scenario you actually wanted to test. That multiplies runtime and makes every test fail together the moment the signup page changes.
Set up through the API or the database, act through the UI:
test('saving a monitor shows it in the list', async ({ page }) => {
const user = await createUser({ plan: 'pro' }); // API or factory call
await login(page, user); // seeded session, not the login form
await page.goto('/monitors');
await page.getByTestId('new-monitor').click();
await page.getByLabel('URL').fill('https://example.com');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('row', { name: /example\.com/ })).toBeVisible();
});
The UI gets exercised exactly where the UI is what you are testing. One page change breaks one scenario, not the suite.
Selectors that survive redesigns
Tailwind classes change when someone rearranges a layout, and they make terrible test targets. Roles and labels survive redesigns and double as accessibility checks: getByRole('button', { name: 'Save' }) breaks only when the button truly stops being a save button. For the few places where nothing semantic exists, add a data-testid attribute. It is two bytes of markup and it ends the argument about whether tests should chase CSS.
The flakiness checklist
Flaky tests are almost always timing, and almost every timing hack is wrong:
- No sleeps.
waitForTimeout(2000)is a coin flip. Playwright auto-waits for elements to be actionable; assertions wait on their own. - Wait for the state, not the spinner. Instead of waiting for a loading spinner to vanish, assert on the thing the user cares about: the row appearing, the toast text, the button flipping back to enabled.
- Disable animations in the test config; a half-finished CSS transition turns a visible element into a non-clickable one at random.
- Let Playwright retry failed tests, not every test. Retrying on failure in CI with a trace attached turns a red run into a diagnosis instead of a shrug.
When a test does flake, fix the cause rather than raising timeouts. A suite that randomly fails twice a week gets ignored, and once it gets ignored it stops protecting anything.
What not to test
Not pixel positions, because they break on font rendering differences and say nothing about whether the product works. Not third-party iframes and not the inside of a payment provider's checkout; their behavior is their contract, and your test doubles only what you built. Not email content byte for byte: send into Mailpit or a similar catcher and assert the message arrived with the right link, nothing more.
One run in CI, honestly run
The usual shape for a small team: Playwright against a seeded database, headless Chromium in CI, full suite on pull requests that touch the app, traces uploaded only for retried failures. The runtime for a suite of a dozen well-built scenarios lands under a couple of minutes, and that number matters: a suite that finishes inside the coffee break gets run, and a suite that gets run is the one that stops the broken checkout from shipping.
Treat browser tests as coverage for the last hop, the one from the endpoint to the screen. That hop is where your users live, and it deserves a handful of tests that pass every single time.