Files
metabuilder/docs/TESTING_GUIDE.md

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

  1. Testing Philosophy
  2. Test-Driven Development (TDD)
  3. Testing Pyramid
  4. Unit Testing
  5. Integration Testing
  6. E2E Testing with Playwright
  7. Running Tests
  8. Best Practices
  9. CI/CD Integration
  10. 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

// 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

  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

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

  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

// ✅ 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

    // Component
    <button data-testid="submit-button">Submit</button>
    
    // Test
    await page.click('[data-testid="submit-button"]')
    
  2. Wait for Network Idle

    await page.goto('/dashboard', { waitUntil: 'networkidle' })
    
  3. Use Built-in Auto-waiting

    // Playwright automatically waits for element to be visible and actionable
    await page.click('button')
    await page.fill('input', 'value')
    
  4. 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' })
    })
    
  5. 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
    
  6. 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:

  1. Increase timeout in config

    // playwright.config.ts
    timeout: 60 * 1000, // 60 seconds
    
  2. 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:

  1. Wait for network idle

    await page.goto('/', { waitUntil: 'networkidle' })
    
  2. Increase timeouts for CI

    retries: process.env.CI ? 2 : 0
    
  3. Take screenshots on failure

    screenshot: 'only-on-failure'
    

Cannot Find Element

Problem:

Error: Element not found: button:has-text("Submit")

Solutions:

  1. Wait for element to be visible

    await expect(page.locator('button:has-text("Submit")')).toBeVisible()
    
  2. Use more specific selectors

    await page.click('[data-testid="submit-button"]')
    
  3. 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:

  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


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.