Skip to content

Frontend Testing

github-actions[bot] edited this page Oct 13, 2026 · 2 revisions

Canonical source: docs/09-testing/frontend-testing-guide.md — this page is rendered from it on every publish; edit the source.

Frontend Testing Guide

Vitest patterns, testing hooks, testing components, MSW mocking, and examples.


1. Configuration

Frontend tests use Vitest with jsdom environment:

  • Extension: *.test.ts / *.test.tsx
  • Location: __tests__/ directories adjacent to source
  • Config: vitest.config.ts in apps/claw-frontend/
  • Environment: jsdom (provides DOM APIs)
  • Path aliases: @/ maps to src/

File Structure

apps/claw-frontend/src/
  components/common/
    __tests__/
      empty-state.test.tsx
      loading-spinner.test.tsx
      page-header.test.tsx
      status-badge.test.tsx
  hooks/
    __tests__/
      use-auth-guard.test.ts
      use-debounce.test.ts
  lib/validation/
    __tests__/
      login.schema.test.ts
      connector.schema.test.ts
      memory.schema.test.ts
      routing.schema.test.ts
      # ... 8 schema test files
  utilities/
    __tests__/
      api.utility.test.ts
      cn.utility.test.ts
      preference.utility.test.ts
      sse.utility.test.ts
      string.utility.test.ts
      toast.utility.test.ts
  repositories/
    __tests__/
      auth.repository.test.ts
      query-keys.test.ts
  stores/
    __tests__/
      auth.store.test.ts
  enums/
    __tests__/
      enums.test.ts
  lib/i18n/
    __tests__/
      locale-context.test.ts
      translations.test.ts

2. Testing Components

Rendering Tests

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

describe('EmptyState', () => {
  it('should render the title and description', () => {
    render(<EmptyState title="No results" description="Try a different search" />);

    expect(screen.getByText('No results')).toBeDefined();
    expect(screen.getByText('Try a different search')).toBeDefined();
  });

  it('should render the action button when provided', () => {
    render(<EmptyState title="No items" action={{ label: 'Create', onClick: vi.fn() }} />);

    expect(screen.getByRole('button', { name: 'Create' })).toBeDefined();
  });

  it('should not render action button when not provided', () => {
    render(<EmptyState title="Empty" />);

    expect(screen.queryByRole('button')).toBeNull();
  });
});

Component Testing Rules

  1. Test rendering behavior, not implementation details
  2. Test that correct content appears based on props
  3. Test loading, empty, and error states
  4. Test user interactions (click, type, submit)
  5. Do not test internal state -- test visible behavior

3. Testing Hooks

Use renderHook from @testing-library/react:

import { renderHook, act } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

describe('useDebounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

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

  it('should return initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('hello', 300));
    expect(result.current).toBe('hello');
  });

  it('should debounce value updates', () => {
    const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
      initialProps: { value: 'hello' },
    });

    rerender({ value: 'world' });
    expect(result.current).toBe('hello'); // Not yet updated

    act(() => vi.advanceTimersByTime(300));
    expect(result.current).toBe('world'); // Now updated
  });
});

Testing Auth Guard Hook

describe('useAuthGuard', () => {
  it('should redirect to login when not authenticated', () => {
    // Mock useAuthStore to return unauthenticated state
    vi.mocked(useAuthStore).mockReturnValue({
      isAuthenticated: false,
      user: null,
    });

    const mockRouter = { push: vi.fn() };
    vi.mocked(useRouter).mockReturnValue(mockRouter);

    renderHook(() => useAuthGuard());

    expect(mockRouter.push).toHaveBeenCalledWith('/login');
  });
});

4. Testing Validation Schemas

import { describe, expect, it } from 'vitest';
import { loginSchema } from '../login.schema';

describe('loginSchema', () => {
  it('should accept valid credentials', () => {
    const result = loginSchema.safeParse({
      email: 'user@example.com',
      password: 'password123',
    });
    expect(result.success).toBe(true);
  });

  it('should reject invalid email', () => {
    const result = loginSchema.safeParse({
      email: 'not-an-email',
      password: 'password123',
    });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].path).toContain('email');
    }
  });

  it('should reject empty password', () => {
    const result = loginSchema.safeParse({
      email: 'user@example.com',
      password: '',
    });
    expect(result.success).toBe(false);
  });

  it('should reject missing fields', () => {
    const result = loginSchema.safeParse({});
    expect(result.success).toBe(false);
    expect(result.error?.issues.length).toBeGreaterThanOrEqual(2);
  });
});

5. Testing Utilities

import { describe, expect, it } from 'vitest';
import { cn } from '../cn.utility';

describe('cn', () => {
  it('should merge class names', () => {
    expect(cn('foo', 'bar')).toBe('foo bar');
  });

  it('should handle conditional classes', () => {
    expect(cn('base', true && 'active', false && 'hidden')).toBe('base active');
  });

  it('should resolve Tailwind conflicts', () => {
    expect(cn('p-4', 'p-8')).toBe('p-8');
  });

  it('should handle undefined and null', () => {
    expect(cn('base', undefined, null)).toBe('base');
  });
});

6. Testing Stores (Zustand)

import { describe, expect, it, beforeEach } from 'vitest';
import { useAuthStore } from '../auth.store';

describe('authStore', () => {
  beforeEach(() => {
    useAuthStore.getState().clearAuth();
  });

  it('should have correct initial state', () => {
    const state = useAuthStore.getState();
    expect(state.isAuthenticated).toBe(false);
    expect(state.user).toBeNull();
    expect(state.accessToken).toBeNull();
  });

  it('should set auth data correctly', () => {
    useAuthStore.getState().setAuth({
      accessToken: 'token-123',
      refreshToken: 'refresh-456',
      user: { id: 'user-1', email: 'test@test.com', role: 'ADMIN' },
    });

    const state = useAuthStore.getState();
    expect(state.isAuthenticated).toBe(true);
    expect(state.accessToken).toBe('token-123');
    expect(state.user?.email).toBe('test@test.com');
  });

  it('should clear auth data', () => {
    useAuthStore.getState().setAuth({
      accessToken: 'token',
      refreshToken: 'refresh',
      user: { id: 'user-1' },
    });

    useAuthStore.getState().clearAuth();

    const state = useAuthStore.getState();
    expect(state.isAuthenticated).toBe(false);
    expect(state.user).toBeNull();
  });
});

7. Testing Repositories

import { describe, expect, it, vi, beforeEach } from 'vitest';
import { authRepository } from '../auth.repository';

// Mock the HTTP client
vi.mock('@/lib/http-client', () => ({
  httpClient: {
    post: vi.fn(),
    get: vi.fn(),
  },
}));

describe('authRepository', () => {
  it('should call login endpoint with correct data', async () => {
    const mockResponse = {
      data: { accessToken: 'token', refreshToken: 'refresh', user: {} },
    };
    vi.mocked(httpClient.post).mockResolvedValue(mockResponse);

    await authRepository.login({ email: 'test@test.com', password: 'pass' });

    expect(httpClient.post).toHaveBeenCalledWith('/auth/login', {
      email: 'test@test.com',
      password: 'pass',
    });
  });
});

8. Testing i18n

Translation Completeness Test

import { describe, expect, it } from 'vitest';
import { en } from '../locales/en';
import { ar } from '../locales/ar';
import { de } from '../locales/de';
// ... all locales

describe('Translation completeness', () => {
  const locales = { ar, de, es, fr, hi, it, pt, ru };

  function getKeys(obj: Record<string, unknown>, prefix = ''): string[] {
    return Object.entries(obj).flatMap(([key, value]) => {
      const fullKey = prefix ? `${prefix}.${key}` : key;
      if (typeof value === 'object' && value !== null) {
        return getKeys(value as Record<string, unknown>, fullKey);
      }
      return [fullKey];
    });
  }

  const enKeys = getKeys(en);

  for (const [name, locale] of Object.entries(locales)) {
    it(`${name} should have all English keys`, () => {
      const localeKeys = getKeys(locale);
      for (const key of enKeys) {
        expect(localeKeys).toContain(key);
      }
    });
  }
});

9. Mocking Patterns

Next.js Router

vi.mock('next/navigation', () => ({
  useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
  useParams: () => ({ threadId: 'test-id' }),
  useSearchParams: () => new URLSearchParams(),
}));

i18n

vi.mock('@/lib/i18n', () => ({
  useTranslation: () => ({ t: (key: string) => key }),
}));

Browser APIs

// localStorage
const mockStorage: Record<string, string> = {};
vi.stubGlobal('localStorage', {
  getItem: (key: string) => mockStorage[key] ?? null,
  setItem: (key: string, value: string) => {
    mockStorage[key] = value;
  },
  removeItem: (key: string) => {
    delete mockStorage[key];
  },
});

// matchMedia
vi.stubGlobal('matchMedia', (query: string) => ({
  matches: false,
  media: query,
  addEventListener: vi.fn(),
  removeEventListener: vi.fn(),
}));

10. Running Frontend Tests

# All frontend tests
npm run test --workspace=apps/claw-frontend

# Watch mode (default in Vitest)
cd apps/claw-frontend && npx vitest

# Run once
cd apps/claw-frontend && npx vitest run

# Single file
cd apps/claw-frontend && npx vitest src/utilities/__tests__/string.utility.test.ts

# Coverage
cd apps/claw-frontend && npx vitest --coverage

# UI mode
cd apps/claw-frontend && npx vitest --ui

Known Issues

  • Frontend tests may fail on the host with Node.js v24+ due to rollup native binary issues. Run inside Docker or use the vitest process cache.

Clone this wiki locally