Initial commit
This commit is contained in:
470
agents/qa-engineer/templates/playwright-e2e-test.ts
Normal file
470
agents/qa-engineer/templates/playwright-e2e-test.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Playwright E2E Test Template
|
||||
*
|
||||
* This template demonstrates best practices for writing end-to-end tests
|
||||
* with Playwright for web applications.
|
||||
*
|
||||
* Features:
|
||||
* - Page Object Model (POM)
|
||||
* - Test fixtures
|
||||
* - Cross-browser testing
|
||||
* - Mobile emulation
|
||||
* - API mocking
|
||||
* - Visual regression
|
||||
* - Accessibility testing
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
// ============================================================================
|
||||
// PAGE OBJECTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Login Page Object
|
||||
*
|
||||
* Encapsulates all interactions with the login page
|
||||
*/
|
||||
class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
// Locators
|
||||
get emailInput() {
|
||||
return this.page.getByLabel('Email');
|
||||
}
|
||||
|
||||
get passwordInput() {
|
||||
return this.page.getByLabel('Password');
|
||||
}
|
||||
|
||||
get loginButton() {
|
||||
return this.page.getByRole('button', { name: 'Login' });
|
||||
}
|
||||
|
||||
get errorMessage() {
|
||||
return this.page.getByRole('alert');
|
||||
}
|
||||
|
||||
// Actions
|
||||
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.loginButton.click();
|
||||
}
|
||||
|
||||
async expectError(message: string) {
|
||||
await expect(this.errorMessage).toContainText(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Page Object
|
||||
*/
|
||||
class DashboardPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
get welcomeMessage() {
|
||||
return this.page.getByText(/Welcome,/);
|
||||
}
|
||||
|
||||
get logoutButton() {
|
||||
return this.page.getByRole('button', { name: 'Logout' });
|
||||
}
|
||||
|
||||
async expectLoggedIn(username: string) {
|
||||
await expect(this.welcomeMessage).toContainText(username);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.logoutButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST FIXTURES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Custom fixture that provides authenticated page
|
||||
*/
|
||||
const test = base.extend<{ authenticatedPage: Page }>({
|
||||
authenticatedPage: async ({ page }, use) => {
|
||||
// Setup: Login before test
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login('user@example.com', 'password123');
|
||||
|
||||
// Wait for navigation to dashboard
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Provide authenticated page to test
|
||||
await use(page);
|
||||
|
||||
// Teardown: Logout after test
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.logout();
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BASIC E2E TESTS
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Authentication Flow', () => {
|
||||
test('should login successfully with valid credentials', async ({ page }) => {
|
||||
// ARRANGE
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
|
||||
// ACT
|
||||
await loginPage.login('user@example.com', 'password123');
|
||||
|
||||
// ASSERT
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.expectLoggedIn('User');
|
||||
});
|
||||
|
||||
test('should show error for invalid credentials', async ({ page }) => {
|
||||
// ARRANGE
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
|
||||
// ACT
|
||||
await loginPage.login('invalid@example.com', 'wrongpassword');
|
||||
|
||||
// ASSERT
|
||||
await loginPage.expectError('Invalid email or password');
|
||||
await expect(page).toHaveURL('/login'); // Still on login page
|
||||
});
|
||||
|
||||
test('should show validation errors for empty fields', async ({ page }) => {
|
||||
// ARRANGE
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
|
||||
// ACT
|
||||
await loginPage.loginButton.click();
|
||||
|
||||
// ASSERT
|
||||
await expect(page.getByText('Email is required')).toBeVisible();
|
||||
await expect(page.getByText('Password is required')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AUTHENTICATED TESTS (Using Fixture)
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Dashboard Features', () => {
|
||||
test('should display user profile', async ({ authenticatedPage }) => {
|
||||
// Navigate to profile
|
||||
await authenticatedPage.goto('/profile');
|
||||
|
||||
// Verify profile data
|
||||
await expect(
|
||||
authenticatedPage.getByText('user@example.com')
|
||||
).toBeVisible();
|
||||
await expect(authenticatedPage.getByText('Member since')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow editing profile', async ({ authenticatedPage }) => {
|
||||
// Navigate to profile
|
||||
await authenticatedPage.goto('/profile');
|
||||
|
||||
// Edit name
|
||||
const nameInput = authenticatedPage.getByLabel('Name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('New Name');
|
||||
|
||||
// Save changes
|
||||
await authenticatedPage
|
||||
.getByRole('button', { name: 'Save Changes' })
|
||||
.click();
|
||||
|
||||
// Verify success
|
||||
await expect(
|
||||
authenticatedPage.getByText('Profile updated successfully')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// API MOCKING
|
||||
// ============================================================================
|
||||
|
||||
test.describe('API Mocking', () => {
|
||||
test('should handle API errors gracefully', async ({ page }) => {
|
||||
// Mock API to return error
|
||||
await page.route('**/api/users', (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: 'Internal Server Error' }),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate to users page
|
||||
await page.goto('/users');
|
||||
|
||||
// Verify error message
|
||||
await expect(
|
||||
page.getByText('Failed to load users. Please try again.')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display mocked user data', async ({ page }) => {
|
||||
// Mock API to return test data
|
||||
await page.route('**/api/users', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify([
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate to users page
|
||||
await page.goto('/users');
|
||||
|
||||
// Verify mocked data is displayed
|
||||
await expect(page.getByText('John Doe')).toBeVisible();
|
||||
await expect(page.getByText('Jane Smith')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// VISUAL REGRESSION TESTING
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Visual Regression', () => {
|
||||
test('homepage matches baseline', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Capture screenshot and compare to baseline
|
||||
await expect(page).toHaveScreenshot('homepage.png', {
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('button states match baseline', async ({ page }) => {
|
||||
await page.goto('/components');
|
||||
|
||||
const button = page.getByRole('button', { name: 'Submit' });
|
||||
|
||||
// Default state
|
||||
await expect(button).toHaveScreenshot('button-default.png');
|
||||
|
||||
// Hover state
|
||||
await button.hover();
|
||||
await expect(button).toHaveScreenshot('button-hover.png');
|
||||
|
||||
// Focus state
|
||||
await button.focus();
|
||||
await expect(button).toHaveScreenshot('button-focus.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ACCESSIBILITY TESTING
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('should not have accessibility violations', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Run axe accessibility scan
|
||||
const accessibilityScanResults = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.analyze();
|
||||
|
||||
// Assert no violations
|
||||
expect(accessibilityScanResults.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('should support keyboard navigation', async ({ page }) => {
|
||||
await page.goto('/form');
|
||||
|
||||
// Tab through form fields
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByLabel('Email')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByLabel('Password')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Submit' })
|
||||
).toBeFocused();
|
||||
|
||||
// Submit with Enter
|
||||
await page.keyboard.press('Enter');
|
||||
});
|
||||
|
||||
test('should have proper ARIA labels', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Check navigation has aria-label
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'Main navigation' })
|
||||
).toBeVisible();
|
||||
|
||||
// Check main content has aria-label
|
||||
await expect(page.getByRole('main')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Main content'
|
||||
);
|
||||
|
||||
// Check all images have alt text
|
||||
const images = await page.getByRole('img').all();
|
||||
for (const img of images) {
|
||||
await expect(img).toHaveAttribute('alt');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MOBILE TESTING
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Mobile Experience', () => {
|
||||
test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE
|
||||
|
||||
test('should render mobile navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Mobile menu button should be visible
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Menu' })
|
||||
).toBeVisible();
|
||||
|
||||
// Desktop navigation should be hidden
|
||||
const desktopNav = page.getByRole('navigation').first();
|
||||
await expect(desktopNav).toBeHidden();
|
||||
});
|
||||
|
||||
test('should handle touch gestures', async ({ page }) => {
|
||||
await page.goto('/gallery');
|
||||
|
||||
// Get image element
|
||||
const image = page.getByRole('img').first();
|
||||
|
||||
// Swipe left
|
||||
await image.dispatchEvent('touchstart', {
|
||||
touches: [{ clientX: 300, clientY: 200 }],
|
||||
});
|
||||
await image.dispatchEvent('touchmove', {
|
||||
touches: [{ clientX: 100, clientY: 200 }],
|
||||
});
|
||||
await image.dispatchEvent('touchend');
|
||||
|
||||
// Verify navigation to next image
|
||||
await expect(page.getByText('Image 2 of 10')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PERFORMANCE TESTING
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Performance', () => {
|
||||
test('page load performance', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Get performance metrics
|
||||
const performanceMetrics = await page.evaluate(() => {
|
||||
const perfData = window.performance.timing;
|
||||
return {
|
||||
loadTime: perfData.loadEventEnd - perfData.navigationStart,
|
||||
domContentLoaded:
|
||||
perfData.domContentLoadedEventEnd - perfData.navigationStart,
|
||||
};
|
||||
});
|
||||
|
||||
// Assert performance targets
|
||||
expect(performanceMetrics.loadTime).toBeLessThan(3000); // 3s max
|
||||
expect(performanceMetrics.domContentLoaded).toBeLessThan(2000); // 2s max
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// FILE UPLOAD/DOWNLOAD
|
||||
// ============================================================================
|
||||
|
||||
test.describe('File Operations', () => {
|
||||
test('should upload file', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
// Set up file chooser
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Upload File' }).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
// Upload file
|
||||
await fileChooser.setFiles('tests/fixtures/test-file.pdf');
|
||||
|
||||
// Verify upload success
|
||||
await expect(page.getByText('File uploaded successfully')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should download file', async ({ page }) => {
|
||||
await page.goto('/downloads');
|
||||
|
||||
// Set up download
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'Download Report' }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Verify download
|
||||
expect(download.suggestedFilename()).toBe('report.pdf');
|
||||
await download.saveAs(`/tmp/${download.suggestedFilename()}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MULTI-TAB/WINDOW TESTING
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Multi-Window', () => {
|
||||
test('should handle popup windows', async ({ context, page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for popup
|
||||
const [popup] = await Promise.all([
|
||||
context.waitForEvent('page'),
|
||||
page.getByRole('button', { name: 'Open Help' }).click(),
|
||||
]);
|
||||
|
||||
// Interact with popup
|
||||
await expect(popup.getByText('Help Center')).toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BEST PRACTICES CHECKLIST
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
✅ Page Object Model (POM)
|
||||
✅ Test Fixtures for Setup/Teardown
|
||||
✅ Descriptive Test Names
|
||||
✅ Auto-Waiting (Playwright built-in)
|
||||
✅ User-Centric Selectors (getByRole, getByLabel)
|
||||
✅ API Mocking for Reliability
|
||||
✅ Visual Regression Testing
|
||||
✅ Accessibility Testing (axe-core)
|
||||
✅ Mobile/Responsive Testing
|
||||
✅ Performance Assertions
|
||||
✅ File Upload/Download
|
||||
✅ Multi-Tab/Window Handling
|
||||
✅ Screenshot/Video on Failure (configured in playwright.config.ts)
|
||||
✅ Parallel Execution (configured in playwright.config.ts)
|
||||
✅ Cross-Browser Testing (configured in playwright.config.ts)
|
||||
*/
|
||||
507
agents/qa-engineer/templates/test-data-factory.ts
Normal file
507
agents/qa-engineer/templates/test-data-factory.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Test Data Factory Template
|
||||
*
|
||||
* This template demonstrates best practices for creating reusable
|
||||
* test data factories using the Factory pattern.
|
||||
*
|
||||
* Benefits:
|
||||
* - Consistent test data generation
|
||||
* - Easy to customize with overrides
|
||||
* - Reduces test setup boilerplate
|
||||
* - Type-safe with TypeScript
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: 'admin' | 'user' | 'guest';
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
profile?: UserProfile;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
bio: string;
|
||||
avatar: string;
|
||||
phoneNumber: string;
|
||||
address: Address;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
category: string;
|
||||
inStock: boolean;
|
||||
quantity: number;
|
||||
imageUrl: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
userId: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
shippingAddress: Address;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FACTORY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* User Factory
|
||||
*
|
||||
* Creates realistic user test data with sensible defaults
|
||||
*/
|
||||
export class UserFactory {
|
||||
/**
|
||||
* Create a single user
|
||||
*
|
||||
* @param overrides - Partial user object to override defaults
|
||||
* @returns Complete user object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const admin = UserFactory.create({ role: 'admin' });
|
||||
* const inactiveUser = UserFactory.create({ isActive: false });
|
||||
* ```
|
||||
*/
|
||||
static create(overrides: Partial<User> = {}): User {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
const email =
|
||||
overrides.email || faker.internet.email({ firstName, lastName });
|
||||
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
email,
|
||||
username: faker.internet.userName({ firstName, lastName }),
|
||||
firstName,
|
||||
lastName,
|
||||
role: 'user',
|
||||
isActive: true,
|
||||
createdAt: faker.date.past(),
|
||||
updatedAt: faker.date.recent(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple users
|
||||
*
|
||||
* @param count - Number of users to create
|
||||
* @param overrides - Partial user object to override defaults for all users
|
||||
* @returns Array of user objects
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const users = UserFactory.createMany(5);
|
||||
* const admins = UserFactory.createMany(3, { role: 'admin' });
|
||||
* ```
|
||||
*/
|
||||
static createMany(count: number, overrides: Partial<User> = {}): User[] {
|
||||
return Array.from({ length: count }, () => this.create(overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create admin user
|
||||
*
|
||||
* @param overrides - Partial user object to override defaults
|
||||
* @returns Admin user object
|
||||
*/
|
||||
static createAdmin(overrides: Partial<User> = {}): User {
|
||||
return this.create({
|
||||
role: 'admin',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user with complete profile
|
||||
*
|
||||
* @param overrides - Partial user object to override defaults
|
||||
* @returns User with profile object
|
||||
*/
|
||||
static createWithProfile(overrides: Partial<User> = {}): User {
|
||||
return this.create({
|
||||
profile: {
|
||||
bio: faker.person.bio(),
|
||||
avatar: faker.image.avatar(),
|
||||
phoneNumber: faker.phone.number(),
|
||||
address: AddressFactory.create(),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create inactive user
|
||||
*
|
||||
* @param overrides - Partial user object to override defaults
|
||||
* @returns Inactive user object
|
||||
*/
|
||||
static createInactive(overrides: Partial<User> = {}): User {
|
||||
return this.create({
|
||||
isActive: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Address Factory
|
||||
*
|
||||
* Creates realistic address test data
|
||||
*/
|
||||
export class AddressFactory {
|
||||
static create(overrides: Partial<Address> = {}): Address {
|
||||
return {
|
||||
street: faker.location.streetAddress(),
|
||||
city: faker.location.city(),
|
||||
state: faker.location.state(),
|
||||
zipCode: faker.location.zipCode(),
|
||||
country: faker.location.country(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
static createUS(overrides: Partial<Address> = {}): Address {
|
||||
return this.create({
|
||||
country: 'United States',
|
||||
zipCode: faker.location.zipCode('#####'),
|
||||
state: faker.location.state({ abbreviated: true }),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Product Factory
|
||||
*
|
||||
* Creates realistic product test data
|
||||
*/
|
||||
export class ProductFactory {
|
||||
static create(overrides: Partial<Product> = {}): Product {
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
name: faker.commerce.productName(),
|
||||
description: faker.commerce.productDescription(),
|
||||
price: parseFloat(faker.commerce.price()),
|
||||
category: faker.commerce.department(),
|
||||
inStock: true,
|
||||
quantity: faker.number.int({ min: 0, max: 100 }),
|
||||
imageUrl: faker.image.url(),
|
||||
createdAt: faker.date.past(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
static createMany(count: number, overrides: Partial<Product> = {}): Product[] {
|
||||
return Array.from({ length: count }, () => this.create(overrides));
|
||||
}
|
||||
|
||||
static createOutOfStock(overrides: Partial<Product> = {}): Product {
|
||||
return this.create({
|
||||
inStock: false,
|
||||
quantity: 0,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
static createExpensive(overrides: Partial<Product> = {}): Product {
|
||||
return this.create({
|
||||
price: faker.number.int({ min: 1000, max: 10000 }),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Order Factory
|
||||
*
|
||||
* Creates realistic order test data with items
|
||||
*/
|
||||
export class OrderFactory {
|
||||
static create(overrides: Partial<Order> = {}): Order {
|
||||
const items = overrides.items || [
|
||||
{
|
||||
productId: faker.string.uuid(),
|
||||
quantity: faker.number.int({ min: 1, max: 5 }),
|
||||
price: parseFloat(faker.commerce.price()),
|
||||
},
|
||||
];
|
||||
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const tax = subtotal * 0.08; // 8% tax
|
||||
const total = subtotal + tax;
|
||||
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
userId: faker.string.uuid(),
|
||||
items,
|
||||
subtotal,
|
||||
tax,
|
||||
total,
|
||||
status: 'pending',
|
||||
shippingAddress: AddressFactory.createUS(),
|
||||
createdAt: faker.date.recent(),
|
||||
updatedAt: faker.date.recent(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
static createMany(count: number, overrides: Partial<Order> = {}): Order[] {
|
||||
return Array.from({ length: count }, () => this.create(overrides));
|
||||
}
|
||||
|
||||
static createWithItems(items: OrderItem[], overrides: Partial<Order> = {}): Order {
|
||||
return this.create({ items, ...overrides });
|
||||
}
|
||||
|
||||
static createShipped(overrides: Partial<Order> = {}): Order {
|
||||
return this.create({
|
||||
status: 'shipped',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
static createCancelled(overrides: Partial<Order> = {}): Order {
|
||||
return this.create({
|
||||
status: 'cancelled',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUILDER PATTERN (Advanced)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* User Builder
|
||||
*
|
||||
* Provides a fluent interface for building complex user objects
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const user = new UserBuilder()
|
||||
* .withEmail('admin@example.com')
|
||||
* .withRole('admin')
|
||||
* .withProfile()
|
||||
* .build();
|
||||
* ```
|
||||
*/
|
||||
export class UserBuilder {
|
||||
private user: Partial<User> = {};
|
||||
|
||||
withId(id: string): this {
|
||||
this.user.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
withEmail(email: string): this {
|
||||
this.user.email = email;
|
||||
return this;
|
||||
}
|
||||
|
||||
withUsername(username: string): this {
|
||||
this.user.username = username;
|
||||
return this;
|
||||
}
|
||||
|
||||
withName(firstName: string, lastName: string): this {
|
||||
this.user.firstName = firstName;
|
||||
this.user.lastName = lastName;
|
||||
return this;
|
||||
}
|
||||
|
||||
withRole(role: User['role']): this {
|
||||
this.user.role = role;
|
||||
return this;
|
||||
}
|
||||
|
||||
withProfile(profile?: UserProfile): this {
|
||||
this.user.profile = profile || {
|
||||
bio: faker.person.bio(),
|
||||
avatar: faker.image.avatar(),
|
||||
phoneNumber: faker.phone.number(),
|
||||
address: AddressFactory.create(),
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
inactive(): this {
|
||||
this.user.isActive = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
active(): this {
|
||||
this.user.isActive = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): User {
|
||||
return UserFactory.create(this.user);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USAGE EXAMPLES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Example: Simple user creation
|
||||
*/
|
||||
export function exampleSimpleUser() {
|
||||
const user = UserFactory.create();
|
||||
const admin = UserFactory.createAdmin();
|
||||
const users = UserFactory.createMany(5);
|
||||
|
||||
return { user, admin, users };
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Customized user creation
|
||||
*/
|
||||
export function exampleCustomUser() {
|
||||
const user = UserFactory.create({
|
||||
email: 'custom@example.com',
|
||||
role: 'admin',
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Builder pattern
|
||||
*/
|
||||
export function exampleBuilder() {
|
||||
const user = new UserBuilder()
|
||||
.withEmail('builder@example.com')
|
||||
.withName('John', 'Doe')
|
||||
.withRole('admin')
|
||||
.withProfile()
|
||||
.active()
|
||||
.build();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Order with products
|
||||
*/
|
||||
export function exampleOrder() {
|
||||
// Create products
|
||||
const products = ProductFactory.createMany(3);
|
||||
|
||||
// Create order items from products
|
||||
const items: OrderItem[] = products.map((product) => ({
|
||||
productId: product.id,
|
||||
quantity: faker.number.int({ min: 1, max: 3 }),
|
||||
price: product.price,
|
||||
}));
|
||||
|
||||
// Create order with items
|
||||
const order = OrderFactory.createWithItems(items);
|
||||
|
||||
return { products, order };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST USAGE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Example test using factories
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('UserService (using factories)', () => {
|
||||
it('should create user', () => {
|
||||
// ARRANGE
|
||||
const userData = UserFactory.create();
|
||||
|
||||
// ACT
|
||||
const result = userService.create(userData);
|
||||
|
||||
// ASSERT
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.email).toBe(userData.email);
|
||||
});
|
||||
|
||||
it('should only allow admins to delete users', () => {
|
||||
// ARRANGE
|
||||
const admin = UserFactory.createAdmin();
|
||||
const regularUser = UserFactory.create();
|
||||
|
||||
// ACT & ASSERT
|
||||
expect(() => userService.deleteUser(regularUser.id, admin)).not.toThrow();
|
||||
expect(() => userService.deleteUser(admin.id, regularUser)).toThrow('Unauthorized');
|
||||
});
|
||||
|
||||
it('should calculate order total correctly', () => {
|
||||
// ARRANGE
|
||||
const order = OrderFactory.create({
|
||||
items: [
|
||||
{ productId: '1', quantity: 2, price: 50 },
|
||||
{ productId: '2', quantity: 1, price: 30 },
|
||||
],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const total = orderService.calculateTotal(order);
|
||||
|
||||
// ASSERT
|
||||
expect(total).toBe(140.4); // (50*2 + 30*1) * 1.08 tax
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BEST PRACTICES
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
✅ Use realistic data (faker.js)
|
||||
✅ Provide sensible defaults
|
||||
✅ Allow overrides for customization
|
||||
✅ Type-safe with TypeScript
|
||||
✅ Create helper methods (createAdmin, createInactive, etc.)
|
||||
✅ Builder pattern for complex objects
|
||||
✅ Consistent naming (create, createMany, createWith...)
|
||||
✅ Document with JSDoc
|
||||
✅ Export all factories for reuse
|
||||
✅ Keep factories simple and focused
|
||||
*/
|
||||
400
agents/qa-engineer/templates/vitest-unit-test.ts
Normal file
400
agents/qa-engineer/templates/vitest-unit-test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Vitest Unit Test Template
|
||||
*
|
||||
* This template demonstrates best practices for writing unit tests
|
||||
* with Vitest for TypeScript projects.
|
||||
*
|
||||
* Features:
|
||||
* - AAA pattern (Arrange-Act-Assert)
|
||||
* - Test isolation
|
||||
* - Mocking dependencies
|
||||
* - Parametric testing
|
||||
* - Error handling tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Import the module under test
|
||||
import { YourModule } from './YourModule';
|
||||
|
||||
// Import dependencies (to be mocked)
|
||||
import { ExternalDependency } from './ExternalDependency';
|
||||
|
||||
// ============================================================================
|
||||
// MOCKS
|
||||
// ============================================================================
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('./ExternalDependency', () => ({
|
||||
ExternalDependency: vi.fn().mockImplementation(() => ({
|
||||
fetchData: vi.fn().mockResolvedValue({ data: 'mocked' }),
|
||||
processData: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('YourModule', () => {
|
||||
// ========================================================================
|
||||
// SETUP & TEARDOWN
|
||||
// ========================================================================
|
||||
|
||||
let instance: YourModule;
|
||||
let mockDependency: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// ARRANGE: Set up fresh instance for each test
|
||||
mockDependency = new ExternalDependency();
|
||||
instance = new YourModule(mockDependency);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// TEARDOWN: Clean up mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// HAPPY PATH TESTS
|
||||
// ========================================================================
|
||||
|
||||
describe('methodName', () => {
|
||||
it('should perform expected operation with valid input', () => {
|
||||
// ARRANGE
|
||||
const input = 'test-input';
|
||||
const expectedOutput = 'test-output';
|
||||
|
||||
// ACT
|
||||
const result = instance.methodName(input);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
it('should call dependency with correct parameters', () => {
|
||||
// ARRANGE
|
||||
const input = 'test-input';
|
||||
|
||||
// ACT
|
||||
instance.methodName(input);
|
||||
|
||||
// ASSERT
|
||||
expect(mockDependency.processData).toHaveBeenCalledWith(input);
|
||||
expect(mockDependency.processData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// ASYNC TESTS
|
||||
// ========================================================================
|
||||
|
||||
describe('asyncMethod', () => {
|
||||
it('should resolve with data on success', async () => {
|
||||
// ARRANGE
|
||||
const mockData = { id: 1, value: 'test' };
|
||||
mockDependency.fetchData.mockResolvedValue(mockData);
|
||||
|
||||
// ACT
|
||||
const result = await instance.asyncMethod();
|
||||
|
||||
// ASSERT
|
||||
expect(result).toEqual(mockData);
|
||||
expect(mockDependency.fetchData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle async errors gracefully', async () => {
|
||||
// ARRANGE
|
||||
const error = new Error('Network error');
|
||||
mockDependency.fetchData.mockRejectedValue(error);
|
||||
|
||||
// ACT & ASSERT
|
||||
await expect(instance.asyncMethod()).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// EDGE CASES
|
||||
// ========================================================================
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty input', () => {
|
||||
// ACT
|
||||
const result = instance.methodName('');
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null input', () => {
|
||||
// ACT
|
||||
const result = instance.methodName(null as any);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined input', () => {
|
||||
// ACT
|
||||
const result = instance.methodName(undefined as any);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle very large input', () => {
|
||||
// ARRANGE
|
||||
const largeInput = 'x'.repeat(1000000);
|
||||
|
||||
// ACT
|
||||
const result = instance.methodName(largeInput);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// ERROR HANDLING
|
||||
// ========================================================================
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error for invalid input', () => {
|
||||
// ARRANGE
|
||||
const invalidInput = -1;
|
||||
|
||||
// ACT & ASSERT
|
||||
expect(() => instance.methodName(invalidInput)).toThrow(
|
||||
'Input must be positive'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw specific error type', () => {
|
||||
// ARRANGE
|
||||
const invalidInput = 'invalid';
|
||||
|
||||
// ACT & ASSERT
|
||||
expect(() => instance.methodName(invalidInput)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should include error details', () => {
|
||||
// ARRANGE
|
||||
const invalidInput = 'invalid';
|
||||
|
||||
// ACT
|
||||
try {
|
||||
instance.methodName(invalidInput);
|
||||
fail('Expected error to be thrown');
|
||||
} catch (error) {
|
||||
// ASSERT
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
expect(error.message).toContain('invalid');
|
||||
expect(error.code).toBe('INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// PARAMETRIC TESTS (Table-Driven)
|
||||
// ========================================================================
|
||||
|
||||
describe.each([
|
||||
{ input: 1, expected: 2 },
|
||||
{ input: 2, expected: 4 },
|
||||
{ input: 3, expected: 6 },
|
||||
{ input: 4, expected: 8 },
|
||||
])('methodName($input)', ({ input, expected }) => {
|
||||
it(`should return ${expected}`, () => {
|
||||
// ACT
|
||||
const result = instance.methodName(input);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// STATE MANAGEMENT TESTS
|
||||
// ========================================================================
|
||||
|
||||
describe('state management', () => {
|
||||
it('should update internal state correctly', () => {
|
||||
// ARRANGE
|
||||
const initialState = instance.getState();
|
||||
|
||||
// ACT
|
||||
instance.updateState('new-value');
|
||||
|
||||
// ASSERT
|
||||
const newState = instance.getState();
|
||||
expect(newState).not.toBe(initialState);
|
||||
expect(newState).toBe('new-value');
|
||||
});
|
||||
|
||||
it('should emit events on state change', () => {
|
||||
// ARRANGE
|
||||
const eventHandler = vi.fn();
|
||||
instance.on('stateChanged', eventHandler);
|
||||
|
||||
// ACT
|
||||
instance.updateState('new-value');
|
||||
|
||||
// ASSERT
|
||||
expect(eventHandler).toHaveBeenCalledWith('new-value');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// PERFORMANCE TESTS
|
||||
// ========================================================================
|
||||
|
||||
describe('performance', () => {
|
||||
it('should complete within reasonable time', () => {
|
||||
// ARRANGE
|
||||
const startTime = Date.now();
|
||||
|
||||
// ACT
|
||||
instance.methodName('test');
|
||||
|
||||
// ASSERT
|
||||
const endTime = Date.now();
|
||||
const executionTime = endTime - startTime;
|
||||
expect(executionTime).toBeLessThan(100); // 100ms threshold
|
||||
});
|
||||
|
||||
it('should handle large datasets efficiently', () => {
|
||||
// ARRANGE
|
||||
const largeDataset = Array.from({ length: 10000 }, (_, i) => i);
|
||||
|
||||
// ACT
|
||||
const startTime = Date.now();
|
||||
const result = instance.processBatch(largeDataset);
|
||||
const endTime = Date.now();
|
||||
|
||||
// ASSERT
|
||||
expect(result).toHaveLength(10000);
|
||||
expect(endTime - startTime).toBeLessThan(1000); // 1s threshold
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// SNAPSHOT TESTS
|
||||
// ========================================================================
|
||||
|
||||
describe('snapshots', () => {
|
||||
it('should match snapshot for complex output', () => {
|
||||
// ARRANGE
|
||||
const input = { id: 1, name: 'Test', nested: { value: 42 } };
|
||||
|
||||
// ACT
|
||||
const result = instance.transform(input);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match inline snapshot for simple output', () => {
|
||||
// ARRANGE
|
||||
const input = 'test';
|
||||
|
||||
// ACT
|
||||
const result = instance.methodName(input);
|
||||
|
||||
// ASSERT
|
||||
expect(result).toMatchInlineSnapshot('"test-output"');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// TIMER/DEBOUNCE TESTS
|
||||
// ========================================================================
|
||||
|
||||
describe('timers', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllTimers();
|
||||
});
|
||||
|
||||
it('should debounce function calls', () => {
|
||||
// ARRANGE
|
||||
const callback = vi.fn();
|
||||
const debounced = instance.debounce(callback, 1000);
|
||||
|
||||
// ACT
|
||||
debounced();
|
||||
debounced();
|
||||
debounced();
|
||||
|
||||
// ASSERT (not called yet)
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// ASSERT (called once)
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throttle function calls', () => {
|
||||
// ARRANGE
|
||||
const callback = vi.fn();
|
||||
const throttled = instance.throttle(callback, 1000);
|
||||
|
||||
// ACT
|
||||
throttled(); // Called immediately
|
||||
throttled(); // Ignored (within throttle window)
|
||||
throttled(); // Ignored (within throttle window)
|
||||
|
||||
// ASSERT
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// ACT
|
||||
throttled(); // Called after throttle window
|
||||
|
||||
// ASSERT
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPER TYPES & CLASSES (For Examples Above)
|
||||
// ============================================================================
|
||||
|
||||
class ValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BEST PRACTICES CHECKLIST
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
✅ AAA Pattern (Arrange-Act-Assert)
|
||||
✅ Test Isolation (beforeEach creates fresh instance)
|
||||
✅ Descriptive Test Names (should do X when Y)
|
||||
✅ One Assertion Per Test (when possible)
|
||||
✅ Mock External Dependencies
|
||||
✅ Test Happy Path
|
||||
✅ Test Edge Cases (null, undefined, empty, large)
|
||||
✅ Test Error Handling
|
||||
✅ Parametric Tests (test.each)
|
||||
✅ Async Testing (async/await, rejects, resolves)
|
||||
✅ Timer Testing (useFakeTimers)
|
||||
✅ Performance Testing (execution time)
|
||||
✅ Snapshot Testing (complex outputs)
|
||||
✅ No Shared State Between Tests
|
||||
✅ Fast Execution (< 1s per test)
|
||||
*/
|
||||
Reference in New Issue
Block a user