mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
1364 lines
34 KiB
Markdown
1364 lines
34 KiB
Markdown
# MetaBuilder Testing Guide
|
|
|
|
> **Comprehensive guide to testing practices, TDD methodology, and Playwright E2E testing**
|
|
|
|
**Version:** 1.0.0
|
|
**Last Updated:** January 8, 2026
|
|
**Status:** 📘 Complete Testing Reference
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Testing Philosophy](#testing-philosophy)
|
|
2. [Test-Driven Development (TDD)](#test-driven-development-tdd)
|
|
3. [Testing Pyramid](#testing-pyramid)
|
|
4. [Unit Testing](#unit-testing)
|
|
5. [Integration Testing](#integration-testing)
|
|
6. [E2E Testing with Playwright](#e2e-testing-with-playwright)
|
|
7. [Running Tests](#running-tests)
|
|
8. [Best Practices](#best-practices)
|
|
9. [CI/CD Integration](#cicd-integration)
|
|
10. [Troubleshooting](#troubleshooting)
|
|
|
|
---
|
|
|
|
## Testing Philosophy
|
|
|
|
MetaBuilder emphasizes **quality through testing** with these core principles:
|
|
|
|
1. **Test First** - Write tests before implementation (TDD)
|
|
2. **Fast Feedback** - Tests should run quickly and provide immediate feedback
|
|
3. **Comprehensive Coverage** - Aim for >80% code coverage
|
|
4. **Maintainable Tests** - Tests should be as maintainable as production code
|
|
5. **Realistic Scenarios** - Tests should reflect real-world usage
|
|
|
|
### Why We Test
|
|
|
|
- **Confidence** - Deploy with confidence knowing nothing broke
|
|
- **Documentation** - Tests serve as living documentation
|
|
- **Design** - TDD drives better software design
|
|
- **Refactoring** - Tests enable safe refactoring
|
|
- **Regression Prevention** - Catch bugs before they reach production
|
|
|
|
---
|
|
|
|
## Test-Driven Development (TDD)
|
|
|
|
TDD is our primary development methodology. Every feature starts with a test.
|
|
|
|
### The Red-Green-Refactor Cycle
|
|
|
|
```
|
|
┌─────────────────────────────────────┐
|
|
│ 1. 🔴 RED: Write a failing test │
|
|
│ - Think about the API first │
|
|
│ - Write test that fails │
|
|
│ - Run test to verify it fails │
|
|
└──────────────┬──────────────────────┘
|
|
↓
|
|
┌──────────────▼──────────────────────┐
|
|
│ 2. 🟢 GREEN: Make the test pass │
|
|
│ - Write minimal code │
|
|
│ - Make test pass quickly │
|
|
│ - Don't worry about perfection │
|
|
└──────────────┬──────────────────────┘
|
|
↓
|
|
┌──────────────▼──────────────────────┐
|
|
│ 3. 🔵 REFACTOR: Improve the code │
|
|
│ - Clean up implementation │
|
|
│ - Remove duplication │
|
|
│ - Improve naming │
|
|
│ - Keep tests passing │
|
|
└──────────────┬──────────────────────┘
|
|
↓
|
|
┌──────────────▼──────────────────────┐
|
|
│ 4. ♻️ REPEAT: Next feature │
|
|
│ - Move to next requirement │
|
|
│ - Start cycle again │
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
### TDD Example: Password Validation
|
|
|
|
#### Step 1: RED 🔴 - Write the failing test
|
|
|
|
```typescript
|
|
// src/lib/auth/validate-password.test.ts
|
|
import { describe, it, expect } from 'vitest'
|
|
import { validatePassword } from './validate-password'
|
|
|
|
describe('validatePassword', () => {
|
|
it.each([
|
|
{
|
|
password: 'short',
|
|
expected: { valid: false, error: 'Password must be at least 8 characters' }
|
|
},
|
|
{
|
|
password: 'validpass123',
|
|
expected: { valid: true, error: null }
|
|
},
|
|
{
|
|
password: 'NoNumbers',
|
|
expected: { valid: false, error: 'Password must contain at least one number' }
|
|
},
|
|
{
|
|
password: '12345678',
|
|
expected: { valid: false, error: 'Password must contain at least one letter' }
|
|
},
|
|
{
|
|
password: 'ValidPass123',
|
|
expected: { valid: true, error: null }
|
|
},
|
|
])('should validate "$password"', ({ password, expected }) => {
|
|
const result = validatePassword(password)
|
|
expect(result).toEqual(expected)
|
|
})
|
|
})
|
|
```
|
|
|
|
Run test: `npm test validate-password.test.ts`
|
|
**Result:** ❌ FAIL - Function doesn't exist yet
|
|
|
|
#### Step 2: GREEN 🟢 - Write minimal code to pass
|
|
|
|
```typescript
|
|
// src/lib/auth/validate-password.ts
|
|
export interface ValidationResult {
|
|
valid: boolean
|
|
error: string | null
|
|
}
|
|
|
|
export function validatePassword(password: string): ValidationResult {
|
|
// Check length
|
|
if (password.length < 8) {
|
|
return {
|
|
valid: false,
|
|
error: 'Password must be at least 8 characters'
|
|
}
|
|
}
|
|
|
|
// Check for numbers
|
|
if (!/\d/.test(password)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Password must contain at least one number'
|
|
}
|
|
}
|
|
|
|
// Check for letters
|
|
if (!/[a-zA-Z]/.test(password)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Password must contain at least one letter'
|
|
}
|
|
}
|
|
|
|
return { valid: true, error: null }
|
|
}
|
|
```
|
|
|
|
Run test: `npm test validate-password.test.ts`
|
|
**Result:** ✅ PASS - All tests passing!
|
|
|
|
#### Step 3: REFACTOR 🔵 - Improve the code
|
|
|
|
```typescript
|
|
// src/lib/auth/validate-password.ts
|
|
export interface ValidationResult {
|
|
valid: boolean
|
|
error: string | null
|
|
}
|
|
|
|
interface ValidationRule {
|
|
test: (password: string) => boolean
|
|
message: string
|
|
}
|
|
|
|
const VALIDATION_RULES: ValidationRule[] = [
|
|
{
|
|
test: (pwd) => pwd.length >= 8,
|
|
message: 'Password must be at least 8 characters'
|
|
},
|
|
{
|
|
test: (pwd) => /\d/.test(pwd),
|
|
message: 'Password must contain at least one number'
|
|
},
|
|
{
|
|
test: (pwd) => /[a-zA-Z]/.test(pwd),
|
|
message: 'Password must contain at least one letter'
|
|
},
|
|
]
|
|
|
|
export function validatePassword(password: string): ValidationResult {
|
|
for (const rule of VALIDATION_RULES) {
|
|
if (!rule.test(password)) {
|
|
return { valid: false, error: rule.message }
|
|
}
|
|
}
|
|
|
|
return { valid: true, error: null }
|
|
}
|
|
```
|
|
|
|
Run test: `npm test validate-password.test.ts`
|
|
**Result:** ✅ PASS - Tests still passing after refactor!
|
|
|
|
#### Step 4: REPEAT ♻️ - Add more requirements
|
|
|
|
Now we can easily add more rules:
|
|
|
|
```typescript
|
|
const VALIDATION_RULES: ValidationRule[] = [
|
|
{
|
|
test: (pwd) => pwd.length >= 8,
|
|
message: 'Password must be at least 8 characters'
|
|
},
|
|
{
|
|
test: (pwd) => /\d/.test(pwd),
|
|
message: 'Password must contain at least one number'
|
|
},
|
|
{
|
|
test: (pwd) => /[a-zA-Z]/.test(pwd),
|
|
message: 'Password must contain at least one letter'
|
|
},
|
|
{
|
|
test: (pwd) => /[!@#$%^&*]/.test(pwd),
|
|
message: 'Password must contain at least one special character'
|
|
},
|
|
]
|
|
```
|
|
|
|
And add corresponding test cases!
|
|
|
|
### TDD Benefits in This Example
|
|
|
|
1. **API Design** - We designed the function interface before implementation
|
|
2. **Comprehensive Cases** - Tests cover all edge cases upfront
|
|
3. **Confidence** - We know exactly what the function should do
|
|
4. **Easy Refactoring** - We refactored with confidence because tests passed
|
|
5. **Documentation** - Tests document expected behavior clearly
|
|
|
|
---
|
|
|
|
## Testing Pyramid
|
|
|
|
MetaBuilder follows the testing pyramid strategy:
|
|
|
|
```
|
|
/\
|
|
/ \
|
|
/E2E \ Few, Slow, Expensive
|
|
/------\ Test critical user flows
|
|
/ \
|
|
/Integration\ More, Medium Speed
|
|
/------------\ Test components working together
|
|
/ \
|
|
/ Unit Tests \ Many, Fast, Cheap
|
|
/------------------\ Test individual functions
|
|
```
|
|
|
|
### Test Distribution
|
|
|
|
| Type | Percentage | Example Count | Avg Duration |
|
|
|------|-----------|---------------|--------------|
|
|
| Unit | 70% | 150+ tests | <100ms each |
|
|
| Integration | 20% | 40+ tests | <500ms each |
|
|
| E2E | 10% | 20+ tests | <30s each |
|
|
|
|
**Total Test Suite:** Should run in <5 minutes
|
|
|
|
---
|
|
|
|
## Unit Testing
|
|
|
|
Unit tests focus on testing individual functions in isolation.
|
|
|
|
### File Naming Convention
|
|
|
|
```
|
|
Source file: get-user-by-id.ts
|
|
Test file: get-user-by-id.test.ts
|
|
```
|
|
|
|
Always place test files next to source files for easy discovery.
|
|
|
|
### Test Structure: AAA Pattern
|
|
|
|
```typescript
|
|
it('should calculate total with tax', () => {
|
|
// ARRANGE: Set up test data
|
|
const items = [
|
|
{ name: 'Item 1', price: 10 },
|
|
{ name: 'Item 2', price: 20 },
|
|
]
|
|
const taxRate = 0.1
|
|
|
|
// ACT: Execute the function under test
|
|
const total = calculateTotal(items, taxRate)
|
|
|
|
// ASSERT: Verify the result
|
|
expect(total).toBe(33) // (10 + 20) * 1.1 = 33
|
|
})
|
|
```
|
|
|
|
### Parameterized Tests with it.each()
|
|
|
|
**✅ GOOD: Use parameterized tests**
|
|
|
|
```typescript
|
|
import { it, expect, describe } from 'vitest'
|
|
import { uppercase } from './uppercase'
|
|
|
|
describe('uppercase', () => {
|
|
it.each([
|
|
{ input: 'hello', expected: 'HELLO' },
|
|
{ input: 'world', expected: 'WORLD' },
|
|
{ input: '', expected: '' },
|
|
{ input: 'MiXeD', expected: 'MIXED' },
|
|
{ input: '123', expected: '123' },
|
|
])('should convert "$input" to "$expected"', ({ input, expected }) => {
|
|
expect(uppercase(input)).toBe(expected)
|
|
})
|
|
})
|
|
```
|
|
|
|
**Benefits:**
|
|
- Covers multiple cases with single test definition
|
|
- Easy to add new test cases
|
|
- Clear input/output mapping
|
|
- Reduces code duplication
|
|
|
|
**❌ BAD: Repetitive individual tests**
|
|
|
|
```typescript
|
|
it('should uppercase hello', () => {
|
|
expect(uppercase('hello')).toBe('HELLO')
|
|
})
|
|
|
|
it('should uppercase world', () => {
|
|
expect(uppercase('world')).toBe('WORLD')
|
|
})
|
|
|
|
it('should handle empty string', () => {
|
|
expect(uppercase('')).toBe('')
|
|
})
|
|
// ... repetitive!
|
|
```
|
|
|
|
### Mocking Dependencies
|
|
|
|
Use Vitest's mocking capabilities to isolate units:
|
|
|
|
```typescript
|
|
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|
import { getUserProfile } from './get-user-profile'
|
|
import { Database } from '@/lib/database'
|
|
|
|
// Mock the database module
|
|
vi.mock('@/lib/database', () => ({
|
|
Database: {
|
|
getUser: vi.fn(),
|
|
getSessions: vi.fn(),
|
|
}
|
|
}))
|
|
|
|
describe('getUserProfile', () => {
|
|
beforeEach(() => {
|
|
// Reset mocks before each test
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('should fetch user and their sessions', async () => {
|
|
// Arrange: Set up mock responses
|
|
const mockUser = { id: '1', name: 'Test User', email: 'test@example.com' }
|
|
const mockSessions = [
|
|
{ id: 's1', userId: '1', createdAt: new Date() }
|
|
]
|
|
|
|
vi.mocked(Database.getUser).mockResolvedValue(mockUser)
|
|
vi.mocked(Database.getSessions).mockResolvedValue(mockSessions)
|
|
|
|
// Act: Call the function
|
|
const profile = await getUserProfile('1')
|
|
|
|
// Assert: Verify the result
|
|
expect(Database.getUser).toHaveBeenCalledWith('1')
|
|
expect(Database.getSessions).toHaveBeenCalledWith({ userId: '1' })
|
|
expect(profile).toEqual({
|
|
user: mockUser,
|
|
sessions: mockSessions,
|
|
sessionCount: 1,
|
|
})
|
|
})
|
|
|
|
it('should handle user not found', async () => {
|
|
// Arrange
|
|
vi.mocked(Database.getUser).mockResolvedValue(null)
|
|
|
|
// Act & Assert
|
|
await expect(getUserProfile('nonexistent')).rejects.toThrow('User not found')
|
|
})
|
|
})
|
|
```
|
|
|
|
### Testing Async Functions
|
|
|
|
```typescript
|
|
describe('fetchUserData', () => {
|
|
it('should fetch and parse user data', async () => {
|
|
const userId = 'user-123'
|
|
|
|
const data = await fetchUserData(userId)
|
|
|
|
expect(data.id).toBe(userId)
|
|
expect(data.name).toBeDefined()
|
|
})
|
|
|
|
it('should handle network errors', async () => {
|
|
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
|
|
|
|
await expect(fetchUserData('user-123')).rejects.toThrow('Network error')
|
|
})
|
|
})
|
|
```
|
|
|
|
### Testing React Components
|
|
|
|
```typescript
|
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { LoginForm } from './LoginForm'
|
|
|
|
describe('LoginForm', () => {
|
|
it('should render login form', () => {
|
|
render(<LoginForm onSubmit={vi.fn()} />)
|
|
|
|
expect(screen.getByLabelText('Email')).toBeInTheDocument()
|
|
expect(screen.getByLabelText('Password')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('should call onSubmit with form data', async () => {
|
|
const handleSubmit = vi.fn()
|
|
render(<LoginForm onSubmit={handleSubmit} />)
|
|
|
|
// Fill in form
|
|
fireEvent.change(screen.getByLabelText('Email'), {
|
|
target: { value: 'test@example.com' }
|
|
})
|
|
fireEvent.change(screen.getByLabelText('Password'), {
|
|
target: { value: 'password123' }
|
|
})
|
|
|
|
// Submit form
|
|
fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
|
|
|
// Verify callback was called with correct data
|
|
expect(handleSubmit).toHaveBeenCalledWith({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
})
|
|
})
|
|
|
|
it('should show validation error for invalid email', async () => {
|
|
render(<LoginForm onSubmit={vi.fn()} />)
|
|
|
|
fireEvent.change(screen.getByLabelText('Email'), {
|
|
target: { value: 'invalid-email' }
|
|
})
|
|
fireEvent.blur(screen.getByLabelText('Email'))
|
|
|
|
expect(await screen.findByText('Invalid email address')).toBeInTheDocument()
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Integration Testing
|
|
|
|
Integration tests verify that multiple units work together correctly.
|
|
|
|
### Database Integration Tests
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach, afterAll } from 'vitest'
|
|
import { Database } from '@/lib/database'
|
|
import { createTestDb, resetTestDb, cleanupTestDb } from '@/lib/db/test-utils'
|
|
|
|
describe('User Management Integration', () => {
|
|
beforeEach(async () => {
|
|
await resetTestDb()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await cleanupTestDb()
|
|
})
|
|
|
|
it('should create user, session, and verify auth flow', async () => {
|
|
// Create user
|
|
const user = await Database.createUser({
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
password: 'password123',
|
|
})
|
|
|
|
expect(user.id).toBeDefined()
|
|
expect(user.level).toBe(1) // Default user level
|
|
|
|
// Create session
|
|
const session = await Database.createSession({
|
|
userId: user.id,
|
|
ipAddress: '127.0.0.1',
|
|
userAgent: 'Test Agent',
|
|
})
|
|
|
|
expect(session.userId).toBe(user.id)
|
|
expect(session.token).toBeDefined()
|
|
|
|
// Verify user can be retrieved by session
|
|
const retrievedUser = await Database.getUserBySession(session.token)
|
|
expect(retrievedUser.id).toBe(user.id)
|
|
expect(retrievedUser.email).toBe('test@example.com')
|
|
|
|
// Verify session is active
|
|
const isValid = await Database.isSessionValid(session.token)
|
|
expect(isValid).toBe(true)
|
|
|
|
// Delete session
|
|
await Database.deleteSession(session.token)
|
|
|
|
// Verify session is no longer valid
|
|
const isStillValid = await Database.isSessionValid(session.token)
|
|
expect(isStillValid).toBe(false)
|
|
})
|
|
|
|
it('should handle permission level upgrades', async () => {
|
|
// Create regular user
|
|
const user = await Database.createUser({
|
|
email: 'user@example.com',
|
|
name: 'Regular User',
|
|
level: 1,
|
|
})
|
|
|
|
expect(user.level).toBe(1)
|
|
|
|
// Upgrade to admin
|
|
await Database.updateUserLevel(user.id, 3)
|
|
|
|
// Verify level was updated
|
|
const updated = await Database.getUser(user.id)
|
|
expect(updated.level).toBe(3)
|
|
|
|
// Verify admin can access admin resources
|
|
const canAccess = await Database.checkPermission(user.id, 'admin_panel')
|
|
expect(canAccess).toBe(true)
|
|
})
|
|
})
|
|
```
|
|
|
|
### API Integration Tests
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest'
|
|
import { createMocks } from 'node-mocks-http'
|
|
import handler from '@/app/api/users/route'
|
|
|
|
describe('GET /api/users', () => {
|
|
it('should return list of users', async () => {
|
|
const { req, res } = createMocks({
|
|
method: 'GET',
|
|
query: { page: '1', limit: '10' },
|
|
})
|
|
|
|
await handler(req, res)
|
|
|
|
expect(res._getStatusCode()).toBe(200)
|
|
|
|
const data = JSON.parse(res._getData())
|
|
expect(data.users).toBeInstanceOf(Array)
|
|
expect(data.total).toBeGreaterThan(0)
|
|
expect(data.page).toBe(1)
|
|
})
|
|
|
|
it('should require authentication', async () => {
|
|
const { req, res } = createMocks({
|
|
method: 'GET',
|
|
headers: {}, // No auth token
|
|
})
|
|
|
|
await handler(req, res)
|
|
|
|
expect(res._getStatusCode()).toBe(401)
|
|
expect(JSON.parse(res._getData())).toEqual({
|
|
error: 'Unauthorized'
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## E2E Testing with Playwright
|
|
|
|
End-to-end tests simulate real user interactions in actual browsers.
|
|
|
|
### Why Playwright?
|
|
|
|
- **Cross-browser** - Test in Chromium, Firefox, and WebKit
|
|
- **Fast and reliable** - Auto-waiting, retry-ability, built-in
|
|
- **Rich API** - Powerful selectors and assertions
|
|
- **Debugging tools** - UI mode, trace viewer, screenshots
|
|
- **CI/CD ready** - Great CI integration
|
|
|
|
### Playwright Setup
|
|
|
|
```bash
|
|
# Install Playwright
|
|
npm install --save-dev @playwright/test
|
|
|
|
# Install browsers
|
|
npx playwright install
|
|
|
|
# Install system dependencies (Linux)
|
|
npx playwright install-deps
|
|
```
|
|
|
|
### Playwright Configuration
|
|
|
|
```typescript
|
|
// e2e/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',
|
|
|
|
use: {
|
|
baseURL: 'http://localhost:3000',
|
|
trace: 'on-first-retry',
|
|
screenshot: 'only-on-failure',
|
|
video: 'retain-on-failure',
|
|
},
|
|
|
|
projects: [
|
|
{
|
|
name: 'chromium',
|
|
use: { ...devices['Desktop Chrome'] },
|
|
},
|
|
{
|
|
name: 'firefox',
|
|
use: { ...devices['Desktop Firefox'] },
|
|
},
|
|
{
|
|
name: 'webkit',
|
|
use: { ...devices['Desktop Safari'] },
|
|
},
|
|
// Mobile testing
|
|
{
|
|
name: 'Mobile Chrome',
|
|
use: { ...devices['Pixel 5'] },
|
|
},
|
|
],
|
|
|
|
webServer: {
|
|
command: 'npm run dev',
|
|
url: 'http://localhost:3000',
|
|
reuseExistingServer: !process.env.CI,
|
|
timeout: 120 * 1000,
|
|
},
|
|
})
|
|
```
|
|
|
|
### Basic E2E Test
|
|
|
|
```typescript
|
|
// e2e/smoke.spec.ts
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
test.describe('Smoke Tests', () => {
|
|
test('should load homepage', async ({ page }) => {
|
|
await page.goto('/')
|
|
|
|
// Check page loaded
|
|
await expect(page).toHaveTitle(/MetaBuilder/)
|
|
|
|
// Check for key elements
|
|
await expect(page.locator('h1')).toBeVisible()
|
|
await expect(page.getByRole('navigation')).toBeVisible()
|
|
})
|
|
|
|
test('should have no console errors', async ({ page }) => {
|
|
const errors: string[] = []
|
|
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'error') {
|
|
errors.push(msg.text())
|
|
}
|
|
})
|
|
|
|
await page.goto('/')
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
expect(errors).toEqual([])
|
|
})
|
|
})
|
|
```
|
|
|
|
### Authentication Flow Test
|
|
|
|
```typescript
|
|
// e2e/auth/login.spec.ts
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
test.describe('Authentication', () => {
|
|
test('should login with valid credentials', async ({ page }) => {
|
|
// Navigate to login page
|
|
await page.goto('/login')
|
|
|
|
// Fill in credentials
|
|
await page.fill('input[name="email"]', 'admin@example.com')
|
|
await page.fill('input[name="password"]', 'password123')
|
|
|
|
// Submit form
|
|
await page.click('button[type="submit"]')
|
|
|
|
// Wait for redirect
|
|
await page.waitForURL(/\/dashboard/)
|
|
|
|
// Verify logged in
|
|
await expect(page.locator('text=Welcome, Admin')).toBeVisible()
|
|
|
|
// Verify session persists
|
|
await page.reload()
|
|
await expect(page).toHaveURL(/\/dashboard/)
|
|
})
|
|
|
|
test('should show error with invalid credentials', async ({ page }) => {
|
|
await page.goto('/login')
|
|
|
|
await page.fill('input[name="email"]', 'invalid@example.com')
|
|
await page.fill('input[name="password"]', 'wrongpassword')
|
|
await page.click('button[type="submit"]')
|
|
|
|
// Verify error message
|
|
await expect(page.locator('text=Invalid credentials')).toBeVisible()
|
|
|
|
// Verify still on login page
|
|
await expect(page).toHaveURL(/\/login/)
|
|
})
|
|
|
|
test('should logout successfully', async ({ page }) => {
|
|
// Login first
|
|
await page.goto('/login')
|
|
await page.fill('input[name="email"]', 'admin@example.com')
|
|
await page.fill('input[name="password"]', 'password123')
|
|
await page.click('button[type="submit"]')
|
|
await page.waitForURL(/\/dashboard/)
|
|
|
|
// Logout
|
|
await page.click('button:has-text("Logout")')
|
|
|
|
// Verify redirected to login
|
|
await expect(page).toHaveURL(/\/login/)
|
|
|
|
// Verify cannot access protected pages
|
|
await page.goto('/dashboard')
|
|
await expect(page).toHaveURL(/\/login/)
|
|
})
|
|
})
|
|
```
|
|
|
|
### CRUD Operations Test
|
|
|
|
```typescript
|
|
// e2e/crud/users.spec.ts
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
test.describe('User CRUD Operations', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Login as admin
|
|
await page.goto('/login')
|
|
await page.fill('input[name="email"]', 'admin@example.com')
|
|
await page.fill('input[name="password"]', 'password123')
|
|
await page.click('button[type="submit"]')
|
|
await page.waitForURL(/\/dashboard/)
|
|
|
|
// Navigate to users page
|
|
await page.goto('/admin/users')
|
|
})
|
|
|
|
test('should create new user', async ({ page }) => {
|
|
// Click create button
|
|
await page.click('button:has-text("Create User")')
|
|
|
|
// Fill form
|
|
await page.fill('input[name="name"]', 'New User')
|
|
await page.fill('input[name="email"]', 'newuser@example.com')
|
|
await page.fill('input[name="password"]', 'password123')
|
|
await page.selectOption('select[name="level"]', '1')
|
|
|
|
// Submit
|
|
await page.click('button[type="submit"]')
|
|
|
|
// Verify success
|
|
await expect(page.locator('text=User created successfully')).toBeVisible()
|
|
|
|
// Verify user in list
|
|
await expect(page.locator('text=newuser@example.com')).toBeVisible()
|
|
})
|
|
|
|
test('should edit existing user', async ({ page }) => {
|
|
// Find user row
|
|
const userRow = page.locator('tr:has-text("newuser@example.com")')
|
|
|
|
// Click edit
|
|
await userRow.locator('button:has-text("Edit")').click()
|
|
|
|
// Update name
|
|
await page.fill('input[name="name"]', 'Updated User Name')
|
|
|
|
// Save
|
|
await page.click('button[type="submit"]')
|
|
|
|
// Verify success
|
|
await expect(page.locator('text=User updated successfully')).toBeVisible()
|
|
|
|
// Verify updated name
|
|
await expect(page.locator('text=Updated User Name')).toBeVisible()
|
|
})
|
|
|
|
test('should delete user', async ({ page }) => {
|
|
// Get initial count
|
|
const initialCount = await page.locator('tbody tr').count()
|
|
|
|
// Find user and click delete
|
|
const userRow = page.locator('tr:has-text("newuser@example.com")')
|
|
await userRow.locator('button:has-text("Delete")').click()
|
|
|
|
// Confirm deletion in dialog
|
|
await page.locator('button:has-text("Confirm")').click()
|
|
|
|
// Verify success
|
|
await expect(page.locator('text=User deleted successfully')).toBeVisible()
|
|
|
|
// Verify count decreased
|
|
const newCount = await page.locator('tbody tr').count()
|
|
expect(newCount).toBe(initialCount - 1)
|
|
|
|
// Verify user not in list
|
|
await expect(page.locator('text=newuser@example.com')).not.toBeVisible()
|
|
})
|
|
})
|
|
```
|
|
|
|
### Page Object Model (POM)
|
|
|
|
```typescript
|
|
// e2e/pages/login-page.ts
|
|
import { Page, expect } from '@playwright/test'
|
|
|
|
export class LoginPage {
|
|
constructor(private page: Page) {}
|
|
|
|
async goto() {
|
|
await this.page.goto('/login')
|
|
}
|
|
|
|
async login(email: string, password: string) {
|
|
await this.page.fill('input[name="email"]', email)
|
|
await this.page.fill('input[name="password"]', password)
|
|
await this.page.click('button[type="submit"]')
|
|
}
|
|
|
|
async expectLoginSuccess() {
|
|
await this.page.waitForURL(/\/dashboard/)
|
|
await expect(this.page.locator('text=Welcome')).toBeVisible()
|
|
}
|
|
|
|
async expectLoginError(message: string) {
|
|
await expect(this.page.locator(`text=${message}`)).toBeVisible()
|
|
}
|
|
}
|
|
|
|
// Usage in test
|
|
test('should login', async ({ page }) => {
|
|
const loginPage = new LoginPage(page)
|
|
await loginPage.goto()
|
|
await loginPage.login('admin@example.com', 'password123')
|
|
await loginPage.expectLoginSuccess()
|
|
})
|
|
```
|
|
|
|
### Test Fixtures
|
|
|
|
```typescript
|
|
// e2e/fixtures/auth-fixture.ts
|
|
import { test as base, expect } from '@playwright/test'
|
|
import { LoginPage } from '../pages/login-page'
|
|
|
|
export const test = base.extend({
|
|
authenticatedPage: async ({ page }, use) => {
|
|
const loginPage = new LoginPage(page)
|
|
await loginPage.goto()
|
|
await loginPage.login('admin@example.com', 'password123')
|
|
await loginPage.expectLoginSuccess()
|
|
|
|
await use(page)
|
|
|
|
// Cleanup: logout
|
|
await page.click('button:has-text("Logout")')
|
|
},
|
|
})
|
|
|
|
export { expect }
|
|
|
|
// Usage
|
|
import { test, expect } from './fixtures/auth-fixture'
|
|
|
|
test('should access admin panel', async ({ authenticatedPage }) => {
|
|
await authenticatedPage.goto('/admin')
|
|
await expect(authenticatedPage.locator('h1')).toHaveText('Admin Panel')
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Running Tests
|
|
|
|
### Unit Tests (Vitest)
|
|
|
|
```bash
|
|
# Run all tests
|
|
npm test
|
|
|
|
# Run in watch mode
|
|
npm run test:watch
|
|
|
|
# Run with coverage
|
|
npm run test:coverage
|
|
|
|
# Run specific test file
|
|
npm test src/lib/users/get-user.test.ts
|
|
|
|
# Run tests matching pattern
|
|
npm test -- --grep="user authentication"
|
|
|
|
# Check function coverage
|
|
npm run test:check-functions
|
|
|
|
# Generate coverage report
|
|
npm run test:coverage:report
|
|
```
|
|
|
|
### E2E Tests (Playwright)
|
|
|
|
```bash
|
|
# Run all E2E tests
|
|
npx playwright test
|
|
|
|
# Run specific test file
|
|
npx playwright test e2e/auth/login.spec.ts
|
|
|
|
# Run tests in specific browser
|
|
npx playwright test --project=chromium
|
|
|
|
# Run in UI mode (interactive)
|
|
npx playwright test --ui
|
|
|
|
# Run in headed mode (see browser)
|
|
npx playwright test --headed
|
|
|
|
# Debug specific test
|
|
npx playwright test --debug e2e/auth/login.spec.ts
|
|
|
|
# Run only failed tests
|
|
npx playwright test --last-failed
|
|
|
|
# View HTML report
|
|
npx playwright show-report
|
|
|
|
# Update snapshots
|
|
npx playwright test --update-snapshots
|
|
```
|
|
|
|
### Continuous Integration
|
|
|
|
```bash
|
|
# CI-friendly command (no watch, exit on failure)
|
|
npm run test:ci
|
|
|
|
# Run all tests including E2E
|
|
npm run test:all
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### General Testing Best Practices
|
|
|
|
1. **Write Tests First (TDD)** - Design your API through tests
|
|
2. **Keep Tests Focused** - One test should test one thing
|
|
3. **Make Tests Independent** - Tests should not depend on each other
|
|
4. **Use Descriptive Names** - Test names should describe what they test
|
|
5. **Follow AAA Pattern** - Arrange, Act, Assert
|
|
6. **Avoid Test Logic** - Tests should be simple and straightforward
|
|
7. **Use Parameterized Tests** - Test multiple cases efficiently
|
|
8. **Keep Tests Fast** - Slow tests won't be run frequently
|
|
9. **Mock External Dependencies** - Isolate units under test
|
|
10. **Clean Up After Tests** - Reset state in afterEach/afterAll
|
|
|
|
### Unit Test Best Practices
|
|
|
|
```typescript
|
|
// ✅ GOOD: Focused, descriptive, parameterized
|
|
describe('calculateDiscount', () => {
|
|
it.each([
|
|
{ price: 100, discount: 0.1, expected: 90 },
|
|
{ price: 50, discount: 0.2, expected: 40 },
|
|
{ price: 200, discount: 0, expected: 200 },
|
|
])('should calculate $expected for price=$price with discount=$discount',
|
|
({ price, discount, expected }) => {
|
|
expect(calculateDiscount(price, discount)).toBe(expected)
|
|
}
|
|
)
|
|
})
|
|
|
|
// ❌ BAD: Vague name, not parameterized
|
|
describe('discount', () => {
|
|
it('works', () => {
|
|
expect(calculateDiscount(100, 0.1)).toBe(90)
|
|
})
|
|
})
|
|
```
|
|
|
|
### Playwright Best Practices
|
|
|
|
1. **Use Data-testid for Stability**
|
|
```typescript
|
|
// Component
|
|
<button data-testid="submit-button">Submit</button>
|
|
|
|
// Test
|
|
await page.click('[data-testid="submit-button"]')
|
|
```
|
|
|
|
2. **Wait for Network Idle**
|
|
```typescript
|
|
await page.goto('/dashboard', { waitUntil: 'networkidle' })
|
|
```
|
|
|
|
3. **Use Built-in Auto-waiting**
|
|
```typescript
|
|
// Playwright automatically waits for element to be visible and actionable
|
|
await page.click('button')
|
|
await page.fill('input', 'value')
|
|
```
|
|
|
|
4. **Take Screenshots for Debugging**
|
|
```typescript
|
|
test('complex flow', async ({ page }) => {
|
|
await page.goto('/dashboard')
|
|
await page.screenshot({ path: 'dashboard-before.png' })
|
|
|
|
// ... perform actions ...
|
|
|
|
await page.screenshot({ path: 'dashboard-after.png' })
|
|
})
|
|
```
|
|
|
|
5. **Use Trace for Debugging**
|
|
```typescript
|
|
// In playwright.config.ts
|
|
use: {
|
|
trace: 'on-first-retry', // or 'on', 'off', 'retain-on-failure'
|
|
}
|
|
|
|
// View trace
|
|
// npx playwright show-trace trace.zip
|
|
```
|
|
|
|
6. **Test Multiple Viewports**
|
|
```typescript
|
|
test('should work on mobile', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 })
|
|
await page.goto('/')
|
|
// ... test mobile layout
|
|
})
|
|
```
|
|
|
|
### Common Pitfalls to Avoid
|
|
|
|
❌ **Don't test implementation details**
|
|
```typescript
|
|
// BAD: Testing internal state
|
|
expect(component.state.isLoading).toBe(true)
|
|
|
|
// GOOD: Testing observable behavior
|
|
expect(screen.getByText('Loading...')).toBeVisible()
|
|
```
|
|
|
|
❌ **Don't use sleeps/arbitrary waits**
|
|
```typescript
|
|
// BAD
|
|
await page.waitForTimeout(5000)
|
|
|
|
// GOOD
|
|
await page.waitForSelector('[data-testid="loaded"]')
|
|
await expect(page.locator('text=Data loaded')).toBeVisible()
|
|
```
|
|
|
|
❌ **Don't depend on test execution order**
|
|
```typescript
|
|
// BAD: Tests depend on each other
|
|
test('create user', () => { /* creates user */ })
|
|
test('update user', () => { /* assumes user exists */ })
|
|
|
|
// GOOD: Each test is independent
|
|
test('update user', () => {
|
|
// Create user in this test
|
|
const user = createTestUser()
|
|
// Now update it
|
|
updateUser(user.id)
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## CI/CD Integration
|
|
|
|
### GitHub Actions
|
|
|
|
```yaml
|
|
# .github/workflows/test.yml
|
|
name: Tests
|
|
|
|
on:
|
|
push:
|
|
branches: [main, develop]
|
|
pull_request:
|
|
branches: [main, develop]
|
|
|
|
jobs:
|
|
unit-tests:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Run linter
|
|
run: npm run lint
|
|
|
|
- name: Run type check
|
|
run: npm run typecheck
|
|
|
|
- name: Run unit tests
|
|
run: npm run test:coverage
|
|
|
|
- name: Upload coverage
|
|
uses: codecov/codecov-action@v3
|
|
with:
|
|
files: ./coverage/coverage-final.json
|
|
|
|
e2e-tests:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Install Playwright browsers
|
|
run: npx playwright install --with-deps
|
|
|
|
- name: Run E2E tests
|
|
run: npx playwright test
|
|
|
|
- name: Upload test results
|
|
if: always()
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: playwright-report
|
|
path: playwright-report/
|
|
retention-days: 30
|
|
|
|
- name: Upload test videos
|
|
if: failure()
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: test-videos
|
|
path: test-results/
|
|
retention-days: 7
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
#### Playwright Tests Timing Out
|
|
|
|
**Problem:** Tests hang or timeout
|
|
```
|
|
Error: Test timeout of 30000ms exceeded
|
|
```
|
|
|
|
**Solution:**
|
|
1. Increase timeout in config
|
|
```typescript
|
|
// playwright.config.ts
|
|
timeout: 60 * 1000, // 60 seconds
|
|
```
|
|
|
|
2. Wait for specific conditions
|
|
```typescript
|
|
await page.waitForLoadState('networkidle')
|
|
await page.waitForSelector('[data-testid="loaded"]')
|
|
```
|
|
|
|
#### Tests Failing in CI but Passing Locally
|
|
|
|
**Problem:** "Flaky" tests that pass locally but fail in CI
|
|
|
|
**Solutions:**
|
|
1. Wait for network idle
|
|
```typescript
|
|
await page.goto('/', { waitUntil: 'networkidle' })
|
|
```
|
|
|
|
2. Increase timeouts for CI
|
|
```typescript
|
|
retries: process.env.CI ? 2 : 0
|
|
```
|
|
|
|
3. Take screenshots on failure
|
|
```typescript
|
|
screenshot: 'only-on-failure'
|
|
```
|
|
|
|
#### Cannot Find Element
|
|
|
|
**Problem:**
|
|
```
|
|
Error: Element not found: button:has-text("Submit")
|
|
```
|
|
|
|
**Solutions:**
|
|
1. Wait for element to be visible
|
|
```typescript
|
|
await expect(page.locator('button:has-text("Submit")')).toBeVisible()
|
|
```
|
|
|
|
2. Use more specific selectors
|
|
```typescript
|
|
await page.click('[data-testid="submit-button"]')
|
|
```
|
|
|
|
3. Check if element is in a frame
|
|
```typescript
|
|
const frame = page.frame({ name: 'my-frame' })
|
|
await frame?.click('button')
|
|
```
|
|
|
|
### Debugging Tips
|
|
|
|
#### Playwright Debug Mode
|
|
|
|
```bash
|
|
# Run single test in debug mode
|
|
npx playwright test --debug e2e/auth/login.spec.ts
|
|
|
|
# Debug from specific line
|
|
npx playwright test --debug e2e/auth/login.spec.ts:10
|
|
```
|
|
|
|
#### Playwright UI Mode
|
|
|
|
```bash
|
|
# Interactive test runner
|
|
npx playwright test --ui
|
|
```
|
|
|
|
#### View Test Traces
|
|
|
|
```bash
|
|
# View trace of failed test
|
|
npx playwright show-trace test-results/path-to-trace.zip
|
|
```
|
|
|
|
#### Console Logging
|
|
|
|
```typescript
|
|
test('debug test', async ({ page }) => {
|
|
// Listen to console logs
|
|
page.on('console', msg => console.log('PAGE LOG:', msg.text()))
|
|
|
|
// Listen to page errors
|
|
page.on('pageerror', error => console.log('PAGE ERROR:', error))
|
|
|
|
await page.goto('/')
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
MetaBuilder's testing strategy ensures high quality through:
|
|
|
|
1. **TDD Methodology** - Write tests first, implement second
|
|
2. **Testing Pyramid** - Right balance of unit, integration, and E2E tests
|
|
3. **Playwright E2E** - Reliable cross-browser testing
|
|
4. **High Coverage** - >80% code coverage goal
|
|
5. **Fast Feedback** - Tests run quickly and provide immediate feedback
|
|
6. **CI Integration** - Automated testing on every commit
|
|
|
|
### Quick Reference
|
|
|
|
| Task | Command |
|
|
|------|---------|
|
|
| Run unit tests | `npm test` |
|
|
| Run with coverage | `npm run test:coverage` |
|
|
| Run E2E tests | `npx playwright test` |
|
|
| Debug E2E test | `npx playwright test --debug` |
|
|
| View E2E report | `npx playwright show-report` |
|
|
| Run in UI mode | `npx playwright test --ui` |
|
|
|
|
### Resources
|
|
|
|
- [Vitest Documentation](https://vitest.dev/)
|
|
- [Playwright Documentation](https://playwright.dev/)
|
|
- [Testing Library](https://testing-library.com/)
|
|
- [TDD by Martin Fowler](https://martinfowler.com/bliki/TestDrivenDevelopment.html)
|
|
|
|
---
|
|
|
|
**Document Status:** 📘 Complete Testing Guide
|
|
**Version:** 1.0.0
|
|
**Last Updated:** January 8, 2026
|
|
|
|
*This guide is a living document. Please update it as testing practices evolve.*
|