๐Ÿงช Testing Complete Guide

Frontend Testing Best Practices, RTL, Jest, Playwright & Security Testing

๐Ÿ”บ Testing Pyramid

The testing pyramid guides how many tests of each type you should write, balancing speed, cost, and confidence.

E2E Tests (10%) - Slow, Expensive, High Confidence
Integration Tests (20%) - Medium Speed & Cost
Unit Tests (70%) - Fast, Cheap, Isolated
LevelCountSpeedCostConfidence
UnitMany (70%)โšก Fast๐Ÿ’ฐ CheapLow (isolated)
IntegrationSome (20%)๐Ÿ”„ Medium๐Ÿ’ฐ๐Ÿ’ฐ MediumMedium
E2EFew (10%)๐Ÿข Slow๐Ÿ’ฐ๐Ÿ’ฐ๐Ÿ’ฐ ExpensiveHigh (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

TypePurposeTools
SnapshotDetect UI changesJest
Visual RegressionCatch visual bugsChromatic, Percy
PerformanceLoad time, bundle sizeLighthouse CI
AccessibilityA11y compliancejest-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 RunnerTesting LibraryUse Case
JestRTLReact (most common)
VitestRTLVite projects (faster)
MochaChai + EnzymeLegacy 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 โŒ
PhilosophyUser perspectiveDeveloper perspective
Query byRole, text, labelComponent, props, state
State accessNo (by design)Yes
Refactor-safeYesNo
React 18Full supportNo support
StatusActiveDeprecated

๐ŸŒ 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

FeaturePlaywrightCypress
BrowsersChromium, Firefox, WebKitChromium, Firefox, Edge
Multi-tabโœ… YesโŒ No
iframesโœ… Easyโš ๏ธ Limited
Speedโšก Fastโšก Fast
API StylePromise-basedChainable
Best forComplex appsSimple apps, great DX

๐ŸŽฏ RTL Query Priority

RTL provides multiple queries, prioritized by accessibility and how real users interact with your app.

โš ๏ธ Common Misconception: Unlike CSS selectors where 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');
๐Ÿ’ก Tip: Not sure what role an element has? Use 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

QueryUse CaseExample
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' });
When IS getByTestId acceptable?
  • 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 getByRole and getByTestId

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

Prefix0 Matches1 Match1+ MatchesAsync?
getByโŒ Throwโœ… ReturnโŒ ThrowNo
queryByโœ… nullโœ… ReturnโŒ ThrowNo
findByโŒ Throwโœ… ReturnโŒ ThrowYes โฑ๏ธ
getAllByโŒ Throwโœ… Arrayโœ… ArrayNo
queryAllByโœ… []โœ… Arrayโœ… ArrayNo
findAllByโŒ Throwโœ… Arrayโœ… ArrayYes โฑ๏ธ

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

ScenarioUseWhy
Need fake API responseStubJust need data, don't care about calls
Testing onClick/onSubmit propsMockNeed to verify interaction happened
Verify function called with specific argsMockNeed to assert on call arguments
Testing analytics/loggingSpyNeed to track without breaking original
Testing 3rd party librarySpyObserve 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

ScenarioUse
Element appears after renderfindBy*
Waiting for multiple updateswaitFor
Element disappearswaitFor + queryBy
setTimeout/setIntervaljest.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

TaskMethod
Type textuser.type(element, 'text')
Clickuser.click(element)
Clear inputuser.clear(element)
Select optionuser.selectOptions(element, 'value')
Press keyuser.keyboard('{Enter}')
Tab navigationuser.tab()
Hoveruser.hover(element)
Upload fileuser.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

ToolTypeWhat It Tests
OWASP ZAPDASTXSS, SQL Injection, CSRF
SnykSAST + DepsVulnerable packages
npm auditDependencyKnown vulnerabilities
ESLint securityStaticDangerous code patterns
SonarQubeSASTCode 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

PurposeTool
Test RunnerVitest or Jest
Component TestingReact Testing Library
E2E TestingPlaywright
API MockingMSW (Mock Service Worker)
CoverageIstanbul / c8

Key Principles

  1. Test behavior, not implementation - Focus on what users see and do
  2. Write tests users would understand - Use accessible queries
  3. Prefer integration over unit tests - More confidence per test
  4. Use realistic data - Avoid test123, use meaningful values
  5. Keep tests independent - No shared state between tests
  6. Clean up after each test - Reset mocks, clear storage
  7. Don't test what you don't own - Trust third-party libraries

Jest vs Vitest

FeatureJestVitest
SpeedGoodFaster โšก
ConfigSeparateUses vite.config
ESM SupportNeeds configNative
Best forCRA, generalVite projects

๐Ÿ“Œ Summary

ConceptKey Takeaway
Testing Pyramid70% unit, 20% integration, 10% E2E
RTL PhilosophyTest like a user, not implementation
Query PrioritygetByRole first, getByTestId last
Async TestingfindBy for appears, waitFor for disappears
MockingStub (data), Mock (verify), Spy (watch)
E2EPlaywright for complex, Cypress for simple
Securitynpm audit + Snyk + ESLint security in CI/CD
๐ŸŽฏ Key Takeaway: Focus on testing user behavior with React Testing Library, use the testing pyramid for balance, prefer getByRole for queries, and integrate security testing in CI/CD.