Back to Sanity

Error & Edge Case Testing

.agents/skills/playwright-best-practices/debugging/error-testing.md

5.24.09.3 KB
Original Source

Error & Edge Case Testing

Table of Contents

  1. Error Boundaries
  2. Network Failures
  3. Offline Testing
  4. Loading States
  5. Form Validation

Error Boundaries

Test Component Errors

typescript
test('error boundary catches component error', async ({page}) => {
  // Trigger error via mock
  await page.route('**/api/user', (route) => {
    route.fulfill({
      json: null, // Will cause component to throw
    })
  })

  await page.goto('/profile')

  // Error boundary should render fallback
  await expect(page.getByText('Something went wrong')).toBeVisible()
  await expect(page.getByRole('button', {name: 'Try Again'})).toBeVisible()
})

Test Error Recovery

typescript
test('recover from error state', async ({page}) => {
  let requestCount = 0

  await page.route('**/api/data', (route) => {
    requestCount++
    if (requestCount === 1) {
      return route.fulfill({status: 500})
    }
    return route.fulfill({
      json: {data: 'success'},
    })
  })

  await page.goto('/dashboard')

  // Error state
  await expect(page.getByText('Failed to load')).toBeVisible()

  // Retry
  await page.getByRole('button', {name: 'Retry'}).click()

  // Success state
  await expect(page.getByText('success')).toBeVisible()
})

Test JavaScript Errors

typescript
test('handles runtime error gracefully', async ({page}) => {
  const errors: string[] = []

  page.on('pageerror', (error) => {
    errors.push(error.message)
  })

  await page.goto('/buggy-page')

  // App should still be functional despite error
  await expect(page.getByRole('navigation')).toBeVisible()

  // Error was logged
  expect(errors.length).toBeGreaterThan(0)
})

Network Failures

Test API Errors

typescript
test.describe('API error handling', () => {
  const errorCodes = [400, 401, 403, 404, 500, 502, 503]

  for (const status of errorCodes) {
    test(`handles ${status} error`, async ({page}) => {
      await page.route('**/api/data', (route) =>
        route.fulfill({
          status,
          json: {error: `Error ${status}`},
        }),
      )

      await page.goto('/dashboard')

      // Appropriate error message shown
      await expect(page.getByRole('alert')).toBeVisible()
    })
  }
})

Test Timeout

typescript
test('handles request timeout', async ({page}) => {
  await page.route('**/api/slow', async (route) => {
    // Never respond - simulates timeout
    await new Promise(() => {})
  })

  await page.goto('/slow-page')

  // Should show timeout message (app should have its own timeout)
  await expect(page.getByText('Request timed out')).toBeVisible({
    timeout: 15000,
  })
})

Test Connection Reset

typescript
test('handles connection failure', async ({page}) => {
  await page.route('**/api/data', (route) => {
    route.abort('connectionfailed')
  })

  await page.goto('/dashboard')

  await expect(page.getByText('Connection failed')).toBeVisible()
  await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible()
})

Test Mid-Request Failure

typescript
test('handles failure during request', async ({page}) => {
  let requestStarted = false

  await page.route('**/api/upload', async (route) => {
    requestStarted = true
    // Abort after small delay (mid-request)
    await new Promise((resolve) => setTimeout(resolve, 500))
    route.abort('failed')
  })

  await page.goto('/upload')
  await page.getByLabel('File').setInputFiles('./fixtures/large-file.pdf')
  await page.getByRole('button', {name: 'Upload'}).click()

  // Should show failure, not hang
  await expect(page.getByText('Upload failed')).toBeVisible()
  expect(requestStarted).toBe(true)
})

Offline Testing

This section covers unexpected network failures and error recovery. For offline-first apps (PWAs) with service workers, caching, and background sync, see service-workers.md.

Go Offline During Session

typescript
test('handles going offline', async ({page, context}) => {
  await page.goto('/dashboard')
  await expect(page.getByTestId('data')).toBeVisible()

  // Go offline unexpectedly
  await context.setOffline(true)

  // Try to refresh data
  await page.getByRole('button', {name: 'Refresh'}).click()

  // Should show offline indicator
  await expect(page.getByText("You're offline")).toBeVisible()

  // Go back online
  await context.setOffline(false)

  // Should recover
  await page.getByRole('button', {name: 'Refresh'}).click()
  await expect(page.getByText("You're offline")).toBeHidden()
})

Test Network Recovery

typescript
test('recovers gracefully when connection returns', async ({page, context}) => {
  await page.goto('/dashboard')

  // Simulate connection drop
  await context.setOffline(true)

  // App should show degraded state
  await expect(page.getByRole('alert')).toContainText(/offline|connection/i)

  // Connection restored
  await context.setOffline(false)

  // Retry should work
  await page.getByRole('button', {name: 'Retry'}).click()
  await expect(page.getByTestId('data')).toBeVisible()
})

Loading States

Test Skeleton Loaders

typescript
test('shows skeleton during load', async ({page}) => {
  // Add delay to API response
  await page.route('**/api/posts', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 1000))
    route.fulfill({
      json: [{id: 1, title: 'Post 1'}],
    })
  })

  await page.goto('/posts')

  // Skeleton should appear immediately
  await expect(page.getByTestId('skeleton')).toBeVisible()

  // Then content replaces skeleton
  await expect(page.getByText('Post 1')).toBeVisible()
  await expect(page.getByTestId('skeleton')).toBeHidden()
})

Test Loading Indicators

typescript
test('shows loading state for actions', async ({page}) => {
  await page.route('**/api/save', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    route.fulfill({json: {success: true}})
  })

  await page.goto('/editor')
  await page.getByLabel('Content').fill('New content')

  const saveButton = page.getByRole('button', {name: 'Save'})
  await saveButton.click()

  // Button should show loading state
  await expect(saveButton).toBeDisabled()
  await expect(page.getByTestId('spinner')).toBeVisible()

  // Then success state
  await expect(saveButton).toBeEnabled()
  await expect(page.getByText('Saved')).toBeVisible()
})

Test Empty States

typescript
test('shows empty state when no data', async ({page}) => {
  await page.route('**/api/items', (route) => route.fulfill({json: []}))

  await page.goto('/items')

  await expect(page.getByText('No items yet')).toBeVisible()
  await expect(page.getByRole('button', {name: 'Create First Item'})).toBeVisible()
})

Form Validation

Test Client-Side Validation

typescript
test('validates required fields', async ({page}) => {
  await page.goto('/signup')

  // Submit empty form
  await page.getByRole('button', {name: 'Sign Up'}).click()

  // Should show validation errors
  await expect(page.getByText('Email is required')).toBeVisible()
  await expect(page.getByText('Password is required')).toBeVisible()

  // Form should not submit
  await expect(page).toHaveURL('/signup')
})

Test Format Validation

typescript
test('validates email format', async ({page}) => {
  await page.goto('/signup')

  await page.getByLabel('Email').fill('invalid-email')
  await page.getByLabel('Email').blur()

  await expect(page.getByText('Invalid email address')).toBeVisible()

  // Fix the error
  await page.getByLabel('Email').fill('[email protected]')
  await page.getByLabel('Email').blur()

  await expect(page.getByText('Invalid email address')).toBeHidden()
})

Test Server-Side Validation

typescript
test('handles server validation errors', async ({page}) => {
  await page.route('**/api/register', (route) =>
    route.fulfill({
      status: 422,
      json: {
        errors: {
          email: 'Email already exists',
          username: 'Username is taken',
        },
      },
    }),
  )

  await page.goto('/signup')
  await page.getByLabel('Email').fill('[email protected]')
  await page.getByLabel('Username').fill('takenuser')
  await page.getByLabel('Password').fill('password123')
  await page.getByRole('button', {name: 'Sign Up'}).click()

  // Server errors should display
  await expect(page.getByText('Email already exists')).toBeVisible()
  await expect(page.getByText('Username is taken')).toBeVisible()
})

Anti-Patterns to Avoid

Anti-PatternProblemSolution
Only testing happy pathMisses error handling bugsTest all error scenarios
No network failure testsApp crashes on poor connectionTest offline/slow/failed requests
Skipping loading statesJanky UX not caughtAssert loading UI appears
Ignoring validationForm bugs slip throughTest both client and server validation