Skip to content
Back to skills

Webapp Testing

ASecurity

Playwright E2E patterns, Testing Library component tests, test selectors. Use when writing browser tests, component tests, or setting up an E2E testing pipeline for a Next.js or React app.

  • 30 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added June 11, 2026
developmenttypescriptgobashreactnextjsnodetestingapi

Works with

  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned June 11, 2026

npx -y skills add sabahattink/antigravity-fullstack-hq --skill webapp-testing --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Webapp Testing?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Webapp Testing
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/sabahattink-webapp-testing/badge)](https://www.skillsdirectory.com/skills/sabahattink-webapp-testing)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: webapp-testing
description: Playwright E2E patterns, Testing Library component tests, test selectors. Use when writing browser tests, component tests, or setting up an E2E testing pipeline for a Next.js or React app.
---

# Web App Testing

## Testing Pyramid

```
          /\
         /E2E\        ← Few, slow, high confidence (Playwright)
        /------\
       / Integr \     ← Some, medium speed (RTL + MSW)
      /----------\
     /  Unit/Comp \   ← Many, fast, isolated (Vitest + RTL)
    /--------------\
```

## Playwright Setup

```bash
npm i -D @playwright/test
npx playwright install chromium firefox webkit
```

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir:   './e2e',
  timeout:   30_000,
  retries:   process.env.CI ? 2 : 0,
  workers:   process.env.CI ? 1 : undefined,
  reporter:  [['html'], ['list']],

  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: 'mobile',   use: { ...devices['iPhone 14'] } },
  ],

  webServer: {
    command: 'npm run build && npm run start',
    url:     'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})
```

## Page Object Model

```typescript
// e2e/pages/login.page.ts
import { Page, Locator, expect } from '@playwright/test'

export class LoginPage {
  readonly emailInput:    Locator
  readonly passwordInput: Locator
  readonly submitButton:  Locator
  readonly errorMessage:  Locator

  constructor(private page: Page) {
    this.emailInput    = page.getByLabel('Email')
    this.passwordInput = page.getByLabel('Password')
    this.submitButton  = page.getByRole('button', { name: 'Sign in' })
    this.errorMessage  = page.getByRole('alert')
  }

  async goto() {
    await this.page.goto('/login')
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message)
  }
}

// e2e/pages/dashboard.page.ts
export class DashboardPage {
  constructor(private page: Page) {}

  async expectWelcome(name: string) {
    await expect(this.page.getByRole('heading', { level: 1 })).toContainText(`Welcome, ${name}`)
  }
}
```

## Playwright Test Examples

```typescript
// e2e/auth/login.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/login.page'
import { DashboardPage } from '../pages/dashboard.page'

test.describe('Login flow', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page)
    const dashboard = new DashboardPage(page)

    await loginPage.goto()
    await loginPage.login('admin@example.com', 'password123')

    await expect(page).toHaveURL('/dashboard')
    await dashboard.expectWelcome('Admin')
  })

  test('invalid credentials shows error message', async ({ page }) => {
    const loginPage = new LoginPage(page)

    await loginPage.goto()
    await loginPage.login('bad@example.com', 'wrongpassword')

    await loginPage.expectError('Invalid email or password')
    await expect(page).toHaveURL('/login')
  })

  test('email field is required', async ({ page }) => {
    const loginPage = new LoginPage(page)
    await loginPage.goto()

    await loginPage.submitButton.click()

    // HTML5 validation
    await expect(loginPage.emailInput).toBeFocused()
  })
})
```

## Authenticated Tests

```typescript
// e2e/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test'

interface AuthFixtures {
  authenticatedPage: Page
  adminPage: Page
}

export const test = base.extend<AuthFixtures>({
  // Regular user
  authenticatedPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'e2e/.auth/user.json',
    })
    const page = await context.newPage()
    await use(page)
    await context.close()
  },

  // Admin user
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'e2e/.auth/admin.json',
    })
    const page = await context.newPage()
    await use(page)
    await context.close()
  },
})

// e2e/auth/setup.ts — run once before all tests
import { chromium } from '@playwright/test'

async function globalSetup() {
  const browser = await chromium.launch()

  // Save user session
  const userCtx = await browser.newContext()
  const userPage = await userCtx.newPage()
  await userPage.goto('http://localhost:3000/login')
  await userPage.fill('[name=email]', 'user@example.com')
  await userPage.fill('[name=password]', 'password')
  await userPage.click('button[type=submit]')
  await userPage.waitForURL('**/dashboard')
  await userCtx.storageState({ path: 'e2e/.auth/user.json' })

  await browser.close()
}

export default globalSetup
```

## React Testing Library

```typescript
// components/UserCard/UserCard.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserCard } from './UserCard'

describe('UserCard', () => {
  const user = {
    id: 1,
    name: 'Jane Doe',
    email: 'jane@example.com',
    role: 'admin' as const,
  }

  it('renders user name and email', () => {
    render(<UserCard user={user} />)

    expect(screen.getByRole('heading', { name: 'Jane Doe' })).toBeInTheDocument()
    expect(screen.getByText('jane@example.com')).toBeInTheDocument()
    expect(screen.getByText('admin')).toBeInTheDocument()
  })

  it('calls onDelete when delete button is clicked', async () => {
    const onDelete = vi.fn()
    render(<UserCard user={user} onDelete={onDelete} />)

    const deleteButton = screen.getByRole('button', { name: /delete/i })
    await userEvent.click(deleteButton)

    expect(onDelete).toHaveBeenCalledWith(user.id)
  })

  it('shows confirmation dialog before deleting', async () => {
    const onDelete = vi.fn()
    render(<UserCard user={user} onDelete={onDelete} />)

    await userEvent.click(screen.getByRole('button', { name: /delete/i }))

    expect(screen.getByRole('dialog')).toBeInTheDocument()
    expect(screen.getByText(/are you sure/i)).toBeInTheDocument()
    expect(onDelete).not.toHaveBeenCalled()

    await userEvent.click(screen.getByRole('button', { name: /confirm/i }))

    expect(onDelete).toHaveBeenCalledWith(user.id)
  })
})
```

## Mocking API Calls with MSW

```typescript
// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/v1/users', () => {
    return HttpResponse.json({
      success: true,
      data: [
        { id: 1, name: 'Jane Doe', email: 'jane@example.com' },
        { id: 2, name: 'John Smith', email: 'john@example.com' },
      ],
      meta: { total: 2, page: 1, limit: 20, pages: 1 },
    })
  }),

  http.post('/api/v1/users', async ({ request }) => {
    const body = await request.json() as CreateUserDto
    return HttpResponse.json({
      success: true,
      data: { id: 3, ...body },
    }, { status: 201 })
  }),

  http.get('/api/v1/users/:id', ({ params }) => {
    if (params.id === '999') {
      return HttpResponse.json(
        { success: false, error: { code: 'USER_NOT_FOUND', message: 'Not found' } },
        { status: 404 }
      )
    }
    return HttpResponse.json({ success: true, data: { id: Number(params.id), name: 'Test' } })
  }),
]

// test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)

// test/setup.ts
import { server } from './mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
```

## Selector Priority

```typescript
// Priority order (highest to lowest confidence):
// 1. getByRole        — reflects what users and screen readers see
// 2. getByLabelText   — form fields
// 3. getByPlaceholderText
// 4. getByText        — visible text
// 5. getByDisplayValue
// 6. getByAltText     — img alt
// 7. getByTitle
// 8. getByTestId      — last resort, use data-testid sparingly

// Good
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText('Email address')
screen.getByRole('heading', { name: 'Dashboard', level: 1 })

// Avoid (brittle, tied to implementation)
document.querySelector('.btn-primary')
container.firstChild
screen.getByTestId('submit-btn')  // only when roles don't work
```

## Async Patterns

```typescript
// Wait for element to appear
await screen.findByText('Loading complete')
await screen.findByRole('table')

// Wait for element to disappear
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'))

// Wait for DOM change
await waitFor(() => {
  expect(screen.getByText('3 items')).toBeInTheDocument()
}, { timeout: 3000 })

// Playwright: wait for network
await page.waitForResponse('**/api/v1/users')
await page.waitForLoadState('networkidle')
```

## Forbidden Patterns

- Never use `getByTestId` when a role/label selector would work
- Never test implementation details (state, method calls) — test user-visible behavior
- Never write Playwright tests that depend on hardcoded data IDs — use dynamic selectors
- Never add `sleep` / `waitForTimeout` — use proper waitFor / findBy
- Never skip error state tests — test the unhappy path
- Never share browser context between tests — each test should be isolated
- Never test styling with RTL — that belongs in visual regression (Chromatic/Percy)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…