UI testing expert for Cypress, Testing Library, and component tests. Use when testing UI components or implementing component tests.
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('[email protected]');
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('[email protected]');
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('[email protected]', '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: '[email protected]',
},
}).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: '[email protected]' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'SecurePass123!' },
});
// Submit form
fireEvent.click(screen.getByRole('button', { name: /login/i }));
// Verify callback
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
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');
});