Initial Release help-frontend

Dr. Frontend

Testing - Component Level

Unit and integration tests for React components using React Testing Library and Vitest.

Quick Reference

Run tests:npm run test
Watch mode:npm run test:watch
Coverage:npm run test:coverage

Core Principles

✓ Do

  • Test user-visible behavior
  • Use semantic queries (getByRole, getByLabelText)
  • Test accessibility with each component
  • Mock external dependencies
  • Write tests that resemble how users interact

✗ Don't

  • Test implementation details
  • Use getByTestId as first choice
  • Test internal state directly
  • Snapshot test everything
  • Write tests that break on refactors

Query Priority

Use queries in this order of preference (most accessible first):

PriorityQueryUse Case
1stgetByRoleButtons, links, headings, form elements
2ndgetByLabelTextForm inputs with labels
3rdgetByPlaceholderTextInputs without visible labels
4thgetByTextNon-interactive text content
LastgetByTestIdOnly when nothing else works

Example: StandaloneDashboard Tests

Complete test file for the StandaloneDashboard component demonstrating best practices.

src/components/examples/__tests__/StandaloneDashboard.test.tsxView on GitHub →
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { StandaloneDashboard } from '../StandaloneDashboard';

describe('StandaloneDashboard', () => {
  // ===========================================================================
  // RENDERING TESTS
  // ===========================================================================
  
  describe('rendering', () => {
    it('renders the dashboard header', () => {
      // Arrange & Act
      render(<StandaloneDashboard />);
      
      // Assert
      expect(screen.getByRole('heading', { name: /research dashboard/i }))
        .toBeInTheDocument();
    });

    it('renders stats cards with correct values', () => {
      render(<StandaloneDashboard />);
      
      expect(screen.getByText('Active Experiments')).toBeInTheDocument();
      expect(screen.getByText('12')).toBeInTheDocument();
    });

    it('renders task queue with all status sections', () => {
      render(<StandaloneDashboard />);
      
      expect(screen.getByText('Requires Your Action')).toBeInTheDocument();
      expect(screen.getByText('In Progress')).toBeInTheDocument();
      expect(screen.getByText('Blocked')).toBeInTheDocument();
    });
  });

  // ===========================================================================
  // STATUS BADGE TESTS
  // ===========================================================================

  describe('status badges', () => {
    it('displays correct status badges for tasks', () => {
      render(<StandaloneDashboard />);
      
      const doneButtons = screen.getAllByText('Done');
      expect(doneButtons.length).toBeGreaterThan(0);
    });

    it('shows blocked reason for blocked tasks', () => {
      render(<StandaloneDashboard />);
      
      expect(screen.getByText(/waiting for structural analysis/i))
        .toBeInTheDocument();
    });
  });

  // ===========================================================================
  // INTERACTION TESTS
  // ===========================================================================

  describe('interactions', () => {
    it('refresh button is clickable', async () => {
      const user = userEvent.setup();
      render(<StandaloneDashboard />);
      
      const refreshButton = screen.getByRole('button', { name: /refresh/i });
      await user.click(refreshButton);
      
      expect(refreshButton).toBeInTheDocument();
    });

    it('checkboxes are rendered for tasks', () => {
      render(<StandaloneDashboard />);
      
      const checkboxes = screen.getAllByRole('checkbox');
      expect(checkboxes.length).toBeGreaterThan(0);
    });
  });

  // ===========================================================================
  // ACCESSIBILITY TESTS
  // ===========================================================================

  describe('accessibility', () => {
    it('has proper heading hierarchy', () => {
      render(<StandaloneDashboard />);
      
      const h1 = screen.getByRole('heading', { level: 1 });
      expect(h1).toHaveTextContent(/research dashboard/i);
    });

    it('buttons have accessible names', () => {
      render(<StandaloneDashboard />);
      
      const buttons = screen.getAllByRole('button');
      buttons.forEach(button => {
        expect(button).toHaveAccessibleName();
      });
    });
  });
});

Common Test Patterns

Pattern: Arrange → Act → Assert

it('submits form with user input', async () => {
  // Arrange
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);
  
  // Act
  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'password123');
  await user.click(screen.getByRole('button', { name: /sign in/i }));
  
  // Assert
  expect(onSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

Pattern: Async Operations

it('loads and displays data', async () => {
  render(<DataList />);
  
  // Wait for loading to complete
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  
  // Wait for data to appear
  await screen.findByText('First Item');
  
  // Assert data is displayed
  expect(screen.getByText('First Item')).toBeInTheDocument();
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

Pattern: User Interactions

it('toggles dropdown on click', async () => {
  const user = userEvent.setup();
  render(<Dropdown options={['A', 'B', 'C']} />);
  
  // Initially closed
  expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
  
  // Open dropdown
  await user.click(screen.getByRole('button', { name: /select/i }));
  expect(screen.getByRole('listbox')).toBeVisible();
  
  // Select option
  await user.click(screen.getByRole('option', { name: 'B' }));
  expect(screen.getByRole('button')).toHaveTextContent('B');
});

Pattern: Mocking with MSW

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/experiments', () => {
    return HttpResponse.json([
      { id: 1, name: 'Experiment A' },
      { id: 2, name: 'Experiment B' },
    ]);
  })
);

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

it('displays experiments from API', async () => {
  render(<ExperimentList />);
  
  await screen.findByText('Experiment A');
  expect(screen.getByText('Experiment B')).toBeInTheDocument();
});

it('handles API errors', async () => {
  server.use(
    http.get('/api/experiments', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );
  
  render(<ExperimentList />);
  
  await screen.findByText(/error loading/i);
});

Dependencies

npm install -D vitest @testing-library/react @testing-library/user-event
npm install -D @testing-library/jest-dom jsdom
npm install -D msw  # For API mocking

Vitest Configuration

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

Test Setup File

// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});