skills/testing-qa/SKILL.md
Expert guide for testing Next.js applications with Playwright, Jest, and React Testing Library. Use when writing tests, debugging test failures, or setting up test infrastructure.
npx skillsauth add jmsktm/claude-settings testing-qaInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill helps you write comprehensive tests for Next.js applications using Playwright for E2E tests, Jest for unit tests, and React Testing Library for component tests.
// 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',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should sign up new user', async ({ page }) => {
await page.goto('/signup')
// Fill form
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirmPassword"]', 'password123')
// Submit
await page.click('button[type="submit"]')
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard')
// Verify welcome message
await expect(page.getByText('Welcome')).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
// Verify error message
await expect(page.getByText('Invalid credentials')).toBeVisible()
})
})
// Page Object Model
// e2e/pages/login.page.ts
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 getErrorMessage() {
return await this.page.locator('[role="alert"]').textContent()
}
}
// Usage
test('login with page object', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('[email protected]', 'password')
await expect(page).toHaveURL('/dashboard')
})
// Fixtures for authenticated state
// e2e/fixtures.ts
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
// Login
await page.goto('/login')
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
await use(page)
},
})
// Usage
test('dashboard test', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
// Test authenticated functionality
})
// Wait for network
await page.waitForResponse(resp => resp.url().includes('/api/items'))
// Test file upload
await page.setInputFiles('input[type="file"]', 'path/to/file.jpg')
// Test download
const downloadPromise = page.waitForEvent('download')
await page.click('button:has-text("Download")')
const download = await downloadPromise
await download.saveAs('/path/to/save')
// Mock API responses
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ items: [] }),
})
})
// Screenshot for debugging
await page.screenshot({ path: 'debug.png', fullPage: true })
// Test responsive design
await page.setViewportSize({ width: 375, height: 667 }) // iPhone size
// Test accessibility
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
expect(accessibilityScanResults.violations).toEqual([])
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
// jest.setup.js
import '@testing-library/jest-dom'
// components/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
it('applies variant styles', () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByText('Delete')
expect(button).toHaveClass('bg-red-600')
})
})
// components/user-profile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './user-profile'
// Mock fetch
global.fetch = jest.fn()
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('displays loading state initially', () => {
(fetch as jest.Mock).mockImplementation(() =>
new Promise(() => {}) // Never resolves
)
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('displays user data when loaded', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ name: 'John Doe', email: '[email protected]' }),
})
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('[email protected]')).toBeInTheDocument()
})
})
it('displays error message when fetch fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch'))
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('Error loading user')).toBeInTheDocument()
})
})
})
// components/contact-form.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './contact-form'
describe('ContactForm', () => {
it('validates required fields', async () => {
render(<ContactForm />)
const submitButton = screen.getByText('Submit')
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Message is required')).toBeInTheDocument()
})
})
it('submits form with valid data', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<ContactForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), '[email protected]')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
message: 'Test message',
})
})
})
it('disables submit button while submitting', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText('Email'), '[email protected]')
await user.type(screen.getByLabelText('Message'), 'Test message')
const submitButton = screen.getByText('Submit')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
})
})
// hooks/use-counter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './use-counter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments count', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets count', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})
// lib/utils.test.ts
import { formatDate, slugify, truncate } from './utils'
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('January 15, 2024')
})
it('handles invalid date', () => {
expect(formatDate(new Date('invalid'))).toBe('Invalid Date')
})
})
describe('slugify', () => {
it('converts string to slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
expect(slugify('Next.js App!')).toBe('next-js-app')
})
it('removes special characters', () => {
expect(slugify('Test@#$%')).toBe('test')
})
})
describe('truncate', () => {
it('truncates long strings', () => {
const text = 'This is a very long text'
expect(truncate(text, 10)).toBe('This is a...')
})
it('does not truncate short strings', () => {
const text = 'Short'
expect(truncate(text, 10)).toBe('Short')
})
})
// app/api/items/route.test.ts
import { GET, POST } from './route'
import { NextRequest } from 'next/server'
describe('/api/items', () => {
describe('GET', () => {
it('returns items', async () => {
const request = new NextRequest('http://localhost:3000/api/items')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.items).toBeDefined()
expect(Array.isArray(data.items)).toBe(true)
})
})
describe('POST', () => {
it('creates new item', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({ title: 'Test Item' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.item).toBeDefined()
expect(data.item.title).toBe('Test Item')
})
it('validates required fields', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
})
// __mocks__/supabase.ts
export const createClient = jest.fn(() => ({
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn().mockResolvedValue({
data: { id: '1', title: 'Test' },
error: null,
}),
})),
auth: {
getUser: jest.fn().mockResolvedValue({
data: { user: { id: '123', email: '[email protected]' } },
error: null,
}),
},
}))
// Mock useRouter
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/test-path',
useSearchParams: () => new URLSearchParams(),
}))
// Use MSW (Mock Service Worker)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/items', (req, res, ctx) => {
return res(
ctx.json({
items: [
{ id: '1', title: 'Item 1' },
{ id: '2', title: 'Item 2' },
],
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Component
<button data-testid="submit-button">Submit</button>
// Test
const button = screen.getByTestId('submit-button')
// By role (best)
screen.getByRole('button', { name: /submit/i })
// By label
screen.getByLabelText('Email')
// By text
screen.getByText('Welcome')
// By placeholder
screen.getByPlaceholderText('Enter email')
it('shows loading then content', async () => {
render(<Component />)
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for content
await waitFor(() => {
expect(screen.getByText('Content')).toBeInTheDocument()
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
})
it('renders error boundary on error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation()
render(
<ErrorBoundary>
<ComponentThatThrows />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
spy.mockRestore()
})
import { screen, render } from '@testing-library/react'
// Print component tree
render(<Component />)
screen.debug()
// Print specific element
screen.debug(screen.getByRole('button'))
# Run in debug mode with browser
npx playwright test --debug
# Run specific test
npx playwright test auth.spec.ts --debug
# Run with headed browser
npx playwright test --headed
Element not found:
screen.getByText vs screen.queryByTextfindBy for async elements: screen.findByTextscreen.debug()Timing issues:
waitFor for async updatesfindBy queries (built-in wait)State updates not reflected:
act() if updating state manuallyuserEvent instead of fireEvent for more realistic events// Lighthouse CI
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
},
},
},
}
Invoke this skill when:
data-ai
Optimize YouTube videos for SEO, thumbnails, descriptions, and audience retention
testing
Design and facilitate effective workshops with agendas, activities, and outcomes
data-ai
Design and optimize AI-powered workflows for complex tasks
data-ai
Design and implement automated workflows to eliminate repetitive tasks and streamline processes