Skip to Content

Frontend Testing

This guide covers testing strategies and implementation for the Rhesis frontend application.

Testing Framework The frontend uses Jest as the test runner and React Testing Library for component testing, providing comprehensive unit testing capabilities.

Quick Start

Running Tests

test-commands.sh
# Navigate to frontend directory
cd apps/frontend

# Run all tests

npm test

# Run tests in watch mode

npm run test:watch

# Run tests with coverage

npm run test:coverage

# Run tests in CI mode

npm run test:ci

Equivalent make targets are also available from apps/frontend: make test runs npm test with --passWithNoTests --ci --watchAll=false; make test-coverage runs npm run test:ci (Jest with coverage reporters aligned with CI). ./rh test frontend (repo root) runs plain npm test in apps/frontend.

Coverage is collected (text, json-summary, lcov reporters) but there is no enforced coverage threshold in jest.config.js — a drop in coverage doesn’t fail the build.

End-to-end Testing (Playwright)

Changes under apps/frontend/** trigger [Test] Frontend E2E in CI. CI uses Docker for the Quick Start backend (make test-e2e-ci, Chromium only).

Locally without Docker (frontend + mocked API only):

e2e-local.sh
cd apps/frontend
npx playwright install chromium   # once
make test-e2e-local               # @mocked tests on http://localhost:3100

This starts a dedicated dev server on port 3100 (so it does not clash with npm run dev on 3000), seeds auth without a backend (E2E_NO_DOCKER=1), and runs Playwright route mocks for API data.

CI / full backend (requires Docker):

e2e-docker.sh
make test-e2e        # @sanity|@crud on Chromium + Firefox (local)
make test-e2e-ci     # @sanity|@crud on Chromium only — same as CI
make test-e2e-smoke  # @sanity only
make docker-down     # tear down stack

Debug: npm run test:e2e:ui or npm run test:e2e:headed. Config: playwright.config.ts; specs: tests/e2e/.

Testing Configuration

Jest Setup

The testing framework is configured in jest.config.js:

jest.config.js
const nextJest = require('next/jest')

const createJestConfig = nextJest({ dir: './' })

const customJestConfig = {
setupFiles: ['<rootDir>/jest.polyfills.js'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
coverageProvider: 'v8',
moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    // Every render() goes through src/test-utils.tsx instead of the raw
    // library, so tests automatically get QueryClientProvider + MUI theme.
    '^@testing-library/react$': '<rootDir>/src/test-utils.tsx',
},
collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '../../ee/frontend/src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
    '!src/app/layout.tsx',
    '!src/app/page.tsx',
    '!src/auth.ts',
],
}

module.exports = createJestConfig(customJestConfig)

There is no coverageThreshold — coverage is reported, not gated.

Test Environment Setup

The test environment is configured in jest.setup.js (abridged below — the real file also adds jest-axe, a next/navigation mock, and localStorage/sessionStorage mocks):

jest.setup.js
import "@testing-library/jest-dom";

// Set environment variables for tests
process.env.API_BASE_URL = "http://localhost:8080/api/v1";
window.__ENV__ = {
apiBaseUrl: "http://localhost:8080/api/v1",
};

// Mock Next.js router
jest.mock("next/router", () => ({
useRouter() {
    return {
      route: "/",
      pathname: "",
      query: {},
      asPath: "",
      push: jest.fn(),
      events: {
        on: jest.fn(),
        off: jest.fn(),
      },
      isFallback: false,
    };
},
}));

// Mock browser APIs
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
})),
});

global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));

Writing Tests

Component Testing

Use React Testing Library to test component behavior and user interactions:

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

describe('BaseDrawer', () => {
const mockOnClose = jest.fn();
const mockOnSave = jest.fn();

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

it('renders with open state', () => {
    render(
      <BaseDrawer open={true} onClose={mockOnClose} title="Test Drawer">
        <div>Test Content</div>
      </BaseDrawer>
    );

    expect(screen.getByText('Test Drawer')).toBeInTheDocument();
    expect(screen.getByText('Test Content')).toBeInTheDocument();
});

it('calls onClose when Cancel button is clicked', async () => {
    const user = userEvent.setup();

    render(
      <BaseDrawer open={true} onClose={mockOnClose} title="Test Drawer">
        <div>Test Content</div>
      </BaseDrawer>
    );

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

it('disables buttons when loading is true', () => {
    render(
      <BaseDrawer
        open={true}
        onClose={mockOnClose}
        onSave={mockOnSave}
        title="Test Drawer"
        loading={true}
      >
        <div>Test Content</div>
      </BaseDrawer>
    );

    expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
    expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
});
});

Hook Testing

renderHook/act come from @testing-library/react directly — there’s no separate @testing-library/react-hooks package. Hooks built on @tanstack/react-query mutations (e.g. useTasks) test the mutation functions, not a fetched-on-mount list:

useTasks.test.ts
import { renderHook, act } from "@testing-library/react";
import { useTasks } from "../useTasks";
import { TasksClient } from "@/utils/api-client/tasks-client";

jest.mock("@/utils/api-client/tasks-client");
const mockTasksClient = TasksClient as jest.Mocked<typeof TasksClient>;

describe("useTasks", () => {
beforeEach(() => {
    jest.clearAllMocks();
});

it("creates a task via the mutation", async () => {
    mockTasksClient.prototype.createTask.mockResolvedValue({ id: "1", name: "Test Task" });

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

    await act(async () => {
      await result.current.createTask({ name: "Test Task" });
    });

    expect(mockTasksClient.prototype.createTask).toHaveBeenCalledWith({ name: "Test Task" });
});
});

Utility Function Testing

Test utility functions with Jest — formatDate (src/utils/date.ts) takes an optional timezone, not a locale (it always formats with en-GB internally, and defaults to the runtime’s local timezone when none is given):

date.test.ts
import { formatDate } from "../date";

describe("formatDate", () => {
it("formats a date string in a given timezone", () => {
    expect(formatDate("2024-01-15T10:30:00Z", "UTC")).toBe("15 Jan 2024, 10:30");
});

it("returns N/A for an undefined date", () => {
    expect(formatDate(undefined)).toBe("N/A");
});
});

Test Utilities

Global Render Wrapper

jest.config.js remaps @testing-library/react itself to src/test-utils.tsx, so every render() call in the suite automatically gets QueryClientProvider and the MUI theme — no renderWithProviders helper is needed at each call site:

src/test-utils.tsx (simplified)
import { render as rtlRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, createTheme } from '@mui/material/styles';

function render(ui, options) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const Wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
    </QueryClientProvider>
);
return rtlRender(ui, { wrapper: Wrapper, ...options });
}

export * from '@testing-library/react';
export { render };

Separately, src/__mocks__/test-utils.tsx mocks next-auth/react and exports mock data factories (createMockProject, createMockTest, etc.) for building fixtures — it does not define a render wrapper itself.

Mock Implementations

Create mock implementations for API clients:

Mock API Client
// Mock API client
jest.mock("@/utils/api-client/tasks-client", () => ({
TasksClient: jest.fn().mockImplementation(() => ({
    getTasks: jest.fn(),
    createTask: jest.fn(),
    updateTask: jest.fn(),
    deleteTask: jest.fn(),
})),
}));

Testing Best Practices

Test Structure

Good Practices

// Test user behavior, not implementation
expect(screen.getByRole('button')).toBeInTheDocument();

// Use semantic queries
screen.getByRole('button', { name: /submit/i });

// Test error states
expect(screen.getByText(/error/i)).toBeInTheDocument();

Avoid These

// Don't test implementation details
expect(wrapper.find('.my-button')).toHaveLength(1);

// Avoid fragile queries
screen.getByClassName('button-submit');

// Don't test internal state
expect(component.state.isLoading).toBe(true);

Test Organization

Tests are co-located in a __tests__/ subfolder next to the code they cover (e.g. components/common/__tests__/BaseDrawer.test.tsx, utils/api-client/__tests__/...), plus a top-level src/__tests__/ for broader integration tests.

Integration with CI/CD

Local Validation Script

apps/frontend/scripts/validate.sh runs tests as part of a full local gate (format, type-check, lint, test, build). It is not wired to a git hook or invoked by CI — run it manually before opening a PR:

apps/frontend/scripts/validate.sh
npm test -- --passWithNoTests --watchAll=false
TEST_EXIT_CODE=$?

if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "Tests failed"
exit 1
fi

GitHub Actions

The unit-test workflow is .github/workflows/frontend-test.yml (“[Test] Frontend Unit”) — not frontend.yml, which is a build/deploy workflow with no test job:

.github/workflows/frontend-test.yml
test:
runs-on: ubuntu-latest
steps:
    - uses: actions/checkout@v6

    - uses: actions/setup-node@v6
      with:
        node-version: "24"
        cache: "npm"
        cache-dependency-path: "apps/frontend/package-lock.json"

    - name: Install dependencies
      run: npm ci --legacy-peer-deps
      working-directory: ./apps/frontend

    - name: Type check
      run: npm run type-check
      working-directory: ./apps/frontend

    - name: Run tests
      run: npm run test:ci
      working-directory: ./apps/frontend
      # Coverage summary is written to $GITHUB_STEP_SUMMARY — no Codecov upload

A separate community-boundary job in the same workflow checks for EE-import leaks into the community codebase.

Troubleshooting

Common Issues

Environment Variables Missing

# Add to jest.setup.js
process.env.API_BASE_URL = 'http://localhost:8080/api/v1';
window.__ENV__ = {
apiBaseUrl: 'http://localhost:8080/api/v1',
};

Browser APIs Not Available

# Mock in jest.setup.js
global.matchMedia = jest.fn().mockImplementation(query => ({ ... }));
global.ResizeObserver = jest.fn();

Async Operations Not Awaited

// Use act() for async operations
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});

Debug Tips

code.txt
# Run tests with verbose output
npm test -- --verbose

# Run specific test file
npm test BaseDrawer.test.tsx

# Run tests matching pattern
npm test -- --testNamePattern="should render"

# Debug failing test
npm test -- --detectOpenHandles