React Testing Strategies and Patterns

1. React Testing Library Best Practices

Practice Description Example Rationale
Test User Behavior Test components as users interact with them, not implementation details screen.getByRole('button') Tests are resilient to refactoring and reflect real usage
Query Priority Use accessible queries first: getByRole, getByLabelText, getByPlaceholderText, getByText getByRole('textbox', {name: /email/i}) Ensures accessibility and semantic HTML
Avoid Test IDs Use data-testid only as last resort when no semantic query available getByTestId('custom-widget') Encourages better accessibility practices
User-Event Library Use @testing-library/user-event for realistic interactions await user.click(button) Simulates real browser events more accurately than fireEvent
Async Utilities Use waitFor, findBy queries for async operations await screen.findByText('Loaded') Handles async updates and avoids race conditions
Avoid act() Warnings Use RTL's async utilities instead of manual act() wrapping await waitFor(() => expect(...)) Built-in utilities handle act() automatically

Example: Basic RTL test with best practices

// UserGreeting.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserGreeting from './UserGreeting';

describe('UserGreeting', () => {
  it('displays greeting after user enters name', async () => {
    const user = userEvent.setup();
    render(<UserGreeting />);
    
    // Use accessible query
    const input = screen.getByRole('textbox', { name: /your name/i });
    const button = screen.getByRole('button', { name: /submit/i });
    
    // Simulate user interaction
    await user.type(input, 'Alice');
    await user.click(button);
    
    // Assert user-visible outcome
    expect(await screen.findByText(/hello, alice/i)).toBeInTheDocument();
  });
});

Example: Query priority guide

// Preferred: Accessible to everyone
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email address/i)
screen.getByPlaceholderText(/search.../i)
screen.getByText(/welcome back/i)

// Semantic queries
screen.getByAltText(/company logo/i)
screen.getByTitle(/close dialog/i)

// Test IDs (last resort)
screen.getByTestId('custom-element')

2. Component Testing and User Interactions

Test Type Focus Query Method Example
Form Input User typing and form submission user.type(), user.click() Type into input, submit form, verify result
Button Clicks Click handlers and state changes user.click() Click button, verify UI update or API call
Async Data Loading Fetch and display data findBy*, waitFor() Wait for loading state, then verify data displayed
Conditional Rendering Show/hide elements based on state queryBy*, expect().not.toBeInTheDocument() Verify element appears/disappears correctly
Keyboard Navigation Tab, Enter, Escape key handling user.keyboard(), user.tab() Navigate with keyboard, verify focus management
Selection Controls Radio, checkbox, select interactions user.selectOptions(), user.click() Select options, verify state and behavior

Example: Testing form with validation

// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  it('shows validation errors for invalid inputs', async () => {
    const user = userEvent.setup();
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    
    const emailInput = screen.getByLabelText(/email/i);
    const passwordInput = screen.getByLabelText(/password/i);
    const submitButton = screen.getByRole('button', { name: /log in/i });
    
    // Submit with invalid email
    await user.type(emailInput, 'invalid-email');
    await user.type(passwordInput, '123');
    await user.click(submitButton);
    
    // Verify validation errors appear
    expect(await screen.findByText(/invalid email format/i)).toBeInTheDocument();
    expect(await screen.findByText(/password must be at least 8 characters/i)).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });

  it('submits form with valid inputs', async () => {
    const user = userEvent.setup();
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    
    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'securePassword123');
    await user.click(screen.getByRole('button', { name: /log in/i }));
    
    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'securePassword123'
    });
  });
});

Example: Testing async data fetching

// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
import { fetchUser } from './api';

jest.mock('./api');

describe('UserProfile', () => {
  it('displays loading state then user data', async () => {
    fetchUser.mockResolvedValue({
      id: 1,
      name: 'Alice',
      email: 'alice@example.com'
    });
    
    render(<UserProfile userId={1} />);
    
    // Verify loading state
    expect(screen.getByText(/loading.../i)).toBeInTheDocument();
    
    // Wait for data to load
    expect(await screen.findByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
    expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument();
  });

  it('displays error message on fetch failure', async () => {
    fetchUser.mockRejectedValue(new Error('Network error'));
    
    render(<UserProfile userId={1} />);
    
    expect(await screen.findByText(/failed to load user/i)).toBeInTheDocument();
  });
});

3. Hook Testing with renderHook

Hook Type Testing Approach Key Method Example
Custom Hooks Test hook logic in isolation renderHook() Test custom hook without wrapping component
State Updates Test state changes and side effects result.current, act() Access hook values and trigger updates
Async Hooks Test async operations in hooks waitFor(() => expect(...)) Wait for async state updates
Hook Dependencies Test effect dependencies and re-runs rerender() Change props and verify effect behavior
Context Hooks Test hooks requiring context wrapper: Provider Wrap hook with necessary providers
Cleanup Testing Verify cleanup functions run unmount() Test effect cleanup and subscriptions

Example: Testing custom hook with state

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

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 to initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });
});

Example: Testing async hook with API calls

// useFetchUser.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import useFetchUser from './useFetchUser';
import { fetchUser } from './api';

jest.mock('./api');

describe('useFetchUser', () => {
  it('fetches user data successfully', async () => {
    const mockUser = { id: 1, name: 'Alice' };
    fetchUser.mockResolvedValue(mockUser);
    
    const { result } = renderHook(() => useFetchUser(1));
    
    // Initial state
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBeNull();
    expect(result.current.error).toBeNull();
    
    // Wait for data to load
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.data).toEqual(mockUser);
    expect(result.current.error).toBeNull();
  });

  it('handles fetch errors', async () => {
    fetchUser.mockRejectedValue(new Error('Network error'));
    
    const { result } = renderHook(() => useFetchUser(1));
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.data).toBeNull();
    expect(result.current.error).toEqual(new Error('Network error'));
  });

  it('refetches when userId changes', async () => {
    fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
    
    const { result, rerender } = renderHook(
      ({ userId }) => useFetchUser(userId),
      { initialProps: { userId: 1 } }
    );
    
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(fetchUser).toHaveBeenCalledWith(1);
    
    // Change userId
    fetchUser.mockResolvedValue({ id: 2, name: 'Bob' });
    rerender({ userId: 2 });
    
    await waitFor(() => expect(result.current.data.name).toBe('Bob'));
    expect(fetchUser).toHaveBeenCalledWith(2);
  });
});

Example: Testing hook with context

// useAuth.test.tsx
import { renderHook } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import useAuth from './useAuth';

const wrapper = ({ children }) => (
  <AuthProvider initialUser={{ id: 1, name: 'Alice' }}>
    {children}
  </AuthProvider>
);

describe('useAuth', () => {
  it('provides auth context values', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    expect(result.current.user).toEqual({ id: 1, name: 'Alice' });
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('throws error when used outside provider', () => {
    // Suppress console.error for this test
    const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
    
    expect(() => {
      renderHook(() => useAuth());
    }).toThrow('useAuth must be used within AuthProvider');
    
    spy.mockRestore();
  });
});

4. Mocking and Test Doubles for Components

Mock Type Use Case Implementation Example
Module Mocking Replace entire module with mock jest.mock('./module') Mock API clients, utilities
Function Mocking Mock specific functions jest.fn() Mock callbacks, event handlers
Component Mocking Replace child components jest.mock('./Component') Isolate component under test
Spy Functions Track function calls without replacing jest.spyOn(obj, 'method') Verify method calls on objects
Timer Mocking Control setTimeout, setInterval jest.useFakeTimers() Test debounce, throttle, delays
Network Mocking Mock HTTP requests MSW (Mock Service Worker) Mock fetch, axios requests

Example: Mocking API calls

// TodoList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
import * as api from './api';

// Mock the entire API module
jest.mock('./api');

describe('TodoList', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('loads and displays todos', async () => {
    const mockTodos = [
      { id: 1, title: 'Buy milk', completed: false },
      { id: 2, title: 'Walk dog', completed: true }
    ];
    
    api.fetchTodos.mockResolvedValue(mockTodos);
    
    render(<TodoList />);
    
    expect(await screen.findByText('Buy milk')).toBeInTheDocument();
    expect(screen.getByText('Walk dog')).toBeInTheDocument();
    expect(api.fetchTodos).toHaveBeenCalledTimes(1);
  });

  it('adds new todo', async () => {
    const user = userEvent.setup();
    api.fetchTodos.mockResolvedValue([]);
    api.createTodo.mockResolvedValue({ id: 3, title: 'New task', completed: false });
    
    render(<TodoList />);
    
    const input = screen.getByPlaceholderText(/new todo/i);
    await user.type(input, 'New task');
    await user.click(screen.getByRole('button', { name: /add/i }));
    
    await waitFor(() => {
      expect(api.createTodo).toHaveBeenCalledWith({ title: 'New task' });
    });
    
    expect(await screen.findByText('New task')).toBeInTheDocument();
  });
});

Example: Mocking child components

// Dashboard.test.tsx
import { render, screen } from '@testing-library/react';
import Dashboard from './Dashboard';

// Mock complex child components
jest.mock('./UserProfile', () => {
  return function MockUserProfile({ userId }) {
    return <div>User Profile: {userId}</div>;
  };
});

jest.mock('./ActivityFeed', () => {
  return function MockActivityFeed() {
    return <div>Activity Feed</div>;
  };
});

describe('Dashboard', () => {
  it('renders main layout with mocked children', () => {
    render(<Dashboard userId={123} />);
    
    expect(screen.getByText('User Profile: 123')).toBeInTheDocument();
    expect(screen.getByText('Activity Feed')).toBeInTheDocument();
  });
});

Example: Using Mock Service Worker (MSW)

// setupTests.ts
import { setupServer } from 'msw/node';
import { rest } from 'msw';

const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' }
      ])
    );
  }),
];

export const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from './setupTests';
import { rest } from 'msw';
import UserList from './UserList';

describe('UserList', () => {
  it('displays users from API', async () => {
    render(<UserList />);
    
    expect(await screen.findByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('Bob')).toBeInTheDocument();
  });

  it('handles API errors', async () => {
    // Override handler for this test
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ error: 'Server error' }));
      })
    );
    
    render(<UserList />);
    
    expect(await screen.findByText(/failed to load users/i)).toBeInTheDocument();
  });
});

Example: Testing with fake timers

// SearchInput.test.tsx - testing debounced input
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchInput from './SearchInput';

describe('SearchInput with debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
  });

  it('debounces search callback', async () => {
    const mockSearch = jest.fn();
    const user = userEvent.setup({ delay: null }); // Disable userEvent delays
    
    render(<SearchInput onSearch={mockSearch} debounceMs={500} />);
    
    const input = screen.getByRole('textbox');
    
    // Type quickly
    await user.type(input, 'test');
    
    // Callback not called yet
    expect(mockSearch).not.toHaveBeenCalled();
    
    // Fast-forward time
    jest.advanceTimersByTime(500);
    
    // Now callback is called once with final value
    expect(mockSearch).toHaveBeenCalledTimes(1);
    expect(mockSearch).toHaveBeenCalledWith('test');
  });
});

5. Integration Testing with React Router

Test Scenario Setup Key Utilities Example
Route Rendering Wrap with MemoryRouter MemoryRouter Test component renders at specific route
Navigation Simulate link clicks user.click(), screen queries Click link, verify new route content
Route Parameters Initialize with initialEntries initialEntries: ['/user/123'] Test dynamic route params
Protected Routes Test auth redirects Navigate, conditional rendering Verify redirect when unauthorized
History Manipulation Create test history createMemoryHistory() Test back/forward navigation
Search Params Include query strings initialEntries: ['?tab=2'] Test URL search params handling

Example: Testing navigation and routes

// App.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import App from './App';

describe('App Navigation', () => {
  it('navigates from home to about page', async () => {
    const user = userEvent.setup();
    
    render(
      <MemoryRouter initialEntries={['/']}>
        <App />
      </MemoryRouter>
    );
    
    // Verify home page
    expect(screen.getByText(/welcome home/i)).toBeInTheDocument();
    
    // Click about link
    const aboutLink = screen.getByRole('link', { name: /about/i });
    await user.click(aboutLink);
    
    // Verify about page
    expect(screen.getByText(/about us/i)).toBeInTheDocument();
    expect(screen.queryByText(/welcome home/i)).not.toBeInTheDocument();
  });

  it('renders 404 page for unknown routes', () => {
    render(
      <MemoryRouter initialEntries={['/unknown-route']}>
        <App />
      </MemoryRouter>
    );
    
    expect(screen.getByText(/404.*not found/i)).toBeInTheDocument();
  });
});

Example: Testing dynamic routes with params

// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import UserProfile from './UserProfile';
import * as api from './api';

jest.mock('./api');

describe('UserProfile', () => {
  it('loads user data based on route param', async () => {
    const mockUser = { id: '123', name: 'Alice', email: 'alice@example.com' };
    api.fetchUser.mockResolvedValue(mockUser);
    
    render(
      <MemoryRouter initialEntries={['/user/123']}>
        <Routes>
          <Route path="/user/:userId" element={<UserProfile />} />
        </Routes>
      </MemoryRouter>
    );
    
    await waitFor(() => {
      expect(api.fetchUser).toHaveBeenCalledWith('123');
    });
    
    expect(await screen.findByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });
});

Example: Testing protected routes

// ProtectedRoute.test.tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './AuthContext';

describe('Protected Routes', () => {
  it('redirects to login when not authenticated', () => {
    render(
      <MemoryRouter initialEntries={['/dashboard']}>
        <AuthProvider initialAuth={{ user: null, isAuthenticated: false }}>
          <App />
        </AuthProvider>
      </MemoryRouter>
    );
    
    // Should be redirected to login
    expect(screen.getByText(/please log in/i)).toBeInTheDocument();
    expect(screen.queryByText(/dashboard/i)).not.toBeInTheDocument();
  });

  it('allows access when authenticated', () => {
    render(
      <MemoryRouter initialEntries={['/dashboard']}>
        <AuthProvider initialAuth={{ 
          user: { id: 1, name: 'Alice' }, 
          isAuthenticated: true 
        }}>
          <App />
        </AuthProvider>
      </MemoryRouter>
    );
    
    // Should show dashboard
    expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
    expect(screen.queryByText(/please log in/i)).not.toBeInTheDocument();
  });
});

6. End-to-End Testing with Cypress/Playwright

Tool Strengths Test Pattern Use Case
Cypress Developer-friendly, time-travel debugging, real-time reloading cy.visit(), cy.get(), cy.click() Full user flows, visual testing, network mocking
Playwright Multi-browser, parallel execution, mobile emulation, auto-wait page.goto(), page.click(), page.fill() Cross-browser testing, mobile testing, API testing
Cypress Commands Custom reusable commands Cypress.Commands.add() Login flows, common interactions
Network Interception Mock/stub API responses cy.intercept(), page.route() Test error states, loading states
Visual Testing Screenshot comparison cy.screenshot(), expect(screenshot) Detect visual regressions
Component Testing Mount React components in test runner cy.mount(), mount() Component isolation with real browser

Example: Cypress E2E test

// cypress/e2e/login.cy.js
describe('Login Flow', () => {
  beforeEach(() => {
    cy.visit('http://localhost:3000');
  });

  it('allows user to log in with valid credentials', () => {
    // Intercept API call
    cy.intercept('POST', '/api/login', {
      statusCode: 200,
      body: {
        user: { id: 1, name: 'Alice', email: 'alice@example.com' },
        token: 'fake-jwt-token'
      }
    }).as('loginRequest');

    // Fill login form
    cy.get('input[name="email"]').type('alice@example.com');
    cy.get('input[name="password"]').type('password123');
    cy.get('button[type="submit"]').click();

    // Wait for API call
    cy.wait('@loginRequest');

    // Verify redirect to dashboard
    cy.url().should('include', '/dashboard');
    cy.contains('Welcome, Alice').should('be.visible');
    
    // Verify token stored
    cy.window().its('localStorage.token').should('exist');
  });

  it('shows error message for invalid credentials', () => {
    cy.intercept('POST', '/api/login', {
      statusCode: 401,
      body: { error: 'Invalid credentials' }
    }).as('loginRequest');

    cy.get('input[name="email"]').type('wrong@example.com');
    cy.get('input[name="password"]').type('wrongpassword');
    cy.get('button[type="submit"]').click();

    cy.wait('@loginRequest');

    // Verify error message
    cy.contains('Invalid credentials').should('be.visible');
    cy.url().should('include', '/login');
  });

  it('validates form inputs', () => {
    cy.get('button[type="submit"]').click();

    // Check validation errors
    cy.contains('Email is required').should('be.visible');
    cy.contains('Password is required').should('be.visible');

    // Fill invalid email
    cy.get('input[name="email"]').type('invalid-email');
    cy.get('button[type="submit"]').click();
    cy.contains('Invalid email format').should('be.visible');
  });
});

Example: Cypress custom commands

// 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');
});

Cypress.Commands.add('logout', () => {
  cy.get('[data-testid="user-menu"]').click();
  cy.contains('Logout').click();
  cy.url().should('include', '/login');
});

// Usage in tests
describe('Dashboard', () => {
  beforeEach(() => {
    cy.login('alice@example.com', 'password123');
  });

  it('displays user data', () => {
    cy.contains('Welcome, Alice').should('be.visible');
  });

  it('allows user to logout', () => {
    cy.logout();
  });
});

Example: Playwright E2E test

// tests/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000');
  });

  test('allows user to log in with valid credentials', async ({ page }) => {
    // Mock API response
    await page.route('**/api/login', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          user: { id: 1, name: 'Alice', email: 'alice@example.com' },
          token: 'fake-jwt-token'
        })
      });
    });

    // Fill and submit form
    await page.fill('input[name="email"]', 'alice@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    // Verify redirect
    await expect(page).toHaveURL(/.*dashboard/);
    await expect(page.locator('text=Welcome, Alice')).toBeVisible();
  });

  test('handles network errors gracefully', async ({ page }) => {
    // Simulate network failure
    await page.route('**/api/login', (route) => route.abort());

    await page.fill('input[name="email"]', 'alice@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    // Verify error message
    await expect(page.locator('text=/network error/i')).toBeVisible();
  });
});

Example: Playwright with fixtures and page objects

// tests/fixtures.ts
import { test as base } from '@playwright/test';

// Create custom fixture for authenticated user
export const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('http://localhost:3000/login');
    await page.fill('input[name="email"]', 'alice@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await page.waitForURL('**/dashboard');
    await use(page);
  }
});

// tests/pages/DashboardPage.ts
export class DashboardPage {
  constructor(private page) {}

  async navigateToSettings() {
    await this.page.click('[data-testid="settings-link"]');
  }

  async getUserName() {
    return await this.page.locator('[data-testid="user-name"]').textContent();
  }

  async createNewProject(name: string) {
    await this.page.click('button:has-text("New Project")');
    await this.page.fill('input[name="project-name"]', name);
    await this.page.click('button:has-text("Create")');
  }
}

// tests/dashboard.spec.ts
import { test } from './fixtures';
import { DashboardPage } from './pages/DashboardPage';
import { expect } from '@playwright/test';

test('user can create new project', async ({ authenticatedPage }) => {
  const dashboard = new DashboardPage(authenticatedPage);
  
  await dashboard.createNewProject('My New Project');
  
  await expect(authenticatedPage.locator('text=My New Project')).toBeVisible();
});
E2E Testing Best Practices: Start server before tests, use baseURL configuration, avoid hard-coded waits (use auto-waiting), test critical user paths only, run in CI/CD pipeline, use parallel execution, implement proper cleanup, mock external services, use data-testid for stable selectors, organize tests by feature, maintain test independence.

React Testing Best Practices Summary