$npx -y skills add sabahattink/antigravity-fullstack-hq --skill webapp-testingPlaywright E2E patterns, Testing Library component tests, test selectors. Use when writing browser tests, component tests, or setting up an E2E testing pipeline for a Next.js or React app.
| 1 | # Web App Testing |
| 2 | |
| 3 | ## Testing Pyramid |
| 4 | |
| 5 | ``` |
| 6 | /\ |
| 7 | /E2E\ ← Few, slow, high confidence (Playwright) |
| 8 | /------\ |
| 9 | / Integr \ ← Some, medium speed (RTL + MSW) |
| 10 | /----------\ |
| 11 | / Unit/Comp \ ← Many, fast, isolated (Vitest + RTL) |
| 12 | /--------------\ |
| 13 | ``` |
| 14 | |
| 15 | ## Playwright Setup |
| 16 | |
| 17 | ```bash |
| 18 | npm i -D @playwright/test |
| 19 | npx playwright install chromium firefox webkit |
| 20 | ``` |
| 21 | |
| 22 | ```typescript |
| 23 | // playwright.config.ts |
| 24 | import { defineConfig, devices } from '@playwright/test' |
| 25 | |
| 26 | export default defineConfig({ |
| 27 | testDir: './e2e', |
| 28 | timeout: 30_000, |
| 29 | retries: process.env.CI ? 2 : 0, |
| 30 | workers: process.env.CI ? 1 : undefined, |
| 31 | reporter: [['html'], ['list']], |
| 32 | |
| 33 | use: { |
| 34 | baseURL: 'http://localhost:3000', |
| 35 | trace: 'on-first-retry', |
| 36 | screenshot: 'only-on-failure', |
| 37 | video: 'retain-on-failure', |
| 38 | }, |
| 39 | |
| 40 | projects: [ |
| 41 | { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, |
| 42 | { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, |
| 43 | { name: 'mobile', use: { ...devices['iPhone 14'] } }, |
| 44 | ], |
| 45 | |
| 46 | webServer: { |
| 47 | command: 'npm run build && npm run start', |
| 48 | url: 'http://localhost:3000', |
| 49 | reuseExistingServer: !process.env.CI, |
| 50 | }, |
| 51 | }) |
| 52 | ``` |
| 53 | |
| 54 | ## Page Object Model |
| 55 | |
| 56 | ```typescript |
| 57 | // e2e/pages/login.page.ts |
| 58 | import { Page, Locator, expect } from '@playwright/test' |
| 59 | |
| 60 | export class LoginPage { |
| 61 | readonly emailInput: Locator |
| 62 | readonly passwordInput: Locator |
| 63 | readonly submitButton: Locator |
| 64 | readonly errorMessage: Locator |
| 65 | |
| 66 | constructor(private page: Page) { |
| 67 | this.emailInput = page.getByLabel('Email') |
| 68 | this.passwordInput = page.getByLabel('Password') |
| 69 | this.submitButton = page.getByRole('button', { name: 'Sign in' }) |
| 70 | this.errorMessage = page.getByRole('alert') |
| 71 | } |
| 72 | |
| 73 | async goto() { |
| 74 | await this.page.goto('/login') |
| 75 | } |
| 76 | |
| 77 | async login(email: string, password: string) { |
| 78 | await this.emailInput.fill(email) |
| 79 | await this.passwordInput.fill(password) |
| 80 | await this.submitButton.click() |
| 81 | } |
| 82 | |
| 83 | async expectError(message: string) { |
| 84 | await expect(this.errorMessage).toContainText(message) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // e2e/pages/dashboard.page.ts |
| 89 | export class DashboardPage { |
| 90 | constructor(private page: Page) {} |
| 91 | |
| 92 | async expectWelcome(name: string) { |
| 93 | await expect(this.page.getByRole('heading', { level: 1 })).toContainText(`Welcome, ${name}`) |
| 94 | } |
| 95 | } |
| 96 | ``` |
| 97 | |
| 98 | ## Playwright Test Examples |
| 99 | |
| 100 | ```typescript |
| 101 | // e2e/auth/login.spec.ts |
| 102 | import { test, expect } from '@playwright/test' |
| 103 | import { LoginPage } from '../pages/login.page' |
| 104 | import { DashboardPage } from '../pages/dashboard.page' |
| 105 | |
| 106 | test.describe('Login flow', () => { |
| 107 | test('successful login redirects to dashboard', async ({ page }) => { |
| 108 | const loginPage = new LoginPage(page) |
| 109 | const dashboard = new DashboardPage(page) |
| 110 | |
| 111 | await loginPage.goto() |
| 112 | await loginPage.login('admin@example.com', 'password123') |
| 113 | |
| 114 | await expect(page).toHaveURL('/dashboard') |
| 115 | await dashboard.expectWelcome('Admin') |
| 116 | }) |
| 117 | |
| 118 | test('invalid credentials shows error message', async ({ page }) => { |
| 119 | const loginPage = new LoginPage(page) |
| 120 | |
| 121 | await loginPage.goto() |
| 122 | await loginPage.login('bad@example.com', 'wrongpassword') |
| 123 | |
| 124 | await loginPage.expectError('Invalid email or password') |
| 125 | await expect(page).toHaveURL('/login') |
| 126 | }) |
| 127 | |
| 128 | test('email field is required', async ({ page }) => { |
| 129 | const loginPage = new LoginPage(page) |
| 130 | await loginPage.goto() |
| 131 | |
| 132 | await loginPage.submitButton.click() |
| 133 | |
| 134 | // HTML5 validation |
| 135 | await expect(loginPage.emailInput).toBeFocused() |
| 136 | }) |
| 137 | }) |
| 138 | ``` |
| 139 | |
| 140 | ## Authenticated Tests |
| 141 | |
| 142 | ```typescript |
| 143 | // e2e/fixtures/auth.fixture.ts |
| 144 | import { test as base, Page } from '@playwright/test' |
| 145 | |
| 146 | interface AuthFixtures { |
| 147 | authenticatedPage: Page |
| 148 | adminPage: Page |
| 149 | } |
| 150 | |
| 151 | export const test = base.extend<AuthFixtures>({ |
| 152 | // Regular user |
| 153 | authenticatedPage: async ({ browser }, use) => { |
| 154 | const context = await browser.newContext({ |
| 155 | storageState: 'e2e/.auth/user.json', |
| 156 | }) |
| 157 | const page = await context.newPage() |
| 158 | await use(page) |
| 159 | await context.close() |
| 160 | }, |
| 161 | |
| 162 | // Admin user |
| 163 | adminPage: async ({ browser }, use) => { |
| 164 | const context = await browser.newContext({ |
| 165 | storageState: 'e2e/.auth/admin.json', |
| 166 | }) |
| 167 | const page = await context.newPage() |
| 168 | await use(page) |
| 169 | await context.close() |
| 170 | }, |
| 171 | }) |
| 172 | |
| 173 | // e2e/auth/setup.ts — run once before all tests |
| 174 | import { chromium } from '@playwright/test' |
| 175 | |
| 176 | async function globalSetup() { |
| 177 | const browser = await chromium.launch() |
| 178 | |
| 179 | // Save user session |
| 180 | const userCtx = await browser.newContext() |
| 181 | const userPage = await userCtx.newPage() |
| 182 | await userPage.goto('http://localhost:3000/login') |
| 183 | await userPage.fill('[name=email]', 'user@example.com') |
| 184 | await userPage.fill('[name=password]', 'password') |
| 185 | await userPage.click('button[typ |