Skip to content

Testing Guide

Comprehensive testing strategy and guidelines for Constellation.

Testing Stack

  • Test Runner: Vitest 4.0.17
  • React Testing: @testing-library/react 16.3.1
  • User Interactions: @testing-library/user-event 14.6.1
  • API Mocking: MSW 2.12.7
  • Coverage: @vitest/coverage-v8 4.0.17

Running Tests

Development Mode

# Watch mode - re-run on file changes
bun test

# Watch mode with UI
bun test:ui

# Single run
bun test:run

# Generate coverage report
bun test:coverage

# Coverage for all files
bun test:coverage-all

Coverage Reports

View HTML coverage report:

bun test:coverage
open coverage/index.html

Test File Organization

src/
โ”œโ”€โ”€ components/
โ”‚   โ”œโ”€โ”€ ui/
โ”‚   โ”‚   โ”œโ”€โ”€ Button.tsx
โ”‚   โ”‚   โ””โ”€โ”€ Button.test.tsx
โ”‚   โ””โ”€โ”€ ...
โ”œโ”€โ”€ hooks/
โ”‚   โ”œโ”€โ”€ use-mobile.tsx
โ”‚   โ””โ”€โ”€ use-mobile.test.tsx
โ”œโ”€โ”€ lib/
โ”‚   โ”œโ”€โ”€ utils.ts
โ”‚   โ””โ”€โ”€ utils.test.ts
โ””โ”€โ”€ test/
    โ”œโ”€โ”€ mocks/
    โ”‚   โ”œโ”€โ”€ server.ts          # MSW server setup
    โ”‚   โ””โ”€โ”€ handlers.ts        # API handlers
    โ”œโ”€โ”€ test-utils.tsx         # Custom render
    โ””โ”€โ”€ test-helpers.ts        # Utilities

Unit Tests

Testing Functions

// src/lib/utils.ts
export function formatDate(date: Date, locale: string = 'en-US'): string {
  return date.toLocaleDateString(locale);
}

// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from './utils';

describe('formatDate', () => {
  it('should format date correctly', () => {
    const date = new Date('2024-01-15T00:00:00Z');
    const result = formatDate(date);
    expect(result).toContain('2024');
    expect(result).toContain('1');
    expect(result).toContain('15');
  });

  it('should use provided locale', () => {
    const date = new Date('2024-01-15');
    const result = formatDate(date, 'fr-FR');
    expect(result).toBeDefined();
  });

  it('should handle edge cases', () => {
    const date = new Date(0); // Unix epoch
    expect(() => formatDate(date)).not.toThrow();
  });
});

Testing Constants

// src/lib/constants.ts
export const MAX_ATTEMPTS = 3;
export const API_TIMEOUT = 30000;

// src/lib/constants.test.ts
import { describe, it, expect } from 'vitest';
import { MAX_ATTEMPTS, API_TIMEOUT } from './constants';

describe('Constants', () => {
  it('should have correct values', () => {
    expect(MAX_ATTEMPTS).toBe(3);
    expect(API_TIMEOUT).toBe(30000);
  });
});

Component Tests

Basic Component Test

// src/components/common/Button.tsx
export function Button({ onClick, children }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>;
}

// src/components/common/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button', () => {
  it('should render with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('should call onClick handler', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();

    render(<Button onClick={handleClick}>Click me</Button>);
    await user.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledOnce();
  });

  it('should be disabled when prop is set', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Testing with State

// src/components/Counter.tsx
export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

// src/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('should increment count', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    expect(screen.getByText('Count: 0')).toBeInTheDocument();

    const button = screen.getByRole('button', { name: /increment/i });
    await user.click(button);

    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  it('should increment multiple times', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    const button = screen.getByRole('button');
    await user.click(button);
    await user.click(button);
    await user.click(button);

    expect(screen.getByText('Count: 3')).toBeInTheDocument();
  });
});

Hook Tests

Testing Custom Hooks

// src/hooks/use-counter.ts
export function useCounter(initial: number = 0) {
  const [count, setCount] = useState(initial);

  return {
    count,
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1),
    reset: () => setCount(initial),
  };
}

// src/hooks/use-counter.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './use-counter';

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('should initialize with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('should increment', () => {
    const { result } = renderHook(() => useCounter());

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

    expect(result.current.count).toBe(1);
  });

  it('should decrement', () => {
    const { result } = renderHook(() => useCounter(5));

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

    expect(result.current.count).toBe(4);
  });

  it('should reset', () => {
    const { result } = renderHook(() => useCounter(5));

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

    expect(result.current.count).toBe(7);

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

    expect(result.current.count).toBe(5);
  });
});

Testing Hooks with Async Operations

// src/hooks/use-user-data.ts
export function useUserData(userId: string) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    (async () => {
      const data = await fetchUser(userId);
      setUser(data);
      setLoading(false);
    })();
  }, [userId]);

  return { user, loading };
}

// src/hooks/use-user-data.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useUserData } from './use-user-data';

// Mock the fetch function
vi.mock('@/src/services/userService', () => ({
  fetchUser: vi.fn(),
}));

describe('useUserData', () => {
  it('should load user data', async () => {
    vi.mocked(fetchUser).mockResolvedValueOnce({
      id: '1',
      name: 'John',
    });

    const { result } = renderHook(() => useUserData('1'));

    expect(result.current.loading).toBe(true);

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

    expect(result.current.user).toEqual({ id: '1', name: 'John' });
  });

  it('should handle different userId', async () => {
    vi.mocked(fetchUser).mockResolvedValueOnce({ id: '2', name: 'Jane' });

    const { result, rerender } = renderHook(
      ({ userId }) => useUserData(userId),
      { initialProps: { userId: '1' } }
    );

    rerender({ userId: '2' });

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

API Mocking with MSW

Setup MSW

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET user
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'John Doe',
      email: 'john@example.com',
    });
  }),

  // POST create workspace
  http.post('/api/workspaces', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: '123', ...body }, { status: 201 });
  }),

  // DELETE workspace
  http.delete('/api/workspaces/:id', ({ params }) => {
    return HttpResponse.json({ success: true });
  }),

  // Error handler
  http.get('/api/error', () => {
    return HttpResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }),
];

// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './src/test/mocks/server';

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

Testing API Calls

// src/components/UserProfile.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';

describe('UserProfile', () => {
  it('should fetch and display user', async () => {
    render(<UserProfile userId="123" />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });

    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });

  it('should handle API errors', async () => {
    server.use(
      http.get('/api/users/:id', () => {
        return HttpResponse.json(
          { error: 'User not found' },
          { status: 404 }
        );
      })
    );

    render(<UserProfile userId="999" />);

    await waitFor(() => {
      expect(screen.getByText(/not found/i)).toBeInTheDocument();
    });
  });
});

Testing Best Practices

Test Naming

// โœ… GOOD - Clear, descriptive names
describe('calculateDiscount', () => {
  it('should return 10% discount for premium users', () => {});
  it('should return 0% discount for new users', () => {});
  it('should throw error for invalid percentage', () => {});
});

// โŒ BAD - Vague names
describe('calculateDiscount', () => {
  it('works', () => {});
  it('test 1', () => {});
  it('error handling', () => {});
});

Arrange-Act-Assert Pattern

describe('UserForm', () => {
  it('should submit form with valid data', async () => {
    // Arrange
    const user = userEvent.setup();
    const handleSubmit = vi.fn();
    render(<UserForm onSubmit={handleSubmit} />);

    // Act
    await user.type(screen.getByLabelText('Name'), 'John');
    await user.click(screen.getByRole('button', { name: /submit/i }));

    // Assert
    expect(handleSubmit).toHaveBeenCalledWith({ name: 'John' });
  });
});

Test Isolation

// โœ… GOOD - Each test is independent
describe('TodoList', () => {
  it('should add todo', () => {
    render(<TodoList />);
    // Complete test
  });

  it('should delete todo', () => {
    render(<TodoList />); // Fresh render
    // Complete test
  });
});

// โŒ BAD - Tests depend on order
let todos = [];
describe('TodoList', () => {
  it('should add todo', () => {
    todos.push({ id: 1, text: 'Todo' });
  });

  it('should delete todo', () => {
    // Depends on previous test
    expect(todos.length).toBe(1);
  });
});

Coverage Goals

Statements: > 80%
Branches: > 75%
Functions: > 80%
Lines: > 80%

Avoid Anti-Patterns

// โŒ BAD - Testing implementation details
it('should call setState', () => {
  const setState = vi.fn();
  render(<Component setState={setState} />);
  // Testing setState directly
});

// โœ… GOOD - Testing user behavior
it('should display updated text', async () => {
  const user = userEvent.setup();
  render(<Component />);

  await user.type(input, 'text');
  expect(screen.getByText('text')).toBeInTheDocument();
});

Debugging Tests

Enable Debug Output

import { render, screen } from '@testing-library/react';

it('should render correctly', () => {
  const { debug } = render(<MyComponent />);

  // Print DOM tree
  debug();

  // Or specific element
  debug(screen.getByRole('button'));
});

Log Test State

import { screen } from '@testing-library/react';

it('should show data', async () => {
  render(<Component />);

  console.log('Rendered queries:', screen.queryAllByRole('button'));

  await waitFor(() => {
    console.log('After wait:', screen.getByText('Data'));
  });
});

Coverage Report

Run coverage to see what needs testing:

bun test:coverage-all

# View HTML report
open coverage/index.html

Common Testing Utilities

Custom Render

// src/test/test-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { Providers } from '@/src/components/context/Providers';

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: Providers, ...options });

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

Test Helpers

// src/test/test-helpers.ts
import { waitFor } from '@testing-library/react';

export async function waitForLoadingToFinish() {
  const loadingElements = document.querySelectorAll('[role="status"]');
  return Promise.all(
    Array.from(loadingElements).map(el =>
      waitFor(() => expect(el).not.toBeInTheDocument())
    )
  );
}

export function fillForm(values: Record<string, string>) {
  // Helper to fill out forms
}


Last Updated: January 2026