Back to Sanity

Test Annotations & Organization

.agents/skills/playwright-best-practices/core/annotations.md

5.20.010.4 KB
Original Source

Test Annotations & Organization

Table of Contents

  1. Skip Annotations
  2. Fixme & Fail Annotations
  3. Slow Tests
  4. Test Steps
  5. Custom Annotations
  6. Conditional Annotations

Skip Annotations

Basic Skip

typescript
// Skip unconditionally
test.skip('feature not implemented', async ({page}) => {
  // This test won't run
})

// Skip with reason
test('payment flow', async ({page}) => {
  test.skip(true, 'Payment gateway in maintenance')
  // Test body won't execute
})

Conditional Skip

typescript
test('webkit-specific feature', async ({page, browserName}) => {
  test.skip(browserName !== 'webkit', 'This feature only works in WebKit')

  await page.goto('/webkit-feature')
})

test('production only', async ({page}) => {
  test.skip(process.env.ENV !== 'production', 'Only runs against production')

  await page.goto('/prod-feature')
})

Skip by Platform

typescript
test('windows-specific', async ({page}) => {
  test.skip(process.platform !== 'win32', 'Windows only')
})

test('not on CI', async ({page}) => {
  test.skip(!!process.env.CI, 'Skipped in CI environment')
})

Skip Describe Block

typescript
test.describe('Admin features', () => {
  test.skip(({browserName}) => browserName === 'firefox', 'Firefox admin bug')

  test('admin dashboard', async ({page}) => {
    // Skipped in Firefox
  })

  test('admin settings', async ({page}) => {
    // Skipped in Firefox
  })
})

Fixme & Fail Annotations

Fixme - Known Issues

typescript
// Mark test as needing fix (skips the test)
test.fixme('broken after refactor', async ({page}) => {
  // Test won't run but is tracked
})

// Conditional fixme
test('flaky on CI', async ({page}) => {
  test.fixme(!!process.env.CI, 'Investigate CI flakiness - ticket #123')

  await page.goto('/flaky-feature')
})

Fail - Expected Failures

typescript
// Test is expected to fail (runs but expects failure)
test('known bug', async ({page}) => {
  test.fail()

  await page.goto('/buggy-page')
  // If this passes, the test fails (bug was fixed!)
  await expect(page.getByText('Working')).toBeVisible()
})

// Conditional fail
test('fails on webkit', async ({page, browserName}) => {
  test.fail(browserName === 'webkit', 'WebKit rendering bug #456')

  await page.goto('/render-test')
  await expect(page.getByTestId('element')).toHaveCSS('width', '100px')
})

Difference Between Skip, Fixme, Fail

AnnotationRuns?Use Case
test.skip()NoFeature not applicable
test.fixme()NoKnown bug, needs investigation
test.fail()YesExpected to fail, tracking a bug

Slow Tests

Mark Slow Tests

typescript
// Triple the default timeout
test('large data import', async ({page}) => {
  test.slow()

  await page.goto('/import')
  await page.setInputFiles('#file', 'large-file.csv')
  await page.getByRole('button', {name: 'Import'}).click()

  await expect(page.getByText('Import complete')).toBeVisible()
})

// Conditional slow
test('video processing', async ({page, browserName}) => {
  test.slow(browserName === 'webkit', 'WebKit video processing is slow')

  await page.goto('/video-editor')
})

Custom Timeout

typescript
test('very long operation', async ({page}) => {
  // Set specific timeout (in milliseconds)
  test.setTimeout(120000) // 2 minutes

  await page.goto('/long-operation')
})

// Timeout for describe block
test.describe('Integration tests', () => {
  test.describe.configure({timeout: 60000})

  test('test 1', async ({page}) => {
    // Has 60 second timeout
  })
})

Test Steps

Basic Steps

typescript
test('checkout flow', async ({page}) => {
  await test.step('Add item to cart', async () => {
    await page.goto('/products')
    await page.getByRole('button', {name: 'Add to Cart'}).click()
  })

  await test.step('Go to checkout', async () => {
    await page.getByRole('link', {name: 'Cart'}).click()
    await page.getByRole('button', {name: 'Checkout'}).click()
  })

  await test.step('Fill shipping info', async () => {
    await page.getByLabel('Address').fill('123 Test St')
    await page.getByLabel('City').fill('Test City')
  })

  await test.step('Complete payment', async () => {
    await page.getByLabel('Card').fill('4242424242424242')
    await page.getByRole('button', {name: 'Pay'}).click()
  })

  await expect(page.getByText('Order confirmed')).toBeVisible()
})

Nested Steps

typescript
test('user registration', async ({page}) => {
  await test.step('Fill registration form', async () => {
    await page.goto('/register')

    await test.step('Personal info', async () => {
      await page.getByLabel('Name').fill('John Doe')
      await page.getByLabel('Email').fill('[email protected]')
    })

    await test.step('Security', async () => {
      await page.getByLabel('Password').fill('SecurePass123')
      await page.getByLabel('Confirm Password').fill('SecurePass123')
    })
  })

  await test.step('Submit and verify', async () => {
    await page.getByRole('button', {name: 'Register'}).click()
    await expect(page.getByText('Welcome')).toBeVisible()
  })
})

Steps with Return Values

typescript
test('verify order', async ({page}) => {
  const orderId = await test.step('Create order', async () => {
    await page.goto('/checkout')
    await page.getByRole('button', {name: 'Place Order'}).click()

    // Return value from step
    return await page.getByTestId('order-id').textContent()
  })

  await test.step('Verify order details', async () => {
    await page.goto(`/orders/${orderId}`)
    await expect(page.getByText(`Order #${orderId}`)).toBeVisible()
  })
})

Step in Page Object

typescript
// pages/checkout.page.ts
export class CheckoutPage {
  async fillShippingInfo(address: string, city: string) {
    await test.step('Fill shipping information', async () => {
      await this.page.getByLabel('Address').fill(address)
      await this.page.getByLabel('City').fill(city)
    })
  }

  async completePayment(cardNumber: string) {
    await test.step('Complete payment', async () => {
      await this.page.getByLabel('Card').fill(cardNumber)
      await this.page.getByRole('button', {name: 'Pay'}).click()
    })
  }
}

Custom Annotations

Add Annotations

typescript
test('important feature', async ({page}, testInfo) => {
  // Add custom annotation
  testInfo.annotations.push({
    type: 'priority',
    description: 'high',
  })

  testInfo.annotations.push({
    type: 'ticket',
    description: 'JIRA-123',
  })

  await page.goto('/feature')
})

Annotation Fixture

typescript
// fixtures/annotations.fixture.ts
import {test as base, TestInfo} from '@playwright/test'

type AnnotationFixtures = {
  annotate: {
    ticket: (id: string) => void
    priority: (level: 'low' | 'medium' | 'high') => void
    owner: (name: string) => void
  }
}

export const test = base.extend<AnnotationFixtures>({
  annotate: async ({}, use, testInfo) => {
    await use({
      ticket: (id) => {
        testInfo.annotations.push({type: 'ticket', description: id})
      },
      priority: (level) => {
        testInfo.annotations.push({type: 'priority', description: level})
      },
      owner: (name) => {
        testInfo.annotations.push({type: 'owner', description: name})
      },
    })
  },
})

// Usage
test('critical feature', async ({page, annotate}) => {
  annotate.ticket('JIRA-456')
  annotate.priority('high')
  annotate.owner('Alice')

  await page.goto('/critical')
})

Read Annotations in Reporter

typescript
// reporters/annotation-reporter.ts
import {Reporter, TestCase, TestResult} from '@playwright/test/reporter'

class AnnotationReporter implements Reporter {
  onTestEnd(test: TestCase, result: TestResult) {
    const ticket = test.annotations.find((a) => a.type === 'ticket')
    const priority = test.annotations.find((a) => a.type === 'priority')

    if (ticket) {
      console.log(`Test linked to: ${ticket.description}`)
    }

    if (priority?.description === 'high' && result.status === 'failed') {
      console.log(`HIGH PRIORITY FAILURE: ${test.title}`)
    }
  }
}

export default AnnotationReporter

Conditional Annotations

Annotation Helper

typescript
// helpers/test-annotations.ts
import {test} from '@playwright/test'

export function skipInCI(reason = 'Skipped in CI') {
  test.skip(!!process.env.CI, reason)
}

export function skipInBrowser(browser: string, reason: string) {
  test.beforeEach(({browserName}) => {
    test.skip(browserName === browser, reason)
  })
}

export function onlyInEnv(env: string) {
  test.skip(process.env.ENV !== env, `Only runs in ${env}`)
}
typescript
// tests/feature.spec.ts
import {skipInCI, onlyInEnv} from '../helpers/test-annotations'

test('local only feature', async ({page}) => {
  skipInCI('Uses local resources')

  await page.goto('/local-feature')
})

test('production check', async ({page}) => {
  onlyInEnv('production')

  await page.goto('/prod-only')
})

Describe-Level Conditions

typescript
test.describe('Mobile features', () => {
  test.beforeEach(({isMobile}) => {
    test.skip(!isMobile, 'Mobile only tests')
  })

  test('touch gestures', async ({page}) => {
    // Only runs on mobile
  })
})

test.describe('Desktop features', () => {
  test.beforeEach(({isMobile}) => {
    test.skip(isMobile, 'Desktop only tests')
  })

  test('hover interactions', async ({page}) => {
    // Only runs on desktop
  })
})

Anti-Patterns to Avoid

Anti-PatternProblemSolution
Skipping without reasonHard to track whyAlways provide description
Too many skipped testsTest debt accumulatesReview and clean up regularly
Using skip instead of fixmeLoses intentUse fixme for bugs, skip for N/A
Not using stepsHard to debug failuresGroup logical actions in steps