This guide provides best practices, patterns, and guidelines for writing and maintaining E2E tests for the Svelte Society platform.
- Testing Philosophy
- Page Object Model Pattern
- Writing Tests
- Database Isolation
- Test Data Management
- Best Practices
- Common Patterns
- Troubleshooting
- CI/CD Integration
✅ Test user flows and behaviors:
- Can a user complete their task?
- Do features work as expected from a user's perspective?
- Are critical paths protected and functional?
✅ Test integration points:
- Forms submit correctly
- Navigation works
- Data displays correctly
- Auth and permissions work
❌ Don't test implementation details:
- Internal state management
- Component props or events
- CSS class names
- Internal functions
Our testing standards:
- 0% flaky tests - Tests must be reliable
- Fast execution - <20 seconds for full suite
- 100% passing - No known failing tests in main branch
- Good coverage - All critical user flows tested
A Page Object Model encapsulates page structure and interactions into a reusable class:
// Bad: Directly using Playwright API in tests
test('can search', async ({ page }) => {
await page.goto('/')
await page.getByTestId('search-input').fill('Counter')
await page.getByTestId('search-input').press('Enter')
await expect(page.getByTestId('content-card')).toBeVisible()
})
// Good: Using a POM
test('can search', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.search('Counter')
const contentList = new ContentListPage(page)
await contentList.expectContentDisplayed()
})- Maintainability - UI changes only require updating the POM, not every test
- Reusability - Same page interactions used across multiple tests
- Readability - Tests read like plain English
- Type Safety - TypeScript catches errors at compile time
- Encapsulation - Implementation details hidden from tests
import { type Page, type Locator } from '@playwright/test'
import { BasePage } from './BasePage'
/**
* MyPage - Brief description
*
* Detailed description of what this page represents and what it does.
*
* @example
* const myPage = new MyPage(page)
* await myPage.doSomething()
*/
export class MyPage extends BasePage {
// 1. Locators (getters for elements)
get submitButton(): Locator {
return this.page.getByTestId('submit-button')
}
// 2. Actions (methods that perform interactions)
async submit(): Promise<void> {
await this.submitButton.click()
}
// 3. Getters (methods that retrieve data)
async getTitle(): Promise<string> {
return (await this.page.getByTestId('title').textContent()) || ''
}
// 4. Assertions (methods that verify state)
async expectFormDisplayed(): Promise<void> {
await expect(this.page.getByTestId('form')).toBeVisible()
}
}- Extend BasePage:
export class NewPage extends BasePage {
// Your implementation
}- Add to index.ts:
export { NewPage } from './NewPage'- Use in tests:
import { NewPage } from '../../pages'import { test, expect } from '@playwright/test'
import { HomePage, ContentListPage } from '../../pages'
import { setupDatabaseIsolation } from '../../helpers/database-isolation'
import { loginAs } from '../../helpers/auth'
test.describe('Feature Name', () => {
test.beforeEach(async ({ page }) => {
// Always set up database isolation
await setupDatabaseIsolation(page)
// Login if needed
await loginAs(page, 'admin')
})
test('specific behavior', async ({ page }) => {
// Arrange
const homePage = new HomePage(page)
await homePage.goto()
// Act
await homePage.search('test')
// Assert
const contentList = new ContentListPage(page)
await contentList.expectContentDisplayed()
})
})Use descriptive names that explain what is being tested:
// ✅ Good
test('can submit a valid recipe with all required fields', ...)
test('shows validation error when title is missing', ...)
test('admin can approve pending content from moderation queue', ...)
// ❌ Bad
test('recipe test', ...)
test('test 1', ...)
test('works', ...)Each test should be completely independent:
// ✅ Good - Each test sets up its own state
test('can save content', async ({ page }) => {
const detailPage = new ContentDetailPage(page)
await detailPage.goto('recipe', '1')
await detailPage.save()
await detailPage.expectSaved()
})
test('can unsave content', async ({ page }) => {
const detailPage = new ContentDetailPage(page)
await detailPage.goto('recipe', '1')
await detailPage.save() // Set up saved state
await detailPage.unsave()
await detailPage.expectNotSaved()
})
// ❌ Bad - Second test depends on first test
test('can save content', async ({ page }) => {
await detailPage.save()
})
test('can unsave content', async ({ page }) => {
await detailPage.unsave() // Assumes previous test ran!
})Each test file gets its own isolated database copy:
- Base database (
test.db) is initialized and seeded once globalSetuppre-creates isolated copies (e.g.,test-public-search.db)- Tests set a cookie to route requests to their database
globalTeardowncleans up all isolated databases
Every test file must include:
import { setupDatabaseIsolation } from '../../helpers/database-isolation'
test.beforeEach(async ({ page }) => {
await setupDatabaseIsolation(page) // Auto-detects test file name
})This automatically:
- Detects the test file name from the call stack
- Sets a cookie that routes requests to the correct database
- Ensures complete test isolation
- ✅ Perfect isolation - tests never interfere with each other
- ✅ Parallel execution safe - no race conditions
- ✅ Consistent state - every test starts with same data
- ✅ Fast - databases pre-created, not during tests
All test data is defined in tests/fixtures/test-data.ts:
import { TEST_USERS, TEST_CONTENT } from '../fixtures/test-data'
// Access test users
TEST_USERS.admin.username // 'test_admin'
TEST_USERS.viewer.email // 'viewer@test.local'
// Access test content
TEST_CONTENT.publishedRecipe.title // 'Test Recipe: Building a Counter Component'Use the loginAs helper:
import { loginAs } from '../../helpers/auth'
// Login as admin
await loginAs(page, 'admin')
// Login as moderator
await loginAs(page, 'contributor')
// Login as member
await loginAs(page, 'viewer')// ❌ Bad
test('can view recipe', async ({ page }) => {
await page.goto('/recipe/1')
await expect(page.getByText('Test Recipe')).toBeVisible()
})
// ✅ Good
test('can view recipe', async ({ page }) => {
const detailPage = new ContentDetailPage(page)
await detailPage.goto('recipe', TEST_CONTENT.publishedRecipe.id)
const title = await detailPage.getTitle()
expect(title).toContain('Counter')
})Always use data-testid for element selection:
<!-- Component.svelte -->
<button data-testid="submit-button">Submit</button>
<input data-testid="search-input" type="search" />// POM
get submitButton(): Locator {
return this.page.getByTestId('submit-button')
}Auto-generated test-ids: Form components automatically generate test-ids:
<Input name="username" />→data-testid="input-username"<Select name="role" />→data-testid="select-role"
// ❌ Bad - Arbitrary timeout
await page.waitForTimeout(2000)
// ✅ Good - Wait for specific condition
await expect(page.getByTestId('content-card')).toBeVisible()
// ✅ Good - Only when necessary
await page.waitForLoadState('networkidle')Playwright automatically waits for elements:
// No manual waiting needed!
await page.getByTestId('button').click() // Waits for visible + enabled
await expect(page.getByTestId('text')).toBeVisible() // Waits up to 5s// ✅ Good - Tests one thing
test('shows validation error when title is missing', async ({ page }) => {
const submitPage = new SubmitPage(page)
await submitPage.goto('recipe')
await submitPage.submit()
await submitPage.expectValidationError('Title is required')
})
// ❌ Bad - Tests multiple things
test('form validation works', async ({ page }) => {
// Tests title validation
// Tests description validation
// Tests URL validation
// Too much in one test!
})test('can navigate to recipes', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.navigateToRecipes()
await expect(page).toHaveURL('/recipe')
})test('can submit valid form', async ({ page }) => {
const submitPage = new SubmitPage(page)
await submitPage.goto('recipe')
await submitPage.fill({
title: 'My Recipe',
description: 'Test description',
body: 'Recipe content'
})
await submitPage.submit()
await expect(page).toHaveURL('/recipe')
})test('can search for content', async ({ page }) => {
const homePage = new HomePage(page)
await homePage.goto()
await homePage.search('Counter')
const contentList = new ContentListPage(page)
await contentList.expectContentDisplayed()
const titles = await contentList.getContentTitles()
expect(titles.some((t) => t.includes('Counter'))).toBeTruthy()
})test('can access admin page', async ({ page }) => {
await loginAs(page, 'admin')
const adminPage = new AdminDashboardPage(page)
await adminPage.goto()
await adminPage.expectDashboardVisible()
})test('member cannot access admin page', async ({ page }) => {
await loginAs(page, 'viewer')
await page.goto('/admin')
await expect(page).toHaveURL('/login')
})Symptoms: Test passes sometimes, fails other times
Common causes:
- Race conditions - Add proper waits
- Network timing - Use
waitForLoadStatewhen needed - Animation delays - Wait for element state changes
Solutions:
// Add explicit waits
await contentList.expectContentDisplayed()
// Wait for network to settle
await page.waitForLoadState('networkidle')
// Use retry logic for known flaky operations
await test.step('retry flaky operation', async () => {
await expect(async () => {
await page.getByTestId('element').click()
}).toPass({ timeout: 10000 })
})Run in headed mode to see what's happening:
bun run test:integration:headedRun in debug mode to step through:
bun run test:integration:debugUse Playwright Inspector:
PWDEBUG=1 bun test:integrationAdd console logs in POMs:
async search(query: string): Promise<void> {
console.log(`Searching for: ${query}`)
await this.searchInput.fill(query)
await this.searchInput.press('Enter')
}Tests run automatically on every PR to staging:
Workflow file: .github/workflows/playwright.yml
What happens:
- Install dependencies (with caching)
- Install Playwright browsers (with caching - saves ~1.5 min)
- Initialize and seed test database
- Build application (~30-45 seconds)
- Run tests (~15-20 seconds)
- Post results as PR comment
- Upload artifacts on failure
Total time: ~3-4 minutes
Every test run posts a comment with:
- ✅/❌ Status
- Number of passed/failed tests
- Link to detailed HTML report
On failure, these are uploaded:
- HTML report with screenshots
- Video recordings of failed tests
- Test logs
Access: Go to Actions → Workflow run → Artifacts
- ✅ Import required POMs and helpers
- ✅ Add
setupDatabaseIsolation()inbeforeEach - ✅ Use
loginAs()if authentication needed - ✅ Use POMs for all interactions
- ✅ Add test-ids to any new UI elements
- ✅ Keep tests focused and independent
- ✅ Use descriptive test names
- ✅ Run locally before pushing
- ✅ Verify tests pass in CI
import { test, expect } from '@playwright/test'
import { NewPage } from '../../pages'
import { setupDatabaseIsolation } from '../../helpers/database-isolation'
import { loginAs } from '../../helpers/auth'
test.describe('New Feature', () => {
test.beforeEach(async ({ page }) => {
await setupDatabaseIsolation(page)
await loginAs(page, 'admin')
})
test('can do something', async ({ page }) => {
const newPage = new NewPage(page)
await newPage.goto()
await newPage.doSomething()
await newPage.expectSuccess()
})
})Key Takeaways:
- Always use POMs - Never interact with pages directly in tests
- Always use database isolation - Call
setupDatabaseIsolation()in every test - Always use test-ids - Never use CSS selectors or text matching
- Keep tests independent - No shared state between tests
- Keep tests focused - One behavior per test
- Use helpers -
loginAs(), test data fromtest-data.ts - Write maintainable tests - Future you will thank you!
Questions? Check tests/README.md or ask the team!