๐บ Testing Pyramid
The testing pyramid guides how many tests of each type you should write, balancing speed, cost, and confidence.
| Level | Count | Speed | Cost | Confidence |
|---|---|---|---|---|
| Unit | Many (70%) | โก Fast | ๐ฐ Cheap | Low (isolated) |
| Integration | Some (20%) | ๐ Medium | ๐ฐ๐ฐ Medium | Medium |
| E2E | Few (10%) | ๐ข Slow | ๐ฐ๐ฐ๐ฐ Expensive | High (real) |
๐ Types of Testing
Unit Testing
Tests individual functions/components in complete isolation. No external dependencies, no API calls, no database. Fast and cheap to run.
// Testing a pure utility function
function add(a, b) {
return a + b;
}
test('adds two positive numbers', () => {
expect(add(1, 2)).toBe(3);
});
test('handles negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
test('handles decimals', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3);
});
// Testing a React component in isolation
test('Button renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
test('Button calls onClick when clicked', async () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('Button is disabled when loading', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
Integration Testing
Tests how multiple components work together. Includes API calls (mocked), state management, and component interactions. Gives more confidence than unit tests.
// Testing component with API call and state
test('displays user data after fetch', async () => {
// Mock the API response
server.use(
rest.get('/api/user/1', (req, res, ctx) => {
return res(ctx.json({ name: 'John Doe', email: 'john@test.com' }));
})
);
render(<UserProfile userId="1" />);
// Initially shows loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for data to load
await screen.findByText('John Doe');
expect(screen.getByText('john@test.com')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Testing form submission with validation
test('shows error on invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'invalid-email');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByText('Please enter a valid email')).toBeInTheDocument();
});
E2E Testing
Tests complete user flows in a real browser. Real API calls, real database, real everything. Slowest but highest confidence.
// Playwright - Complete checkout flow
test('user can complete checkout', async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('[name="email"]', 'user@test.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Navigate to products
await page.goto('/products');
await page.click('[data-testid="product-1"] button');
// Go to cart and checkout
await page.click('[data-testid="cart-icon"]');
await page.click('text=Proceed to Checkout');
// Fill payment details
await page.fill('[name="cardNumber"]', '4242424242424242');
await page.click('text=Place Order');
// Verify success
await expect(page.locator('h1')).toHaveText('Order Confirmed!');
});
Other Testing Types
| Type | Purpose | Tools |
|---|---|---|
| Snapshot | Detect UI changes | Jest |
| Visual Regression | Catch visual bugs | Chromatic, Percy |
| Performance | Load time, bundle size | Lighthouse CI |
| Accessibility | A11y compliance | jest-axe, axe-core |
๐ Test Runners vs Testing Libraries
Understanding the difference is crucial for setting up your testing infrastructure.
Test Runner
Executes tests and reports results.
- Find test files
- Execute tests
- Report pass/fail
- Code coverage
- Watch mode
Examples: Jest, Vitest, Mocha
Testing Library
Provides utilities for interacting with code.
- Render components
- Query DOM elements
- Simulate user events
- Provide assertions
Examples: RTL, Enzyme, Chai
They Work Together
// Jest (runner) + RTL (library)
import { render, screen } from '@testing-library/react'; // RTL
import Button from './Button';
test('renders button', () => { // Jest
render(<Button>Click</Button>); // RTL
expect(screen.getByRole('button')).toBeInTheDocument(); // RTL + Jest
});
Popular Combinations
| Test Runner | Testing Library | Use Case |
|---|---|---|
| Jest | RTL | React (most common) |
| Vitest | RTL | Vite projects (faster) |
| Mocha | Chai + Enzyme | Legacy React |
โ๏ธ React Testing Libraries
React Testing Library (RTL) โญ
Recommended
Philosophy: Test like a user, not implementation details.
test('counter increments', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(
screen.getByRole('button', { name: 'Increment' })
);
expect(screen.getByText('Count: 1'))
.toBeInTheDocument();
});
Enzyme (Legacy)
Deprecated
Philosophy: Test component internals, state, props.
test('counter increments', () => {
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
// โ Testing internal state
expect(wrapper.state('count')).toBe(1);
});
RTL vs Enzyme Comparison
| Aspect | RTL โ | Enzyme โ |
|---|---|---|
| Philosophy | User perspective | Developer perspective |
| Query by | Role, text, label | Component, props, state |
| State access | No (by design) | Yes |
| Refactor-safe | Yes | No |
| React 18 | Full support | No support |
| Status | Active | Deprecated |
๐ E2E Testing Libraries
Playwright
Recommended
- Multi-browser (Chromium, Firefox, WebKit)
- Multi-tab support
- Auto-wait
- Excellent for complex apps
Cypress
Great DX
- Time-travel debugging
- Real-time reloads
- Great documentation
- Best for simple apps
Code Comparison
Playwright
test('checkout', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="add"]');
await page.click('text=Checkout');
await expect(page)
.toHaveURL('/checkout');
});
Cypress
it('checkout', () => {
cy.visit('/products');
cy.get('[data-testid="add"]').click();
cy.contains('Checkout').click();
cy.url()
.should('include', '/checkout');
});
Feature Comparison
| Feature | Playwright | Cypress |
|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, Edge |
| Multi-tab | โ Yes | โ No |
| iframes | โ Easy | โ ๏ธ Limited |
| Speed | โก Fast | โก Fast |
| API Style | Promise-based | Chainable |
| Best for | Complex apps | Simple apps, great DX |
๐ฏ RTL Query Priority
RTL provides multiple queries, prioritized by accessibility and how real users interact with your app.
id is fastest due to hash table indexing and * is slowest because it searches all elements, RTL query priority is NOT about performance. All RTL queries have similar performance. The priority is purely about accessibility and user experience.
The Priority Order (and Why)
1. getByRole โ BEST CHOICE โ
Why #1? Roles are how assistive technologies (screen readers, voice control) identify elements. If you can find an element by its role, it means:
- โ Screen reader users can find it
- โ Keyboard users can navigate to it
- โ Mouse users can click it
- โ Voice control users can say "click Submit button"
// Finding buttons
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('button', { name: 'Cancel' });
// Finding headings
screen.getByRole('heading', { level: 1 }); // h1
screen.getByRole('heading', { name: 'User Settings' });
// Finding form elements
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('checkbox', { name: 'Remember me' });
screen.getByRole('combobox', { name: 'Country' }); // select
// Finding navigation elements
screen.getByRole('navigation');
screen.getByRole('link', { name: 'Home' });
// Finding dialogs and alerts
screen.getByRole('dialog');
screen.getByRole('alert');
screen.getByRole('alertdialog');
screen.logTestingPlaygroundURL() or check the WAI-ARIA roles spec. Most HTML elements have implicit roles (button โ button, a โ link, input โ textbox).
2. getByLabelText โ For Form Inputs
Why #2? This is exactly how users find form fields. When filling out a form, you look at the label to understand what to type.
// Label is associated with input via htmlFor/id
// <label htmlFor="email">Email Address</label>
// <input id="email" type="email" />
screen.getByLabelText('Email Address');
// Label wrapping the input
// <label>Password <input type="password" /></label>
screen.getByLabelText('Password');
// With aria-label
// <input aria-label="Search products" />
screen.getByLabelText('Search products');
// With aria-labelledby
// <span id="qty">Quantity</span>
// <input aria-labelledby="qty" />
screen.getByLabelText('Quantity');
3. getByPlaceholderText โ When No Label Exists
Why #3? Placeholder is visible to users but NOT ideal for accessibility (disappears when typing). Use only when there's no label.
// Search input with no label
// <input placeholder="Search..." />
screen.getByPlaceholderText('Search...');
// โ ๏ธ Better: Use aria-label for accessibility
// <input aria-label="Search" placeholder="Search..." />
screen.getByRole('textbox', { name: 'Search' }); // Preferred!
4. getByText โ For Non-Interactive Content
Why #4? Useful for finding static text like paragraphs, spans, divs. Users see this text visually.
// Finding text content
screen.getByText('Welcome back, John!');
screen.getByText('Your order has been placed.');
// With regex for partial match
screen.getByText(/welcome/i); // Case insensitive
screen.getByText(/order.*placed/i);
// Finding links by text (but getByRole is better)
screen.getByText('Click here');
// โ
Better:
screen.getByRole('link', { name: 'Click here' });
5-7. Less Common Queries
| Query | Use Case | Example |
|---|---|---|
getByDisplayValue |
Find input by its current value | getByDisplayValue('john@test.com') |
getByAltText |
Find images by alt text | getByAltText('Company logo') |
getByTitle |
Find by title attribute | getByTitle('Close dialog') |
8. getByTestId โ LAST RESORT โ
Why last? Users cannot see or interact with data-testid. It's purely for testing and provides zero accessibility benefit.
// โ Users can't see this - avoid when possible
screen.getByTestId('submit-button');
// โ
Use getByRole instead - everyone can access it
screen.getByRole('button', { name: 'Submit' });
- Complex visualizations (charts, graphs, maps)
- Canvas-based content
- Elements with no semantic meaning
- Dynamic content where text constantly changes
// โ
Acceptable uses of getByTestId
screen.getByTestId('revenue-chart'); // Canvas chart
screen.getByTestId('google-map'); // Embedded map
screen.getByTestId('animation-container'); // Complex animation
Why NOT Performance-Based?
You asked a great question about whether RTL query priority is like CSS specificity (where id is fastest due to hash indexing). The answer is NO:
- CSS: Browser maintains hash tables for IDs (O(1) lookup), but must traverse DOM for class/tag selectors (slower)
- RTL: All queries traverse the DOM similarly. There's no significant performance difference between
getByRoleandgetByTestId
RTL's priority is about testing philosophy: test the way users interact with your app. If you can't find an element the way a user would, your app might have accessibility issues!
Query Variants Cheat Sheet
| Prefix | 0 Matches | 1 Match | 1+ Matches | Async? |
|---|---|---|---|---|
getBy | โ Throw | โ Return | โ Throw | No |
queryBy | โ null | โ Return | โ Throw | No |
findBy | โ Throw | โ Return | โ Throw | Yes โฑ๏ธ |
getAllBy | โ Throw | โ Array | โ Array | No |
queryAllBy | โ [] | โ Array | โ Array | No |
findAllBy | โ Throw | โ Array | โ Array | Yes โฑ๏ธ |
Common Patterns
// Assert element EXISTS
expect(screen.getByRole('button')).toBeInTheDocument();
// Assert element DOES NOT EXIST
expect(screen.queryByRole('button')).not.toBeInTheDocument();
// Wait for element to APPEAR (async)
const button = await screen.findByRole('button');
// Find MULTIPLE elements
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(5);
// Assert multiple elements DON'T EXIST
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
๐ญ Stubs vs Mocks vs Spies
Stub
Returns fake data without tracking calls. You don't care HOW it was called, just need it to return something.
// Simple stub - just returns fake value
jest.fn().mockReturnValue('fake data');
// Stubbing an entire module
jest.mock('./api', () => ({
getUser: () => ({ name: 'John', age: 30 }),
getPosts: () => [{ id: 1, title: 'Hello' }]
}));
// Using in a test - you only care about rendered output
test('shows user name', () => {
render(<UserCard />);
// Don't care HOW getUser was called, just that data renders
expect(screen.getByText('John')).toBeInTheDocument();
});
โ Use when: Need fake API response, testing component output, don't care about call details
Mock
Replace AND verify how it was called. Used when you need to assert on the interaction itself.
// Testing callback props
test('calls onSubmit with form data', async () => {
const mockSubmit = jest.fn();
const user = userEvent.setup();
render(<ContactForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText('Name'), 'John Doe');
await user.type(screen.getByLabelText('Email'), 'john@test.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
// Assert it was called
expect(mockSubmit).toHaveBeenCalledTimes(1);
// Assert it was called with correct data
expect(mockSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@test.com'
});
});
// Testing that delete was NOT called when cancelled
test('does not delete when cancelled', async () => {
const mockDelete = jest.fn();
render(<DeleteButton onDelete={mockDelete} />);
await userEvent.click(screen.getByText('Delete'));
await userEvent.click(screen.getByText('Cancel')); // Cancel dialog
expect(mockDelete).not.toHaveBeenCalled();
});
โ Use when: Testing callbacks (onClick, onSubmit), verifying function arguments, counting call times
Spy
Watch the real function while it still executes. Original behavior is preserved, you just observe it.
// Spy on console.log - still actually logs!
test('logs error message', () => {
const consoleSpy = jest.spyOn(console, 'log');
logError('Something went wrong');
// Original console.log still ran (message appears in console)
expect(consoleSpy).toHaveBeenCalledWith('Error: Something went wrong');
consoleSpy.mockRestore(); // Always cleanup!
});
// Spy on analytics - verify tracking without breaking it
test('tracks purchase event', async () => {
const trackSpy = jest.spyOn(analytics, 'track');
render(<CheckoutButton productId="123" />);
await userEvent.click(screen.getByText('Buy Now'));
// Analytics.track() still ran - data still sent to analytics service
expect(trackSpy).toHaveBeenCalledWith('purchase_clicked', {
productId: '123'
});
trackSpy.mockRestore();
});
// Spy on Date - useful for time-dependent tests
test('formats date correctly', () => {
const dateSpy = jest.spyOn(Date, 'now').mockReturnValue(
new Date('2024-01-15').getTime()
);
expect(getFormattedDate()).toBe('January 15, 2024');
dateSpy.mockRestore();
});
โ Use when: Testing 3rd party integrations, observing side effects, need to restore original behavior
Quick Comparison
| Scenario | Use | Why |
|---|---|---|
| Need fake API response | Stub | Just need data, don't care about calls |
| Testing onClick/onSubmit props | Mock | Need to verify interaction happened |
| Verify function called with specific args | Mock | Need to assert on call arguments |
| Testing analytics/logging | Spy | Need to track without breaking original |
| Testing 3rd party library | Spy | Observe without modifying behavior |
โฑ๏ธ Async Testing
findBy* vs getBy*
// โ getBy - fails immediately if not found
const button = screen.getByText('Submit'); // Throws if not there NOW
// โ
findBy - waits for element to appear (up to 1s)
const button = await screen.findByText('Submit');
waitFor
Retries assertion until it passes or times out.
// Wait for element to appear
await waitFor(() => {
expect(screen.getByText('Success!')).toBeInTheDocument();
});
// Wait for element to DISAPPEAR
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Wait for mock to be called
await waitFor(() => {
expect(mockApi).toHaveBeenCalled();
});
Fake Timers
jest.useFakeTimers();
render(<Toast message="Saved!" duration={5000} />);
expect(screen.getByText('Saved!')).toBeInTheDocument();
// Fast-forward 5 seconds INSTANTLY
jest.advanceTimersByTime(5000);
expect(screen.queryByText('Saved!')).not.toBeInTheDocument();
jest.useRealTimers(); // Cleanup
Decision Guide
| Scenario | Use |
|---|---|
| Element appears after render | findBy* |
| Waiting for multiple updates | waitFor |
| Element disappears | waitFor + queryBy |
| setTimeout/setInterval | jest.useFakeTimers() |
๐ช Testing Hooks & Context
Testing Custom Hooks
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter(0));
// Access current value
expect(result.current.count).toBe(0);
// Call hook methods inside act()
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Testing Context Providers (Wrapper Pattern)
// test-utils.js - Create reusable wrapper
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { UserProvider } from './UserContext';
const AllProviders = ({ children }) => (
<ThemeProvider>
<UserProvider>
{children}
</UserProvider>
</ThemeProvider>
);
const customRender = (ui, options) =>
render(ui, { wrapper: AllProviders, ...options });
export { customRender as render };
// In tests - import from test-utils instead
import { render, screen } from './test-utils';
render(<Dashboard />); // All providers included!
๐ Testing Forms
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits form with correct data', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
// Type in inputs
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
// Select dropdown
await user.selectOptions(screen.getByLabelText('Role'), 'admin');
// Check checkbox
await user.click(screen.getByLabelText('Remember me'));
// Submit
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(mockSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'secret123',
role: 'admin',
remember: true
});
});
user-event Methods
| Task | Method |
|---|---|
| Type text | user.type(element, 'text') |
| Click | user.click(element) |
| Clear input | user.clear(element) |
| Select option | user.selectOptions(element, 'value') |
| Press key | user.keyboard('{Enter}') |
| Tab navigation | user.tab() |
| Hover | user.hover(element) |
| Upload file | user.upload(input, file) |
๐ Security Testing
Unit Tests for XSS Prevention
test('prevents XSS in user input', () => {
const malicious = '<script>alert("hacked")</script>';
render(<Comment text={malicious} />);
// Should show escaped text, NOT execute script
expect(screen.getByText('<script>alert("hacked")</script>'))
.toBeInTheDocument();
expect(document.querySelector('script')).not.toBeInTheDocument();
});
test('sanitizes dangerous HTML', () => {
const dirty = '<img src=x onerror=alert(1)>';
const clean = sanitizeHTML(dirty);
expect(clean).toBe('<img src="x">');
});
Automated Security Tools
| Tool | Type | What It Tests |
|---|---|---|
| OWASP ZAP | DAST | XSS, SQL Injection, CSRF |
| Snyk | SAST + Deps | Vulnerable packages |
| npm audit | Dependency | Known vulnerabilities |
| ESLint security | Static | Dangerous code patterns |
| SonarQube | SAST | Code vulnerabilities |
E2E Security Tests
test('prevents XSS via URL params', async ({ page }) => {
await page.goto('/search?q=<script>alert(1)</script>');
page.on('dialog', () => {
throw new Error('XSS vulnerability detected!');
});
await expect(page.locator('.search-term'))
.toHaveText('<script>alert(1)</script>');
});
test('CSRF token required', async ({ request }) => {
const response = await request.post('/api/transfer', {
data: { amount: 1000 },
headers: {} // No CSRF token
});
expect(response.status()).toBe(403);
});
CI/CD Security Pipeline
security:
runs-on: ubuntu-latest
steps:
- name: Dependency audit
run: npm audit --audit-level=high
- name: Snyk scan
uses: snyk/actions/node@master
- name: ESLint security
run: npx eslint --config .eslintrc.security.js src/
โ Best Practices
โ What to Test
- User interactions
- Component output
- Edge cases
- Error states
- Accessibility
โ What NOT to Test
- Implementation details
- Internal state
- Third-party libraries
- Framework internals
- CSS styling
Recommended Stack
| Purpose | Tool |
|---|---|
| Test Runner | Vitest or Jest |
| Component Testing | React Testing Library |
| E2E Testing | Playwright |
| API Mocking | MSW (Mock Service Worker) |
| Coverage | Istanbul / c8 |
Key Principles
- Test behavior, not implementation - Focus on what users see and do
- Write tests users would understand - Use accessible queries
- Prefer integration over unit tests - More confidence per test
- Use realistic data - Avoid
test123, use meaningful values - Keep tests independent - No shared state between tests
- Clean up after each test - Reset mocks, clear storage
- Don't test what you don't own - Trust third-party libraries
Jest vs Vitest
| Feature | Jest | Vitest |
|---|---|---|
| Speed | Good | Faster โก |
| Config | Separate | Uses vite.config |
| ESM Support | Needs config | Native |
| Best for | CRA, general | Vite projects |
๐ Summary
| Concept | Key Takeaway |
|---|---|
| Testing Pyramid | 70% unit, 20% integration, 10% E2E |
| RTL Philosophy | Test like a user, not implementation |
| Query Priority | getByRole first, getByTestId last |
| Async Testing | findBy for appears, waitFor for disappears |
| Mocking | Stub (data), Mock (verify), Spy (watch) |
| E2E | Playwright for complex, Cypress for simple |
| Security | npm audit + Snyk + ESLint security in CI/CD |
getByRole for queries, and integrate security testing in CI/CD.