playwright-e2e-builder by davila7/claude-code-templates
npx skills add https://github.com/davila7/claude-code-templates --skill playwright-e2e-builder在以下场景中使用此技能:
进入计划模式。在编写任何测试之前,先探索现有项目:
playwright.config.ts,package.json 中的 @playwright/test)e2e/、tests/、__tests__/)npm run dev、 等)广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
next devdata-testid、data-test、data-cy 属性).env 文件中特定于测试的环境变量.github/workflows/、.gitlab-ci.yml、Jenkinsfile)使用 AskUserQuestion 来澄清需求。分轮次提问。
Question: "需要测试哪些关键用户流程?"
Header: "流程"
multiSelect: true
Options:
- "身份验证(注册、登录、注销、密码重置)" — 核心身份验证流程
- "核心增删改查(创建、读取、更新、删除主要资源)" — 主要数据操作
- "结账/支付(购物车、账单、确认)" — 电子商务或支付流程
- "仪表板/管理(数据视图、过滤器、导出)" — 管理面板交互
Question: "应用程序大约有多少页面/路由?"
Header: "应用规模"
Options:
- "小型(< 10 个路由)" — 着陆页、身份验证、几个功能页面
- "中型(10-30 个路由)" — 多个功能区域、设置、配置文件
- "大型(30+ 个路由)" — 具有多个部分和用户角色的复杂应用
Question: "您的应用如何处理身份验证?"
Header: "身份验证类型"
Options:
- "基于 Cookie/会话(推荐)" — 登录后服务器设置 httpOnly cookie
- "localStorage 中的 JWT" — 令牌存储在浏览器 localStorage 中
- "OAuth/SSO(Google、GitHub 等)" — 第三方身份验证提供商的重定向流程
- "无身份验证(公共应用)" — 无需登录
Question: "测试应如何进行身份验证?"
Header: "测试身份验证"
Options:
- "通过 UI 登录一次,重用状态(推荐)" — storageState 模式:在设置中登录,跨测试共享 cookie
- "在 beforeEach 中通过 API 登录" — 在每个测试前直接调用身份验证 API,跳过 UI 登录
- "在夹具中植入身份验证令牌" — 注入预生成的令牌,无需登录流程
- "每次测试都测试登录 UI" — 在每个测试套件中实际测试登录表单
Question: "应如何管理测试数据?"
Header: "测试数据"
Options:
- "在夹具中进行 API 数据植入(推荐)" — 在每个测试前调用 API 端点来创建/清理测试数据
- "数据库植入(直接 SQL)" — 运行 SQL 脚本或 ORM 命令来填充测试数据库
- "共享测试环境(预填充)" — 针对具有现有数据的持久暂存环境运行测试
- "模拟 API 响应" — 拦截网络请求并返回模拟数据
Question: "端到端测试针对什么环境运行?"
Header: "环境"
Options:
- "本地开发服务器(推荐)" — 在测试前启动开发服务器,针对 localhost 运行
- "预览/暂存 URL" — 针对已部署的预览或暂存环境运行
- "Docker Compose 堆栈" — 容器中的完整堆栈,测试在外部或内部运行
Question: "测试应如何在 CI 中运行?"
Header: "CI"
Options:
- "GitHub Actions(推荐)" — 具有分片功能的原生 Playwright 支持
- "GitLab CI" — 基于 Docker 的 runner 和 Playwright 镜像
- "仅本地(尚无 CI)" — 目前仅进行本地测试运行
- "其他 CI(Jenkins、CircleCI)" — 自定义 CI 配置
Question: "您需要视觉回归测试吗?"
Header: "视觉"
Options:
- "否 — 仅功能测试(推荐)" — 断言行为,而非像素
- "是 — 截图比较" — 捕获并比较页面截图
- "是 — 组件截图" — 捕获特定组件,而非完整页面
编写一个具体的实施计划,涵盖:
通过 ExitPlanMode 呈现给用户审批。
审批后,按以下顺序实施:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
// 身份验证设置 — 在所有测试之前运行
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'mobile',
use: {
...devices['iPhone 14'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
// 导航到登录页面
await page.goto('/login');
// 填写登录表单
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL || 'test@example.com');
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD || 'testpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// 等待身份验证完成 — 根据您的应用调整选择器
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// 保存已登录状态
await page.context().storageState({ path: authFile });
});
// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';
// 用于测试数据植入的 API 客户端
class ApiClient {
constructor(private baseURL: string, private token?: string) {}
async createResource(data: Record<string, unknown>) {
const response = await fetch(`${this.baseURL}/api/resources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Seed failed: ${response.status}`);
return response.json();
}
async deleteResource(id: string) {
await fetch(`${this.baseURL}/api/resources/${id}`, {
method: 'DELETE',
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
});
}
}
type Fixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
api: ApiClient;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
api: async ({ baseURL }, use) => {
const client = new ApiClient(baseURL!);
await use(client);
},
});
export { expect };
// e2e/pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/pages/dashboard-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly heading: Locator;
readonly createButton: Locator;
readonly searchInput: Locator;
readonly resourceList: Locator;
constructor(private page: Page) {
this.heading = page.getByRole('heading', { level: 1 });
this.createButton = page.getByRole('button', { name: 'Create' });
this.searchInput = page.getByPlaceholder('Search');
this.resourceList = page.getByTestId('resource-list');
}
async goto() {
await this.page.goto('/dashboard');
}
async createResource(name: string) {
await this.createButton.click();
await this.page.getByLabel('Name').fill(name);
await this.page.getByRole('button', { name: 'Save' }).click();
}
async search(query: string) {
await this.searchInput.fill(query);
// 等待防抖搜索触发
await this.page.waitForResponse(resp =>
resp.url().includes('/api/resources') && resp.status() === 200
);
}
async expectResourceVisible(name: string) {
await expect(this.resourceList.getByText(name)).toBeVisible();
}
async expectResourceCount(count: number) {
await expect(this.resourceList.getByRole('listitem')).toHaveCount(count);
}
}
// e2e/auth.spec.ts
import { test, expect } from './fixtures';
test.describe('Authentication', () => {
// 这些测试在没有 storageState 的情况下运行(未认证)
test.use({ storageState: { cookies: [], origins: [] } });
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('test@example.com', 'testpassword');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials shows error', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('test@example.com', 'wrongpassword');
await loginPage.expectError('Invalid credentials');
});
test('logout clears session', async ({ page }) => {
// 先登录
await page.goto('/login');
// ... 登录步骤 ...
// 注销
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login');
// 验证无法访问受保护的路由
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures';
test.describe('Dashboard', () => {
test('displays resource list', async ({ dashboardPage }) => {
await dashboardPage.goto();
await expect(dashboardPage.heading).toHaveText('Dashboard');
await expect(dashboardPage.resourceList).toBeVisible();
});
test('create new resource', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.createResource('New E2E Resource');
// 验证资源出现在列表中
await dashboardPage.expectResourceVisible('New E2E Resource');
});
test('search filters results', async ({ dashboardPage, api }) => {
// 通过 API 植入测试数据
await api.createResource({ name: 'Alpha Item' });
await api.createResource({ name: 'Beta Item' });
await dashboardPage.goto();
await dashboardPage.search('Alpha');
await dashboardPage.expectResourceVisible('Alpha Item');
});
test('empty state shown when no resources', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.search('nonexistent-query-xyz');
await expect(page.getByText('No results found')).toBeVisible();
});
});
// e2e/crud.spec.ts
import { test, expect } from './fixtures';
test.describe('Resource CRUD', () => {
let resourceId: string;
test.beforeEach(async ({ api }) => {
// 为需要资源的测试植入一个资源
const resource = await api.createResource({ name: 'Test Resource' });
resourceId = resource.id;
});
test.afterEach(async ({ api }) => {
// 清理植入的数据
if (resourceId) {
await api.deleteResource(resourceId).catch(() => {});
}
});
test('edit resource name', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Name').clear();
await page.getByLabel('Name').fill('Updated Resource');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading')).toHaveText('Updated Resource');
});
test('delete resource with confirmation', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Delete' }).click();
// 确认删除对话框
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click();
// 应重定向到列表
await expect(page).toHaveURL('/dashboard');
});
});
// e2e/visual.spec.ts
import { test, expect } from './fixtures';
test.describe('Visual regression', () => {
test('dashboard matches snapshot', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
// 等待动态内容稳定
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
});
});
test('login page matches snapshot', async ({ loginPage, page }) => {
test.use({ storageState: { cookies: [], origins: [] } });
await loginPage.goto();
await expect(page).toHaveScreenshot('login.png', {
maxDiffPixelRatio: 0.01,
});
});
// 组件级截图
test('navigation component matches snapshot', async ({ page }) => {
await page.goto('/dashboard');
const nav = page.getByRole('navigation');
await expect(nav).toHaveScreenshot('navigation.png');
});
});
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --shard=${{ matrix.shard }}
env:
BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ strategy.job-index }}
path: playwright-report/
retention-days: 14
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results-${{ strategy.job-index }}
path: test-results/
retention-days: 7
e2e/
├── .auth/
│ └── user.json # 保存的身份验证状态(被 git 忽略)
├── fixtures.ts # 自定义测试夹具和 API 客户端
├── pages/
│ ├── login-page.ts # 登录页面对象
│ ├── dashboard-page.ts # 仪表板页面对象
│ └── resource-page.ts # 资源详情页面对象
├── auth.setup.ts # 全局身份验证设置(运行一次)
├── auth.spec.ts # 身份验证测试
├── dashboard.spec.ts # 仪表板测试
├── crud.spec.ts # 增删改查操作测试
└── visual.spec.ts # 视觉回归测试(可选)
playwright.config.ts # Playwright 配置
优先使用 getByRole()、getByLabel()、getByText() 而非 CSS 选择器或测试 ID。这些定位器反映了用户与页面的交互方式,并能发现无障碍访问问题:
// 首选 — 无障碍且健壮
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('user@test.com');
// 备选 — 当基于角色的定位器不起作用时
await page.getByTestId('custom-widget').click();
// 避免 — 脆弱,重构时容易出错
await page.locator('.btn-primary').click();
await page.locator('#email-input').fill('user@test.com');
切勿使用 page.waitForTimeout()。等待特定条件:
// 等待 API 响应
await page.waitForResponse(resp => resp.url().includes('/api/data'));
// 等待元素状态
await expect(page.getByText('Saved')).toBeVisible();
// 等待导航
await expect(page).toHaveURL('/dashboard');
// 等待加载完成
await expect(page.getByTestId('spinner')).toBeHidden();
每个测试应创建自己的数据并在之后清理:
test('edit resource', async ({ api, page }) => {
// 准备 — 通过 API 植入数据
const resource = await api.createResource({ name: 'Test' });
// 执行
await page.goto(`/resources/${resource.id}`);
// ... 测试逻辑 ...
// 清理(也通过 afterEach 在失败时运行)
});
test('checkout flow @slow @checkout', async ({ page }) => {
// 标记为选择性执行的长测试
});
// 仅运行:npx playwright test --grep @checkout
// 跳过慢速:npx playwright test --grep-invert @slow
# Playwright
e2e/.auth/
test-results/
playwright-report/
blob-report/
playwright.config.ts 配置了 webServer 以启动开发服务器getByRole、getByLabel、getByText)waitForTimeout() 调用 — 仅等待元素、URL 或响应.auth/ 目录在 .gitignore 中npx playwright test 在本地通过每周安装量
76
仓库
GitHub 星标数
22.6K
首次出现
2026年2月14日
安全审计
安装于
codex76
gemini-cli75
amp75
github-copilot75
kimi-cli75
opencode75
Use this skill when you need to:
Enter plan mode. Before writing any tests, explore the existing project:
playwright.config.ts, @playwright/test in package.json)e2e/, tests/, __tests__/)npm run dev, next dev, etc.)data-testid, data-test, data-cy attributes).env files for test-specific environment variables.github/workflows/, .gitlab-ci.yml, Jenkinsfile)Use AskUserQuestion to clarify requirements. Ask in rounds.
Question: "What are the critical user flows to test?"
Header: "Flows"
multiSelect: true
Options:
- "Authentication (signup, login, logout, password reset)" — Core auth flows
- "Core CRUD (create, read, update, delete main resources)" — Primary data operations
- "Checkout/payments (cart, billing, confirmation)" — E-commerce or payment flows
- "Dashboard/admin (data views, filters, exports)" — Admin panel interactions
Question: "How many pages/routes does the application have approximately?"
Header: "App size"
Options:
- "Small (< 10 routes)" — Landing page, auth, a few feature pages
- "Medium (10-30 routes)" — Multiple feature areas, settings, profiles
- "Large (30+ routes)" — Complex app with many sections and user roles
Question: "How does your app handle authentication?"
Header: "Auth type"
Options:
- "Cookie/session based (Recommended)" — Server sets httpOnly cookies after login
- "JWT in localStorage" — Token stored in browser localStorage
- "OAuth/SSO (Google, GitHub, etc.)" — Third-party auth provider redirect flow
- "No auth (public app)" — No login required
Question: "How should tests authenticate?"
Header: "Test auth"
Options:
- "Login via UI once, reuse state (Recommended)" — storageState pattern: login in setup, share cookies across tests
- "API login in beforeEach" — Call auth API directly before each test, skip UI login
- "Seed auth token in fixtures" — Inject pre-generated tokens, no login flow needed
- "Test login UI every time" — Actually test the login form in each test suite
Question: "How should test data be managed?"
Header: "Test data"
Options:
- "API seeding in fixtures (Recommended)" — Call API endpoints to create/clean test data before each test
- "Database seeding (direct SQL)" — Run SQL scripts or ORM commands to populate test database
- "Shared test environment (pre-populated)" — Tests run against a persistent staging environment with existing data
- "Mock API responses" — Intercept network requests and return mock data
Question: "What environment do E2E tests run against?"
Header: "Environment"
Options:
- "Local dev server (Recommended)" — Start dev server before tests, run against localhost
- "Preview/staging URL" — Run against a deployed preview or staging environment
- "Docker Compose stack" — Full stack in containers, tests run outside or inside
Question: "How should tests run in CI?"
Header: "CI"
Options:
- "GitHub Actions (Recommended)" — Native Playwright support with sharding
- "GitLab CI" — Docker-based runners with Playwright image
- "Local only (no CI yet)" — Just local test runs for now
- "Other CI (Jenkins, CircleCI)" — Custom CI configuration
Question: "Do you need visual regression testing?"
Header: "Visual"
Options:
- "No — functional tests only (Recommended)" — Assert behavior, not pixels
- "Yes — screenshot comparisons" — Capture and compare page screenshots
- "Yes — component screenshots" — Capture specific components, not full pages
Write a concrete implementation plan covering:
Present via ExitPlanMode for user approval.
After approval, implement following this order:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
// Auth setup — runs before all tests
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'mobile',
use: {
...devices['iPhone 14'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
// Navigate to login page
await page.goto('/login');
// Fill login form
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL || 'test@example.com');
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD || 'testpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for auth to complete — adjust selector to your app
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// Save signed-in state
await page.context().storageState({ path: authFile });
});
// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';
// API client for test data seeding
class ApiClient {
constructor(private baseURL: string, private token?: string) {}
async createResource(data: Record<string, unknown>) {
const response = await fetch(`${this.baseURL}/api/resources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Seed failed: ${response.status}`);
return response.json();
}
async deleteResource(id: string) {
await fetch(`${this.baseURL}/api/resources/${id}`, {
method: 'DELETE',
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
});
}
}
type Fixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
api: ApiClient;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
api: async ({ baseURL }, use) => {
const client = new ApiClient(baseURL!);
await use(client);
},
});
export { expect };
// e2e/pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/pages/dashboard-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly heading: Locator;
readonly createButton: Locator;
readonly searchInput: Locator;
readonly resourceList: Locator;
constructor(private page: Page) {
this.heading = page.getByRole('heading', { level: 1 });
this.createButton = page.getByRole('button', { name: 'Create' });
this.searchInput = page.getByPlaceholder('Search');
this.resourceList = page.getByTestId('resource-list');
}
async goto() {
await this.page.goto('/dashboard');
}
async createResource(name: string) {
await this.createButton.click();
await this.page.getByLabel('Name').fill(name);
await this.page.getByRole('button', { name: 'Save' }).click();
}
async search(query: string) {
await this.searchInput.fill(query);
// Wait for debounced search to trigger
await this.page.waitForResponse(resp =>
resp.url().includes('/api/resources') && resp.status() === 200
);
}
async expectResourceVisible(name: string) {
await expect(this.resourceList.getByText(name)).toBeVisible();
}
async expectResourceCount(count: number) {
await expect(this.resourceList.getByRole('listitem')).toHaveCount(count);
}
}
// e2e/auth.spec.ts
import { test, expect } from './fixtures';
test.describe('Authentication', () => {
// These tests run WITHOUT storageState (unauthenticated)
test.use({ storageState: { cookies: [], origins: [] } });
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('test@example.com', 'testpassword');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials shows error', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('test@example.com', 'wrongpassword');
await loginPage.expectError('Invalid credentials');
});
test('logout clears session', async ({ page }) => {
// Login first
await page.goto('/login');
// ... login steps ...
// Logout
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login');
// Verify can't access protected route
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures';
test.describe('Dashboard', () => {
test('displays resource list', async ({ dashboardPage }) => {
await dashboardPage.goto();
await expect(dashboardPage.heading).toHaveText('Dashboard');
await expect(dashboardPage.resourceList).toBeVisible();
});
test('create new resource', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.createResource('New E2E Resource');
// Verify resource appears in list
await dashboardPage.expectResourceVisible('New E2E Resource');
});
test('search filters results', async ({ dashboardPage, api }) => {
// Seed test data via API
await api.createResource({ name: 'Alpha Item' });
await api.createResource({ name: 'Beta Item' });
await dashboardPage.goto();
await dashboardPage.search('Alpha');
await dashboardPage.expectResourceVisible('Alpha Item');
});
test('empty state shown when no resources', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.search('nonexistent-query-xyz');
await expect(page.getByText('No results found')).toBeVisible();
});
});
// e2e/crud.spec.ts
import { test, expect } from './fixtures';
test.describe('Resource CRUD', () => {
let resourceId: string;
test.beforeEach(async ({ api }) => {
// Seed a resource for tests that need one
const resource = await api.createResource({ name: 'Test Resource' });
resourceId = resource.id;
});
test.afterEach(async ({ api }) => {
// Clean up seeded data
if (resourceId) {
await api.deleteResource(resourceId).catch(() => {});
}
});
test('edit resource name', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Name').clear();
await page.getByLabel('Name').fill('Updated Resource');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading')).toHaveText('Updated Resource');
});
test('delete resource with confirmation', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Delete' }).click();
// Confirm deletion dialog
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click();
// Should redirect to list
await expect(page).toHaveURL('/dashboard');
});
});
// e2e/visual.spec.ts
import { test, expect } from './fixtures';
test.describe('Visual regression', () => {
test('dashboard matches snapshot', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
// Wait for dynamic content to stabilize
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
});
});
test('login page matches snapshot', async ({ loginPage, page }) => {
test.use({ storageState: { cookies: [], origins: [] } });
await loginPage.goto();
await expect(page).toHaveScreenshot('login.png', {
maxDiffPixelRatio: 0.01,
});
});
// Component-level screenshots
test('navigation component matches snapshot', async ({ page }) => {
await page.goto('/dashboard');
const nav = page.getByRole('navigation');
await expect(nav).toHaveScreenshot('navigation.png');
});
});
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --shard=${{ matrix.shard }}
env:
BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ strategy.job-index }}
path: playwright-report/
retention-days: 14
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results-${{ strategy.job-index }}
path: test-results/
retention-days: 7
e2e/
├── .auth/
│ └── user.json # Saved auth state (gitignored)
├── fixtures.ts # Custom test fixtures and API client
├── pages/
│ ├── login-page.ts # Login page object
│ ├── dashboard-page.ts # Dashboard page object
│ └── resource-page.ts # Resource detail page object
├── auth.setup.ts # Global auth setup (runs once)
├── auth.spec.ts # Authentication tests
├── dashboard.spec.ts # Dashboard tests
├── crud.spec.ts # CRUD operation tests
└── visual.spec.ts # Visual regression tests (optional)
playwright.config.ts # Playwright configuration
Prefer getByRole(), getByLabel(), getByText() over CSS selectors or test IDs. These locators mirror how users interact with the page and catch accessibility issues:
// Preferred — accessible and resilient
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('user@test.com');
// Fallback — when role-based doesn't work
await page.getByTestId('custom-widget').click();
// Avoid — fragile, breaks on refactors
await page.locator('.btn-primary').click();
await page.locator('#email-input').fill('user@test.com');
Never use page.waitForTimeout(). Wait for specific conditions:
// Wait for API response
await page.waitForResponse(resp => resp.url().includes('/api/data'));
// Wait for element state
await expect(page.getByText('Saved')).toBeVisible();
// Wait for navigation
await expect(page).toHaveURL('/dashboard');
// Wait for loading to finish
await expect(page.getByTestId('spinner')).toBeHidden();
Each test should create its own data and clean up after:
test('edit resource', async ({ api, page }) => {
// Arrange — seed via API
const resource = await api.createResource({ name: 'Test' });
// Act
await page.goto(`/resources/${resource.id}`);
// ... test logic ...
// Cleanup (also runs on failure via afterEach)
});
test('checkout flow @slow @checkout', async ({ page }) => {
// Long test tagged for selective execution
});
// Run only: npx playwright test --grep @checkout
// Skip slow: npx playwright test --grep-invert @slow
# Playwright
e2e/.auth/
test-results/
playwright-report/
blob-report/
playwright.config.ts has webServer configured to start the dev servergetByRole, getByLabel, getByText)waitForTimeout() calls — only wait for elements, URLs, or responses.auth/ directory is in .gitignorenpx playwright test passes locally before pushingWeekly Installs
76
Repository
GitHub Stars
22.6K
First Seen
Feb 14, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex76
gemini-cli75
amp75
github-copilot75
kimi-cli75
opencode75
Azure Data Explorer (Kusto) 查询技能:KQL数据分析、日志遥测与时间序列处理
138,800 周安装