Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing React Apps

Cài đặt

# Với Vite
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

# Hoặc với Create React App (Jest có sẵn)
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
  },
});

// src/test/setup.ts
import '@testing-library/jest-dom';

React Testing Library - Nguyên tắc

“Test your components the way users use them.”

// ✅ Query theo những gì user thấy
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
screen.getByText('Welcome, Alice!');
screen.getByPlaceholderText('Search...');

// ✅ Khi không có accessible text
screen.getByTestId('loading-spinner');

// ❌ Tránh query theo implementation details
screen.getByClassName('btn-primary');   // Fragile
screen.getByAttribute('data-id', '42'); // Fragile

Unit Testing Components

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

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();

    render(<Button onClick={handleClick}>Submit</Button>);

    await user.click(screen.getByRole('button', { name: /submit/i }));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Submit</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('does not call onClick when disabled', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();

    render(<Button disabled onClick={handleClick}>Submit</Button>);
    await user.click(screen.getByRole('button'));

    expect(handleClick).not.toHaveBeenCalled();
  });
});

Testing Forms

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

describe('LoginForm', () => {
  const user = userEvent.setup();

  it('shows validation errors for empty submit', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.click(screen.getByRole('button', { name: /login/i }));

    expect(await screen.findByText('Email is required')).toBeInTheDocument();
    expect(screen.getByText('Password is required')).toBeInTheDocument();
  });

  it('shows error for invalid email', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText(/email/i), 'not-an-email');
    await user.click(screen.getByRole('button', { name: /login/i }));

    expect(await screen.findByText('Invalid email format')).toBeInTheDocument();
  });

  it('calls onSubmit with correct data when valid', async () => {
    const handleSubmit = vi.fn().mockResolvedValue(undefined);
    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'securepass123');
    await user.click(screen.getByRole('button', { name: /login/i }));

    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        email: 'user@example.com',
        password: 'securepass123',
      });
    });
  });
});

Testing Async Components

// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { UserProfile } from './UserProfile';

// Mock API với MSW (Mock Service Worker)
const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    if (params.id === '1') {
      return HttpResponse.json({
        id: 1,
        name: 'Alice Johnson',
        email: 'alice@example.com',
      });
    }
    return new HttpResponse(null, { status: 404 });
  })
);

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

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    render(<UserProfile userId={1} />);
    expect(screen.getByRole('progressbar')).toBeInTheDocument();
    // hoặc
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('displays user data after loading', async () => {
    render(<UserProfile userId={1} />);

    expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });

  it('shows error message when user not found', async () => {
    render(<UserProfile userId={999} />);

    expect(await screen.findByRole('alert')).toBeInTheDocument();
    expect(screen.getByText(/not found|error/i)).toBeInTheDocument();
  });

  it('retries on server error', async () => {
    server.use(
      http.get('/api/users/1', () =>
        new HttpResponse(null, { status: 500 })
      )
    );

    render(<UserProfile userId={1} />);
    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });
});

Testing Custom Hooks

// 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 provided value', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('resets count', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.reset();
    });

    expect(result.current.count).toBe(0);
  });
});

// useLocalStorage.test.ts
describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('returns initial value when no stored value', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'default'));
    expect(result.current[0]).toBe('default');
  });

  it('persists value to localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('key', ''));

    act(() => {
      result.current[1]('new value');
    });

    expect(localStorage.getItem('key')).toBe('"new value"');
    expect(result.current[0]).toBe('new value');
  });
});

Testing với Context

// Tạo custom render wrapper
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,     // Không retry trong test
        gcTime: 0,
      },
    },
  });
}

interface RenderOptions {
  initialRoute?: string;
  initialUser?: User;
}

function renderWithProviders(
  ui: React.ReactElement,
  { initialRoute = '/', initialUser }: RenderOptions = {}
) {
  const queryClient = createTestQueryClient();

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider initialUser={initialUser}>
          <MemoryRouter initialEntries={[initialRoute]}>
            {children}
          </MemoryRouter>
        </AuthProvider>
      </QueryClientProvider>
    );
  }

  return render(ui, { wrapper: Wrapper });
}

// Test với context
describe('ProtectedRoute', () => {
  it('redirects to login when not authenticated', () => {
    renderWithProviders(<ProtectedPage />, { initialRoute: '/dashboard' });
    expect(screen.getByText(/login/i)).toBeInTheDocument();
  });

  it('renders content when authenticated', () => {
    renderWithProviders(<ProtectedPage />, {
      initialUser: { id: 1, name: 'Alice', roles: ['user'] },
    });
    expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
  });
});