34 KiB
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
- Testing Philosophy
- Test-Driven Development (TDD)
- Testing Pyramid
- Unit Testing
- Integration Testing
- E2E Testing with Playwright
- Running Tests
- Best Practices
- CI/CD Integration
- Troubleshooting
Testing Philosophy
MetaBuilder emphasizes quality through testing with these core principles:
- Test First - Write tests before implementation (TDD)
- Fast Feedback - Tests should run quickly and provide immediate feedback
- Comprehensive Coverage - Aim for >80% code coverage
- Maintainable Tests - Tests should be as maintainable as production code
- 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
// 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
// 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
// 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:
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
- API Design - We designed the function interface before implementation
- Comprehensive Cases - Tests cover all edge cases upfront
- Confidence - We know exactly what the function should do
- Easy Refactoring - We refactored with confidence because tests passed
- 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
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
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
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:
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
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
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
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
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
# Install Playwright
npm install --save-dev @playwright/test
# Install browsers
npx playwright install
# Install system dependencies (Linux)
npx playwright install-deps
Playwright Configuration
// 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
// 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
// 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
// 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)
// 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
// 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)
# 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)
# 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
# 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
- Write Tests First (TDD) - Design your API through tests
- Keep Tests Focused - One test should test one thing
- Make Tests Independent - Tests should not depend on each other
- Use Descriptive Names - Test names should describe what they test
- Follow AAA Pattern - Arrange, Act, Assert
- Avoid Test Logic - Tests should be simple and straightforward
- Use Parameterized Tests - Test multiple cases efficiently
- Keep Tests Fast - Slow tests won't be run frequently
- Mock External Dependencies - Isolate units under test
- Clean Up After Tests - Reset state in afterEach/afterAll
Unit Test Best Practices
// ✅ 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
-
Use Data-testid for Stability
// Component <button data-testid="submit-button">Submit</button> // Test await page.click('[data-testid="submit-button"]') -
Wait for Network Idle
await page.goto('/dashboard', { waitUntil: 'networkidle' }) -
Use Built-in Auto-waiting
// Playwright automatically waits for element to be visible and actionable await page.click('button') await page.fill('input', 'value') -
Take Screenshots for Debugging
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' }) }) -
Use Trace for Debugging
// In playwright.config.ts use: { trace: 'on-first-retry', // or 'on', 'off', 'retain-on-failure' } // View trace // npx playwright show-trace trace.zip -
Test Multiple Viewports
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
// 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
// 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
// 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
# .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:
-
Increase timeout in config
// playwright.config.ts timeout: 60 * 1000, // 60 seconds -
Wait for specific conditions
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:
-
Wait for network idle
await page.goto('/', { waitUntil: 'networkidle' }) -
Increase timeouts for CI
retries: process.env.CI ? 2 : 0 -
Take screenshots on failure
screenshot: 'only-on-failure'
Cannot Find Element
Problem:
Error: Element not found: button:has-text("Submit")
Solutions:
-
Wait for element to be visible
await expect(page.locator('button:has-text("Submit")')).toBeVisible() -
Use more specific selectors
await page.click('[data-testid="submit-button"]') -
Check if element is in a frame
const frame = page.frame({ name: 'my-frame' }) await frame?.click('button')
Debugging Tips
Playwright Debug Mode
# 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
# Interactive test runner
npx playwright test --ui
View Test Traces
# View trace of failed test
npx playwright show-trace test-results/path-to-trace.zip
Console Logging
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:
- TDD Methodology - Write tests first, implement second
- Testing Pyramid - Right balance of unit, integration, and E2E tests
- Playwright E2E - Reliable cross-browser testing
- High Coverage - >80% code coverage goal
- Fast Feedback - Tests run quickly and provide immediate feedback
- 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
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.