Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:28:25 +08:00
commit bc292955bb
23 changed files with 5385 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import { test as base, Page } from '@playwright/test';
/**
* Custom Fixtures for Playwright Tests
*
* Fixtures provide a way to set up test preconditions and share
* common setup logic across tests. They ensure test isolation
* and reduce boilerplate code.
*/
// Define custom fixture types
type MyFixtures = {
authenticatedPage: Page;
testUser: {
email: string;
password: string;
name: string;
};
};
/**
* Extend the base test with custom fixtures
*/
export const test = base.extend<MyFixtures>({
/**
* Fixture: Authenticated Page
*
* Provides a page that is already logged in with a test user.
* Use this when you need to test features that require authentication.
*
* Usage:
* test('should view profile', async ({ authenticatedPage }) => {
* await authenticatedPage.goto('/profile');
* // Test authenticated features
* });
*/
authenticatedPage: async ({ page }, use) => {
// Setup: Perform login
await page.goto('/login');
// Wait for login form to be visible
await page.locator('[data-testid="login-form"]').waitFor();
// Fill in credentials
await page.locator('[data-testid="email-input"]').fill('test@example.com');
await page.locator('[data-testid="password-input"]').fill('TestPassword123');
// Submit login form
await page.locator('[data-testid="login-button"]').click();
// Wait for successful login (redirect to dashboard)
await page.waitForURL('/dashboard');
// Verify login success
await page.locator('[data-testid="user-menu"]').waitFor();
// Provide the authenticated page to the test
await use(page);
// Teardown: Logout (optional)
// await page.locator('[data-testid="logout-button"]').click();
},
/**
* Fixture: Test User
*
* Provides consistent test user data across tests.
* Use this when you need user credentials or information.
*
* Usage:
* test('should register user', async ({ page, testUser }) => {
* await page.locator('[data-testid="email"]').fill(testUser.email);
* });
*/
testUser: async ({}, use) => {
const user = {
email: 'test@example.com',
password: 'TestPassword123',
name: 'Test User',
};
await use(user);
},
});
export { expect } from '@playwright/test';
/**
* Additional Fixture Examples:
*
* 1. Database Setup Fixture:
* dbContext: async ({}, use) => {
* const db = await setupTestDatabase();
* await use(db);
* await db.cleanup();
* }
*
* 2. API Context Fixture:
* apiContext: async ({ playwright }, use) => {
* const context = await playwright.request.newContext({
* baseURL: 'https://api.example.com',
* extraHTTPHeaders: { 'Authorization': 'Bearer token' }
* });
* await use(context);
* await context.dispose();
* }
*
* 3. Test Data Fixture:
* testProduct: async ({}, use) => {
* const product = {
* name: 'Test Product',
* price: 99.99,
* sku: 'TEST-001'
* };
* await use(product);
* }
*
* 4. Viewport Fixture:
* mobileViewport: async ({ page }, use) => {
* await page.setViewportSize({ width: 375, height: 667 });
* await use(page);
* }
*
* 5. Mock Data Fixture:
* mockApiResponses: async ({ page }, use) => {
* await page.route('**/api/users', route => {
* route.fulfill({ json: [{ id: 1, name: 'Test User' }] });
* });
* await use(page);
* }
*/

View File

@@ -0,0 +1,106 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright Configuration
*
* This configuration follows best practices for E2E testing:
* - Retries for flaky test handling
* - Parallel execution for speed
* - Screenshots and traces on failure
* - Multiple browser support
*/
export default defineConfig({
// Test directory
testDir: './tests',
// Maximum time one test can run
timeout: 30 * 1000,
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 1,
// Number of parallel workers
workers: process.env.CI ? 1 : undefined,
// Reporter to use
reporter: [
['html'],
['list'],
// Add JUnit reporter for CI
...(process.env.CI ? [['junit', { outputFile: 'test-results/junit.xml' }]] : []),
],
// Shared settings for all projects
use: {
// Base URL to use in actions like `await page.goto('/')`
baseURL: process.env.BASE_URL || 'http://localhost:3000',
// Collect trace when retrying the failed test
trace: 'on-first-retry',
// Take screenshot on failure
screenshot: 'only-on-failure',
// Capture video on first retry
video: 'retain-on-failure',
// Maximum time for each action (e.g., click, fill)
actionTimeout: 10 * 1000,
// Maximum time for navigation actions
navigationTimeout: 30 * 1000,
},
// Configure projects for major browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Test against mobile viewports
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
// Test against branded browsers
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
// Run your local dev server before starting the tests
// webServer: {
// command: 'npm run dev',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// timeout: 120 * 1000,
// },
});

View File

@@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
/**
* Test Suite: [Feature Name]
*
* Description: [Brief description of what this test suite covers]
*/
test.describe('[Feature Name]', () => {
/**
* Setup: Runs before each test in this describe block
*/
test.beforeEach(async ({ page }) => {
// Navigate to the starting page
await page.goto('/');
// Wait for page to be ready
await page.waitForLoadState('domcontentloaded');
});
/**
* Test: [Specific behavior being tested]
*
* Scenario: [User story or use case]
* Expected: [What should happen]
*/
test('should [expected behavior]', async ({ page }) => {
// ============================================
// ARRANGE: Setup test preconditions
// ============================================
// Navigate to specific page (if needed)
// await page.goto('/specific-page');
// Verify page is loaded
// await expect(page.locator('[data-testid="page-container"]')).toBeVisible();
// ============================================
// ACT: Perform the action being tested
// ============================================
// Interact with elements using data-testid
// await page.locator('[data-testid="input-field"]').fill('test value');
// await page.locator('[data-testid="submit-button"]').click();
// ============================================
// ASSERT: Verify the expected outcome
// ============================================
// Check that the expected result is visible
// await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
// await expect(page.locator('[data-testid="result"]')).toContainText('Expected text');
});
/**
* Test: [Another specific behavior]
*/
test('should [another expected behavior]', async ({ page }) => {
// Arrange
// Act
// Assert
});
});
/**
* Best Practices Checklist:
* ✅ Use only data-testid locators
* ✅ Follow AAA pattern (Arrange-Act-Assert)
* ✅ Use descriptive test names starting with "should"
* ✅ Add comments for complex logic
* ✅ Use explicit waits (waitForSelector, waitForLoadState)
* ✅ No hardcoded waits (waitForTimeout)
* ✅ One scenario per test
* ✅ Tests are independent and isolated
* ✅ Use TypeScript types
* ✅ Add meaningful assertions
*/

View File

@@ -0,0 +1,325 @@
import { Page, Locator, expect } from '@playwright/test';
/**
* Utility Functions for Playwright Tests
*
* Common helpers to reduce boilerplate and improve test readability.
* All functions follow best practices: data-testid locators, explicit waits,
* and proper error handling.
*/
/**
* Fill a form with multiple fields at once
*
* @param page - Playwright page object
* @param fields - Object mapping data-testid to values
*
* @example
* await fillForm(page, {
* 'username-input': 'testuser',
* 'password-input': 'password123',
* 'email-input': 'test@example.com'
* });
*/
export async function fillForm(
page: Page,
fields: Record<string, string>
): Promise<void> {
for (const [testId, value] of Object.entries(fields)) {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'visible' });
await locator.fill(value);
}
}
/**
* Click and wait for navigation
*
* @param page - Playwright page object
* @param testId - data-testid of the element to click
* @param expectedUrl - Optional URL pattern to wait for
*
* @example
* await clickAndNavigate(page, 'submit-button', '/dashboard');
*/
export async function clickAndNavigate(
page: Page,
testId: string,
expectedUrl?: string | RegExp
): Promise<void> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'visible' });
await Promise.all([
expectedUrl ? page.waitForURL(expectedUrl) : page.waitForLoadState('networkidle'),
locator.click(),
]);
}
/**
* Wait for element and verify visibility
*
* @param page - Playwright page object
* @param testId - data-testid of the element
* @param timeout - Optional timeout in milliseconds
*
* @example
* await waitForElement(page, 'success-message');
*/
export async function waitForElement(
page: Page,
testId: string,
timeout = 10000
): Promise<Locator> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'visible', timeout });
return locator;
}
/**
* Select option from dropdown
*
* @param page - Playwright page object
* @param testId - data-testid of the select element
* @param value - Value to select
*
* @example
* await selectOption(page, 'country-select', 'USA');
*/
export async function selectOption(
page: Page,
testId: string,
value: string
): Promise<void> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'visible' });
await locator.selectOption(value);
}
/**
* Check if element exists without throwing error
*
* @param page - Playwright page object
* @param testId - data-testid of the element
* @returns true if element exists, false otherwise
*
* @example
* if (await elementExists(page, 'error-message')) {
* // Handle error state
* }
*/
export async function elementExists(
page: Page,
testId: string
): Promise<boolean> {
try {
await page.locator(`[data-testid="${testId}"]`).waitFor({
state: 'visible',
timeout: 2000,
});
return true;
} catch {
return false;
}
}
/**
* Wait for element to disappear
*
* @param page - Playwright page object
* @param testId - data-testid of the element
* @param timeout - Optional timeout in milliseconds
*
* @example
* await waitForElementToDisappear(page, 'loading-spinner');
*/
export async function waitForElementToDisappear(
page: Page,
testId: string,
timeout = 10000
): Promise<void> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'hidden', timeout });
}
/**
* Get text content of an element
*
* @param page - Playwright page object
* @param testId - data-testid of the element
* @returns Text content of the element
*
* @example
* const username = await getTextContent(page, 'username-display');
*/
export async function getTextContent(
page: Page,
testId: string
): Promise<string> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'visible' });
const text = await locator.textContent();
return text?.trim() || '';
}
/**
* Upload file to input
*
* @param page - Playwright page object
* @param testId - data-testid of the file input
* @param filePath - Path to the file to upload
*
* @example
* await uploadFile(page, 'file-input', './test-data/sample.pdf');
*/
export async function uploadFile(
page: Page,
testId: string,
filePath: string
): Promise<void> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'attached' });
await locator.setInputFiles(filePath);
}
/**
* Check or uncheck a checkbox
*
* @param page - Playwright page object
* @param testId - data-testid of the checkbox
* @param checked - Whether to check or uncheck
*
* @example
* await toggleCheckbox(page, 'terms-checkbox', true);
*/
export async function toggleCheckbox(
page: Page,
testId: string,
checked: boolean
): Promise<void> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.waitFor({ state: 'visible' });
const isChecked = await locator.isChecked();
if (isChecked !== checked) {
await locator.click();
}
}
/**
* Retry an action with exponential backoff
*
* @param action - Async function to retry
* @param maxRetries - Maximum number of retries
* @param initialDelay - Initial delay in milliseconds
*
* @example
* await retryWithBackoff(async () => {
* await page.locator('[data-testid="submit"]').click();
* }, 3, 1000);
*/
export async function retryWithBackoff<T>(
action: () => Promise<T>,
maxRetries = 3,
initialDelay = 1000
): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < maxRetries; i++) {
try {
return await action();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, i);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
/**
* Wait for API response
*
* @param page - Playwright page object
* @param urlPattern - URL pattern to wait for
* @param action - Action that triggers the API call
* @returns Response data
*
* @example
* const response = await waitForApiResponse(
* page,
* '**/api/users',
* () => page.locator('[data-testid="load-users"]').click()
* );
*/
export async function waitForApiResponse<T = any>(
page: Page,
urlPattern: string | RegExp,
action: () => Promise<void>
): Promise<T> {
const [response] = await Promise.all([
page.waitForResponse(urlPattern),
action(),
]);
return await response.json();
}
/**
* Take screenshot with timestamp
*
* @param page - Playwright page object
* @param name - Base name for the screenshot
*
* @example
* await takeTimestampedScreenshot(page, 'error-state');
* // Saves as: error-state-2024-01-15T10-30-45.png
*/
export async function takeTimestampedScreenshot(
page: Page,
name: string
): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await page.screenshot({ path: `${name}-${timestamp}.png`, fullPage: true });
}
/**
* Assert multiple elements are visible
*
* @param page - Playwright page object
* @param testIds - Array of data-testid values
*
* @example
* await assertElementsVisible(page, ['header', 'nav', 'footer']);
*/
export async function assertElementsVisible(
page: Page,
testIds: string[]
): Promise<void> {
for (const testId of testIds) {
const locator = page.locator(`[data-testid="${testId}"]`);
await expect(locator).toBeVisible();
}
}
/**
* Get all text contents from multiple elements
*
* @param page - Playwright page object
* @param testId - data-testid of elements (can match multiple)
* @returns Array of text contents
*
* @example
* const productNames = await getAllTextContents(page, 'product-name');
*/
export async function getAllTextContents(
page: Page,
testId: string
): Promise<string[]> {
const locator = page.locator(`[data-testid="${testId}"]`);
await locator.first().waitFor({ state: 'visible' });
return await locator.allTextContents();
}