-
Notifications
You must be signed in to change notification settings - Fork 19
Frontend Testing Standards
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.
| 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 |
# 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:coverageTests 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}',
],
},
},
});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();
});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 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
| 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 |
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();
});
});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
MockAdapterinbeforeEach, restore inafterEach - 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)
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()inbeforeEachto 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 (likerefetch) -
renderHookwithrerenderto test prop/param changes
- 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)
- 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)
- 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)
- 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.logoutput (unless it's part of error handling)
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