690517:1449 204 and 302 refactor #03
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
---
|
||||
name: e2e-testing
|
||||
description: Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies for LCBP3-DMS.
|
||||
version: 1.9.0
|
||||
scope: testing
|
||||
depends-on: []
|
||||
handoffs-to: [speckit-tester]
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# E2E Testing Skill
|
||||
|
||||
Playwright E2E testing patterns adapted for LCBP3-DMS (NestJS + Next.js + MariaDB stack).
|
||||
|
||||
## LCBP3 Context
|
||||
|
||||
See [`_LCBP3-CONTEXT.md`](../_LCBP3-CONTEXT.md) for project-specific testing requirements:
|
||||
- Backend: Jest (Unit + Integration + E2E)
|
||||
- Frontend: Vitest (Unit) + Playwright (E2E)
|
||||
- E2E test location: `frontend/e2e/workflow-adr021.spec.ts`
|
||||
- Coverage goals: Backend 70%+, Business Logic 80%+
|
||||
|
||||
## When to Use
|
||||
|
||||
Invoke this skill when:
|
||||
- Creating new E2E tests for frontend features
|
||||
- Debugging flaky Playwright tests
|
||||
- Setting up CI/CD integration for E2E tests
|
||||
- Optimizing test performance and reliability
|
||||
- Implementing Page Object Model (POM) patterns
|
||||
|
||||
## Test File Organization
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── e2e/
|
||||
│ ├── auth/
|
||||
│ │ ├── login.spec.ts
|
||||
│ │ └── logout.spec.ts
|
||||
│ ├── correspondence/
|
||||
│ │ ├── create.spec.ts
|
||||
│ │ └── workflow.spec.ts
|
||||
│ ├── transmittals/
|
||||
│ │ ├── create.spec.ts
|
||||
│ │ └── submit.spec.ts
|
||||
│ ├── circulation/
|
||||
│ │ ├── routing.spec.ts
|
||||
│ │ └── approval.spec.ts
|
||||
│ └── workflow-adr021.spec.ts # Existing ADR-021 integration test
|
||||
├── playwright.config.ts
|
||||
└── tests/
|
||||
└── fixtures/
|
||||
├── auth.ts
|
||||
└── data.ts
|
||||
```
|
||||
|
||||
## Page Object Model (POM)
|
||||
|
||||
```typescript
|
||||
// frontend/e2e/pages/CorrespondencePage.ts
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
|
||||
export class CorrespondencePage {
|
||||
readonly page: Page
|
||||
readonly createButton: Locator
|
||||
readonly subjectInput: Locator
|
||||
readonly recipientSelect: Locator
|
||||
readonly submitButton: Locator
|
||||
readonly successMessage: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.createButton = page.getByTestId('create-correspondence')
|
||||
this.subjectInput = page.getByTestId('subject-input')
|
||||
this.recipientSelect = page.getByTestId('recipient-select')
|
||||
this.submitButton = page.getByTestId('submit-button')
|
||||
this.successMessage = page.getByTestId('success-message')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/admin/doc-control/correspondences')
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async createCorrespondence(data: {
|
||||
subject: string
|
||||
recipientId: string
|
||||
}) {
|
||||
await this.createButton.click()
|
||||
await this.subjectInput.fill(data.subject)
|
||||
await this.recipientSelect.selectOption(data.recipientId)
|
||||
await this.submitButton.click()
|
||||
}
|
||||
|
||||
async verifySuccess() {
|
||||
await expect(this.successMessage).toBeVisible()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```typescript
|
||||
// frontend/e2e/correspondence/create.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { CorrespondencePage } from '../pages/CorrespondencePage'
|
||||
|
||||
test.describe('Correspondence Creation', () => {
|
||||
let correspondencePage: CorrespondencePage
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
correspondencePage = new CorrespondencePage(page)
|
||||
await correspondencePage.goto()
|
||||
})
|
||||
|
||||
test('should create correspondence successfully', async ({ page }) => {
|
||||
await correspondencePage.createCorrespondence({
|
||||
subject: 'Test Correspondence',
|
||||
recipientId: 'test-recipient-id'
|
||||
})
|
||||
|
||||
await correspondencePage.verifySuccess()
|
||||
await page.screenshot({ path: 'artifacts/correspondence-created.png' })
|
||||
})
|
||||
|
||||
test('should validate required fields', async ({ page }) => {
|
||||
await correspondencePage.createButton.click()
|
||||
await correspondencePage.submitButton.click()
|
||||
|
||||
await expect(page.getByTestId('subject-error')).toBeVisible()
|
||||
await expect(page.getByTestId('recipient-error')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Playwright Configuration
|
||||
|
||||
```typescript
|
||||
// frontend/playwright.config.ts
|
||||
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: [
|
||||
['html', { outputFolder: 'playwright-report' }],
|
||||
['junit', { outputFile: 'playwright-results.xml' }],
|
||||
['json', { outputFile: 'playwright-results.json' }]
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 10000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Flaky Test Patterns
|
||||
|
||||
### Quarantine
|
||||
|
||||
```typescript
|
||||
test('flaky: complex workflow', async ({ page }) => {
|
||||
test.fixme(true, 'Flaky - Issue #123')
|
||||
// test code...
|
||||
})
|
||||
|
||||
test('conditional skip', async ({ page }) => {
|
||||
test.skip(process.env.CI, 'Flaky in CI - Issue #123')
|
||||
// test code...
|
||||
})
|
||||
```
|
||||
|
||||
### Identify Flakiness
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npx playwright test e2e/correspondence/create.spec.ts --repeat-each=10
|
||||
npx playwright test e2e/correspondence/create.spec.ts --retries=3
|
||||
```
|
||||
|
||||
### Common Causes & Fixes
|
||||
|
||||
**Race conditions:**
|
||||
```typescript
|
||||
// Bad: assumes element is ready
|
||||
await page.click('[data-testid="submit-button"]')
|
||||
|
||||
// Good: auto-wait locator
|
||||
await page.locator('[data-testid="submit-button"]').click()
|
||||
```
|
||||
|
||||
**Network timing:**
|
||||
```typescript
|
||||
// Bad: arbitrary timeout
|
||||
await page.waitForTimeout(5000)
|
||||
|
||||
// Good: wait for specific condition
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/api/correspondences') && resp.status() === 201
|
||||
)
|
||||
```
|
||||
|
||||
**Animation timing:**
|
||||
```typescript
|
||||
// Bad: click during animation
|
||||
await page.click('[data-testid="menu-item"]')
|
||||
|
||||
// Good: wait for stability
|
||||
await page.locator('[data-testid="menu-item"]').waitFor({ state: 'visible' })
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.locator('[data-testid="menu-item"]').click()
|
||||
```
|
||||
|
||||
## Artifact Management
|
||||
|
||||
### Screenshots
|
||||
|
||||
```typescript
|
||||
await page.screenshot({ path: 'artifacts/after-login.png' })
|
||||
await page.screenshot({ path: 'artifacts/full-page.png', fullPage: true })
|
||||
await page.locator('[data-testid="workflow-banner"]').screenshot({
|
||||
path: 'artifacts/workflow-banner.png'
|
||||
})
|
||||
```
|
||||
|
||||
### Traces
|
||||
|
||||
```typescript
|
||||
// In playwright.config.ts
|
||||
use: {
|
||||
trace: 'on-first-retry'
|
||||
}
|
||||
|
||||
// View trace
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
### Video
|
||||
|
||||
```typescript
|
||||
// In playwright.config.ts
|
||||
use: {
|
||||
video: 'retain-on-failure',
|
||||
videosPath: 'artifacts/videos/'
|
||||
}
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: pnpm install
|
||||
- run: cd frontend && npx playwright install --with-deps
|
||||
- run: cd frontend && npx playwright test
|
||||
env:
|
||||
BASE_URL: ${{ vars.STAGING_URL }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
```
|
||||
|
||||
## Test Report Template
|
||||
|
||||
```markdown
|
||||
# E2E Test Report
|
||||
|
||||
**Date:** YYYY-MM-DD HH:MM
|
||||
**Duration:** Xm Ys
|
||||
**Status:** PASSING / FAILING
|
||||
|
||||
## Summary
|
||||
- Total: X | Passed: Y (Z%) | Failed: A | Flaky: B | Skipped: C
|
||||
|
||||
## Failed Tests
|
||||
|
||||
### correspondence-create
|
||||
**File:** `frontend/e2e/correspondence/create.spec.ts:45`
|
||||
**Error:** Expected element to be visible
|
||||
**Screenshot:** artifacts/failed.png
|
||||
**Recommended Fix:** Add waitForLoadState after form submission
|
||||
|
||||
## Artifacts
|
||||
- HTML Report: frontend/playwright-report/index.html
|
||||
- Screenshots: frontend/artifacts/*.png
|
||||
- Videos: frontend/artifacts/videos/*.webm
|
||||
- Traces: frontend/artifacts/*.zip
|
||||
```
|
||||
|
||||
## Critical Flow Testing
|
||||
|
||||
```typescript
|
||||
// frontend/e2e/workflow/adr021.spec.ts
|
||||
test('workflow: correspondence → rfa → approval', async ({ page }) => {
|
||||
// Create correspondence
|
||||
await createCorrespondence(page)
|
||||
await expect(page.getByTestId('correspondence-created')).toBeVisible()
|
||||
|
||||
// Submit for RFA
|
||||
await page.getByTestId('submit-rfa').click()
|
||||
await expect(page.getByTestId('rfa-submitted')).toBeVisible()
|
||||
|
||||
// Approve RFA
|
||||
await page.goto('/admin/doc-control/rfa/123')
|
||||
await page.getByTestId('approve-button').click()
|
||||
await expect(page.getByTestId('approval-success')).toBeVisible()
|
||||
|
||||
// Verify workflow state
|
||||
await expect(page.getByTestId('workflow-state')).toContainText('APPROVED')
|
||||
})
|
||||
```
|
||||
|
||||
## LCBP3-Specific Considerations
|
||||
|
||||
- **UUID Handling:** Use `publicId` (string UUID) in E2E tests, never `parseInt()` (ADR-019)
|
||||
- **Authentication:** Mock auth tokens for E2E tests to avoid real auth flows
|
||||
- **Workflow States:** Test ADR-021 workflow transitions (DRAFT → PENDING → APPROVED)
|
||||
- **i18n:** Test with Thai language to verify i18n key resolution
|
||||
- **RBAC:** Test different user roles (admin, user, reviewer) for permission checks
|
||||
|
||||
## References
|
||||
|
||||
- LCBP3 Testing Strategy: `specs/05-Engineering-Guidelines/05-04-testing-strategy.md`
|
||||
- ADR-021 Workflow Context: `specs/06-Decision-Records/ADR-021-workflow-context.md`
|
||||
- Existing E2E test: `frontend/e2e/workflow-adr021.spec.ts`
|
||||
Reference in New Issue
Block a user