Skip to content

Frontend Testing Standards

Mathew Storm edited this page Feb 12, 2026 · 1 revision

Code Testing for Frontend

Every test we write is a promise to students that our platform works — even on a 2G connection, even on a 5-year-old phone.

Testing Stack

Tool Purpose
Vitest Test runner (fast, Vite-native, Jest-compatible API)
React Testing Library Component rendering and DOM queries
@testing-library/user-event Simulating realistic user interactions
@testing-library/jest-dom Custom DOM matchers (toBeInTheDocument, toHaveClass, etc.)
axios-mock-adapter Mocking HTTP requests in API service tests
jsdom Browser environment simulation

Quick Start

# Run all tests once
npm run test

# Watch mode (re-runs on file changes)
npm run test:watch

# Interactive UI
npm run test:ui

# With coverage report
npm run test:coverage

Configuration

Tests are configured in vite.config.js:

// vite.config.js
export default defineConfig({
  // ...
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
    pool: 'forks',
    poolOptions: {
      forks: {
        singleFork: true,
      },
    },
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.test.{ts,tsx,js,jsx}',
        '**/*.spec.{ts,tsx,js,jsx}',
      ],
    },
  },
});

Setup File

src/test/setup.ts runs before every test file:

import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';

// Extend Vitest's expect with jest-dom matchers
expect.extend(matchers);

// Cleanup after each test
afterEach(() => {
  cleanup();
});

Test Utilities

src/test/utils.tsx provides a renderWithProviders function that wraps components with Router, Auth, and i18n contexts:

import { renderWithProviders } from '@/test/utils';

// Use instead of plain render() when your component needs Router, Auth, or i18n
renderWithProviders(<MyComponent />);

Test File Organization

Test files live in __tests__/ folders next to the code they test:

src/
├── components/
│   └── common/
│       ├── Button.tsx
│       └── __tests__/
│           ├── Button.test.tsx        # Standard tests
│           └── Button.edge.test.tsx   # Edge cases & security
├── hooks/
│   ├── useCourses.ts
│   └── __tests__/
│       └── useCourses.test.ts
├── services/
│   ├── coursesApi.ts
│   └── __tests__/
│       └── coursesApi.test.ts
└── pages/
    ├── LoginPage.tsx
    └── __tests__/
        ├── LoginPage.test.tsx
        └── LoginPage.integration.test.tsx

Naming Conventions

Pattern Use For
ComponentName.test.tsx Standard component tests
ComponentName.edge.test.tsx Edge cases and security testing
ComponentName.integration.test.tsx Integration tests (multiple components together)
serviceName.test.ts API service tests
useHookName.test.ts Custom hook tests

Writing Tests

Component Tests

Use renderWithProviders for components that need Router/Auth/i18n context, plain render for isolated components.

import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen } from "@/test/utils";
import userEvent from "@testing-library/user-event";
import Button from "../Button";

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

  it("calls onClick when clicked", async () => {
    const handleClick = vi.fn();
    renderWithProviders(<Button onClick={handleClick}>Click</Button>);

    await userEvent.click(screen.getByRole("button"));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it("is disabled when disabled prop is true", () => {
    renderWithProviders(<Button disabled>Disabled</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

API Service Tests

Use axios-mock-adapter to mock HTTP requests. Follow the pattern in slideshowApi.test.ts and coursesApi.test.ts.

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { listCourses } from "../coursesApi";

describe("coursesApi", () => {
  let mock: MockAdapter;

  beforeEach(() => {
    mock = new MockAdapter(axios);
  });

  afterEach(() => {
    mock.restore();
  });

  describe("listCourses", () => {
    it("should fetch paginated courses", async () => {
      const mockResponse = {
        count: 2,
        next: null,
        previous: null,
        total_pages: 1,
        current_page: 1,
        page_size: 20,
        results: [
          { id: 1, title: "Course 1" },
          { id: 2, title: "Course 2" },
        ],
      };

      mock
        .onGet("http://localhost:8000/api/courses/")
        .reply(200, mockResponse);

      const result = await listCourses();
      expect(result).toEqual(mockResponse);
      expect(result.results).toHaveLength(2);
    });

    it("should handle server errors", async () => {
      mock
        .onGet("http://localhost:8000/api/courses/")
        .reply(500, { detail: "Server error" });

      await expect(listCourses()).rejects.toThrow();
    });
  });
});

Key patterns:

  • Create a new MockAdapter in beforeEach, restore in afterEach
  • Match the exact URL including base (http://localhost:8000/api/...)
  • Test both success and error cases
  • Test different HTTP status codes (400, 403, 404, 409, 500)

Custom Hook Tests

Use renderHook from React Testing Library and vi.mock to mock the API service.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useCourses } from "../useCourses";
import * as coursesApi from "../../services/coursesApi";

// Mock the entire service module
vi.mock("../../services/coursesApi");

describe("useCourses", () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  it("should start in loading state", () => {
    vi.mocked(coursesApi.listCourses).mockImplementation(
      () => new Promise(() => {}), // Never resolves
    );

    const { result } = renderHook(() => useCourses());

    expect(result.current.loading).toBe(true);
    expect(result.current.error).toBeNull();
  });

  it("should fetch data on mount", async () => {
    const mockData = { count: 1, results: [{ id: 1, title: "Test" }] };
    vi.mocked(coursesApi.listCourses).mockResolvedValue(mockData);

    const { result } = renderHook(() => useCourses());

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.courses).toEqual(mockData);
  });

  it("should handle errors", async () => {
    vi.mocked(coursesApi.listCourses).mockRejectedValue(
      new Error("Network error"),
    );

    const { result } = renderHook(() => useCourses());

    await waitFor(() => {
      expect(result.current.error).toBe("Network error");
    });
  });

  it("should refetch when refetch is called", async () => {
    vi.mocked(coursesApi.listCourses).mockResolvedValue(mockData);

    const { result } = renderHook(() => useCourses());

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    vi.mocked(coursesApi.listCourses).mockResolvedValue(updatedData);

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

    await waitFor(() => {
      expect(result.current.courses).toEqual(updatedData);
    });
  });

  it("should reload when props change", async () => {
    const { result, rerender } = renderHook(
      ({ id }) => useCourseDetail(id),
      { initialProps: { id: 1 } },
    );

    await waitFor(() => {
      expect(result.current.course?.id).toBe(1);
    });

    rerender({ id: 2 });

    await waitFor(() => {
      expect(result.current.course?.id).toBe(2);
    });
  });
});

Key patterns:

  • vi.mock("../../services/moduleName") at the top level to mock the service
  • vi.resetAllMocks() in beforeEach to ensure clean state
  • vi.mocked(fn).mockResolvedValue(data) for success cases
  • vi.mocked(fn).mockRejectedValue(error) for error cases
  • waitFor() to wait for async state updates
  • act() to wrap actions that trigger state changes (like refetch)
  • renderHook with rerender to test prop/param changes

What to Test

For Components

  • Renders correctly with default and custom props
  • User interactions (clicks, typing, form submission)
  • Conditional rendering (loading, error, empty states)
  • Accessibility (ARIA attributes, keyboard navigation)
  • Edge cases (empty strings, long text, special characters)

For API Services

  • Successful requests return expected data
  • Filter/query parameters are passed correctly
  • Error responses throw with safe error messages
  • Different HTTP error codes (400, 403, 404, 409, 500)

For Custom Hooks

  • Initial loading state
  • Data fetching on mount
  • Error handling (Error objects and non-Error rejections)
  • Refetch functionality
  • Re-fetching when params/IDs change
  • Cleanup on unmount (no state updates after unmount)

What NOT to Test

  • Third-party library internals (axios, react-router, etc.)
  • Implementation details (internal state values, private functions)
  • Exact CSS classes (test behavior, not styling — unless it's a style variant)
  • console.log output (unless it's part of error handling)

Running Tests Before PRs

Before submitting a PR, run the full test suite:

# All tests must pass
npm run test

# TypeScript must compile
npm run type-check

# No linting errors
npm run lint

# Build must succeed
npx vite build

Resources

Clone this wiki locally