Playwright Best Practices for Reliable Tests
Learn the essential Playwright patterns and practices that make tests more reliable, maintainable, and faster to run.
Playwright is a powerful testing framework, but without proper patterns, tests can become brittle and hard to maintain. Here are the best practices we've learned from running thousands of AI-generated tests.
1. Selector Strategy
The order of selector priority matters:
// Best: Semantic, accessible selectors await page.getByRole('button', { name: 'Submit' }) await page.getByLabel('Email address') await page.getByPlaceholder('Enter your email') // Okay: Text content (can change with copy updates) await page.getByText('Submit') // Avoid: CSS selectors (brittle) await page.locator('.btn-primary') await page.locator('#submit-button')
Why? Role-based selectors mirror how users interact with your app and are more resilient to UI changes.
2. Proper Waiting Patterns
Never use arbitrary timeouts:
// Bad: Arbitrary wait await page.waitForTimeout(3000) // Good: Wait for specific conditions await page.waitForLoadState('networkidle') await page.getByRole('button').waitFor({ state: 'visible' }) await expect(page.getByText('Success')).toBeVisible()
Pro tip: Most Playwright actions auto-wait, so explicit waits are often unnecessary.
3. Page Object Pattern
Organize tests with page objects:
class LoginPage { constructor(private page: Page) {} async login(email: string, password: string) { await this.page.getByLabel('Email').fill(email) await this.page.getByLabel('Password').fill(password) await this.page.getByRole('button', { name: 'Sign In' }).click() } async expectLoggedIn() { await expect(this.page.getByText('Welcome back')).toBeVisible() } }
This keeps tests DRY and easier to maintain.
4. Test Isolation
Each test should be independent:
// Good: Fresh state per test test('user can add item to cart', async ({ page }) => { await loginAsUser(page) // Fresh login await addItemToCart(page) await expect(page.getByText('1 item')).toBeVisible() }) // Bad: Tests depend on each other test.describe.serial('checkout flow', () => { test('login', async ({ page }) => { /* ... */ }) test('add to cart', async ({ page }) => { /* ... */ }) // Assumes logged in })
5. Assertions Best Practices
Make assertions specific and meaningful:
// Weak assertion await expect(page.locator('div')).toBeVisible() // Strong assertion await expect(page.getByRole('alert')).toContainText('Order confirmed') await expect(page.getByRole('status')).toHaveAttribute('aria-live', 'polite')
6. Error Handling
Handle expected errors gracefully:
// Test error states explicitly test('shows error on invalid email', async ({ page }) => { await page.getByLabel('Email').fill('invalid-email') await page.getByRole('button', { name: 'Submit' }).click() await expect(page.getByRole('alert')).toContainText('Invalid email') })
7. Network Mocking
Mock external dependencies:
test('loads user data', async ({ page }) => { // Mock API response await page.route('**/api/user', async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ name: 'Test User' }), }) }) await page.goto('/dashboard') await expect(page.getByText('Test User')).toBeVisible() })
8. Mobile Testing
Test responsive designs:
test('mobile menu works', async ({ page }) => { // Set mobile viewport await page.setViewportSize({ width: 375, height: 667 }) await page.goto('/') await page.getByRole('button', { name: 'Menu' }).click() await expect(page.getByRole('navigation')).toBeVisible() })
9. Screenshot and Video on Failure
Configure automatic debugging aids:
// playwright.config.ts export default defineConfig({ use: { screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure', }, })
10. Parallel Execution
Run tests faster with parallelization:
// playwright.config.ts export default defineConfig({ workers: process.env.CI ? 2 : undefined, fullyParallel: true, })
How Noet Helps
Noet automatically implements these best practices:
- Smart selectors: AI chooses the most resilient selector strategy
- Auto-waits: Proper waiting logic is built into generated code
- Self-healing: When UI changes, tests adapt automatically
- Isolation: Each generated test runs independently
Instead of memorizing all these patterns, just describe your test in plain English and let Noet generate code that follows best practices automatically.
Conclusion
Playwright is powerful, but following these patterns makes tests:
- More reliable - fewer flaky failures
- Easier to maintain - changes don't break everything
- Faster to run - proper waits and parallelization
- Better documented - clear, semantic selectors
Start applying these practices today, or let Noet handle them for you automatically.
