Multi-framework UI testing expert - Cypress, Testing Library, component tests. Use for framework comparison, Cypress-specific testing, or React Testing Library. For DEEP Playwright expertise, use e2e-playwright skill instead. Activates for Cypress, Testing Library, component tests, React testing, Vue testing, framework comparison, which testing tool, Cypress vs Playwright.
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Expert in UI testing with Cypress and Testing Library. For deep Playwright expertise, see the e2e-playwright skill.
| Framework | Best For | Key Strength |
|---|---|---|
| Playwright | E2E, cross-browser | Auto-wait, multi-browser → Use e2e-playwright skill |
| Cypress | E2E, developer experience | Time-travel debugging, real-time reload |
| Testing Library | Component tests | User-centric queries, accessibility-first |
Why Cypress?
describe('User Authentication', () => {
it('should login with valid credentials', () => {
cy.visit('/login');
cy.get('input[name="email"]').type('user@example.com');
cy.get('input[name="password"]').type('SecurePass123!');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
cy.get('h1').should('have.text', 'Welcome, User');
});
it('should show error with invalid credentials', () => {
cy.visit('/login');
cy.get('input[name="email"]').type('wrong@example.com');
cy.get('input[name="password"]').type('WrongPass');
cy.get('button[type="submit"]').click();
cy.get('.error-message')
.should('be.visible')
.and('have.text', 'Invalid credentials');
});
});
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('input[name="email"]').type(email);
cy.get('input[name="password"]').type(password);
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
});
// Usage in tests
it('should display dashboard for logged-in user', () => {
cy.login('user@example.com', 'SecurePass123!');
cy.get('h1').should('have.text', 'Dashboard');
});
it('should display mocked user data', () => {
cy.intercept('GET', '/api/user', {
statusCode: 200,
body: {
id: 1,
name: 'Mock User',
email: 'mock@example.com',
},
}).as('getUser');
cy.visit('/profile');
cy.wait('@getUser');
cy.get('.user-name').should('have.text', 'Mock User');
});
Why Testing Library?
import { render, screen, fireEvent } from '@testing-library/react';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('should render email and password inputs', () => {
render(<LoginForm />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('should call onSubmit with email and password', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Type into inputs
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'user@example.com' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'SecurePass123!' },
});
// Submit form
fireEvent.click(screen.getByRole('button', { name: /login/i }));
// Verify callback
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'SecurePass123!',
});
});
it('should show validation error for invalid email', async () => {
render(<LoginForm />);
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'invalid-email' },
});
fireEvent.blur(screen.getByLabelText('Email'));
expect(await screen.findByText('Invalid email format')).toBeInTheDocument();
});
});
// ✅ GOOD: Accessible queries (user-facing)
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter your email');
screen.getByText('Welcome');
// ❌ BAD: Implementation-detail queries (fragile)
screen.getByClassName('btn-primary'); // Changes when CSS changes
screen.getByTestId('submit-button'); // Not user-facing
/\
/ \ E2E (10%)
/____\
/ \ Integration (30%)
/________\
/ \ Unit (60%)
/____________\
Unit Tests (60%):
Integration Tests (30%):
E2E Tests (10%):
What to Test:
What NOT to Test:
Common Causes of Flaky Tests:
❌ Bad:
await page.click('button');
const text = await page.textContent('.result'); // May fail!
✅ Good:
await page.click('button');
await page.waitForSelector('.result'); // Wait for element
const text = await page.textContent('.result');
❌ Bad:
expect(page.locator('.user')).toHaveCount(5); // Depends on database state
✅ Good:
// Mock API to return deterministic data
await page.route('**/api/users', (route) =>
route.fulfill({
body: JSON.stringify([{ id: 1, name: 'User 1' }, { id: 2, name: 'User 2' }]),
})
);
expect(page.locator('.user')).toHaveCount(2); // Predictable
❌ Bad:
await page.waitForTimeout(3000); // Arbitrary wait
✅ Good:
await page.waitForSelector('.loaded'); // Wait for specific condition
await page.waitForLoadState('networkidle'); // Wait for network idle
❌ Bad:
test('create user', async () => {
// Creates user in DB
});
test('login user', async () => {
// Depends on previous test creating user
});
✅ Good:
test.beforeEach(async () => {
// Each test creates its own user
await createTestUser();
});
test.afterEach(async () => {
await cleanupTestUsers();
});
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('should have no accessibility violations', async ({ page }) => {
await page.goto('https://example.com');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('should navigate form with keyboard', async ({ page }) => {
await page.goto('/form');
// Tab through form fields
await page.keyboard.press('Tab');
await expect(page.locator('input[name="email"]')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator('input[name="password"]')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator('button[type="submit"]')).toBeFocused();
// Submit with Enter
await page.keyboard.press('Enter');
await expect(page).toHaveURL('**/dashboard');
});
test('should have proper ARIA labels', async ({ page }) => {
await page.goto('/login');
// Verify accessible names
await expect(page.getByRole('textbox', { name: 'Email' })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Password' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
// Verify error announcements (aria-live)
await page.fill('input[name="email"]', 'invalid-email');
await page.click('button[type="submit"]');
const errorRegion = page.locator('[role="alert"]');
await expect(errorRegion).toHaveText('Invalid email format');
});
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 2 : undefined, // Parallel in CI
fullyParallel: true,
retries: process.env.CI ? 2 : 0, // Retry flaky tests in CI
reporter: process.env.CI ? 'github' : 'html',
});
# Split tests across 4 machines
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
<!-- ✅ GOOD: Stable selector -->
<button data-testid="submit-button">Submit</button>
<!-- ❌ BAD: Fragile selectors -->
<button class="btn btn-primary">Submit</button> <!-- CSS changes break tests -->
// Test
await page.click('[data-testid="submit-button"]');
❌ Bad:
// Testing internal state
expect(component.state.isLoading).toBe(true);
✅ Good:
// Testing visible UI
expect(screen.getByText('Loading...')).toBeInTheDocument();
// ✅ GOOD: Each test is independent
test.beforeEach(async ({ page }) => {
await page.goto('/');
await login(page, 'user@example.com', 'password');
});
test('test 1', async ({ page }) => {
// Fresh state
});
test('test 2', async ({ page }) => {
// Fresh state
});
❌ Bad:
expect(true).toBe(true); // Useless assertion
✅ Good:
await expect(page.locator('.success-message')).toHaveText(
'Order placed successfully'
);
❌ Bad:
await page.waitForTimeout(5000); // Slow, brittle
✅ Good:
await page.waitForSelector('.results'); // Wait for specific element
await expect(page.locator('.results')).toBeVisible(); // Built-in wait
npx playwright test --headed
npx playwright test --headed --debug # Pause on each step
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed') {
await page.screenshot({ path: `failure-${testInfo.title}.png` });
}
});
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry', // Record trace on retry
},
});
# View trace
npx playwright show-trace trace.zip
page.on('console', (msg) => console.log('Browser log:', msg.text()));
page.on('pageerror', (error) => console.error('Page error:', error));
test('should validate form fields', async ({ page }) => {
await page.goto('/form');
// Empty submission (validation)
await page.click('button[type="submit"]');
await expect(page.locator('.email-error')).toHaveText('Email is required');
// Invalid email
await page.fill('input[name="email"]', 'invalid');
await page.click('button[type="submit"]');
await expect(page.locator('.email-error')).toHaveText('Invalid email format');
// Valid submission
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'SecurePass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('**/success');
});
test('should open and close modal', async ({ page }) => {
await page.goto('/');
// Open modal
await page.click('[data-testid="open-modal"]');
await expect(page.locator('.modal')).toBeVisible();
// Close with X button
await page.click('.modal .close-button');
await expect(page.locator('.modal')).not.toBeVisible();
// Open again, close with Escape
await page.click('[data-testid="open-modal"]');
await page.keyboard.press('Escape');
await expect(page.locator('.modal')).not.toBeVisible();
});
test('should drag and drop items', async ({ page }) => {
await page.goto('/kanban');
const todoItem = page.locator('[data-testid="item-1"]');
const doneColumn = page.locator('[data-testid="column-done"]');
// Drag item from TODO to DONE
await todoItem.dragTo(doneColumn);
// Verify item moved
await expect(doneColumn.locator('[data-testid="item-1"]')).toBeVisible();
});
Ask me about: