Testing

Testing setup and strategies using Vitest

Testing

ShipKit uses Vitest as its testing framework, with support for unit tests, integration tests, and end-to-end testing.

Overview

The testing system provides:

  • Unit testing with Vitest
  • Component testing with Testing Library
  • End-to-end testing with Playwright
  • Coverage reporting
  • Mock utilities
  • Test helpers

Test Setup

Vitest Configuration

// vitest.config.ts
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/test/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      exclude: [
        "node_modules/**",
        "src/test/**",
        "**/*.d.ts",
        "**/*.config.ts",
        "**/types/**",
      ],
    },
  },
});

Test Environment Setup

// src/test/setup.ts
import "@testing-library/jest-dom";
import { TextDecoder, TextEncoder } from "util";
import { vi } from "vitest";

// Mock fetch
global.fetch = vi.fn();

// Mock TextEncoder/TextDecoder
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;

// Mock NextAuth session
vi.mock("next-auth", () => ({
  auth: vi.fn(() =>
    Promise.resolve({
      user: {
        id: "test-user-id",
        name: "Test User",
        email: "test@example.com",
      },
    })
  ),
}));

// Mock next/navigation
vi.mock("next/navigation", () => ({
  redirect: vi.fn(),
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    refresh: vi.fn(),
  }),
  useSearchParams: () => ({
    get: vi.fn(),
  }),
}));

// Clean up after each test
afterEach(() => {
  vi.clearAllMocks();
});

Writing Tests

Unit Tests

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

describe('formatDate', () => {
  it('formats date correctly', () => {
    const date = new Date('2024-01-01');
    expect(formatDate(date)).toBe('January 1, 2024');
  });

  it('handles invalid dates', () => {
    expect(formatDate(null)).toBe('');
  });
});

Component Tests

// src/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

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

  it('handles click events', () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click me</Button>);
    fireEvent.click(screen.getByText('Click me'));
    expect(onClick).toHaveBeenCalled();
  });
});

API Tests

// src/server/actions/auth.test.ts
import { describe, it, expect, vi } from 'vitest';
import { login } from './auth';

describe('login', () => {
  it('authenticates user successfully', async () => {
    const mockAuth = vi.fn().mockResolvedValue({
      user: { id: '123', email: 'test@example.com' }
    });
    vi.mock('next-auth', () => ({ auth: mockAuth }));

    const result = await login({ email: 'test@example.com' });
    expect(result.success).toBe(true);
  });

  it('handles authentication errors', async () => {
    const mockAuth = vi.fn().mockRejectedValue(new Error('Auth failed'));
    vi.mock('next-auth', () => ({ auth: mockAuth }));

    const result = await login({ email: 'test@example.com' });
    expect(result.success).toBe(false);
  });
});

Test Organization

Directory Structure

src/
├── test/
│   ├── setup.ts           # Test setup and mocks
│   ├── helpers/           # Test helper functions
│   └── fixtures/          # Test data fixtures
├── __tests__/            # Test files (co-located with source)
└── components/
    └── __tests__/        # Component tests

Naming Conventions

  1. Test Files

    • Unit tests: *.test.ts
    • Component tests: *.test.tsx
    • Integration tests: *.integration.test.ts
    • E2E tests: *.e2e.test.ts
  2. Test Suites

    • Group by feature/component
    • Use descriptive names
    • Follow naming pattern

Testing Utilities

Mock Functions

// Mock API calls
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: async () => ({ data: 'test' }),
});
global.fetch = mockFetch;

// Mock modules
vi.mock('@/lib/api', () => ({
  fetchData: vi.fn().mockResolvedValue({ data: 'test' }),
}));

Test Helpers

// src/test/helpers/render.tsx
import { render } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';

export function renderWithProviders(ui: React.ReactElement) {
  return render(
    <ThemeProvider>{ui}</ThemeProvider>
  );
}

Running Tests

Test Commands

# Run all tests
pnpm test

# Watch mode
pnpm test:watch

# Coverage report
pnpm test:coverage

# Run specific tests
pnpm test path/to/test.ts

# Run with pattern
pnpm test --testNamePattern="button"

Coverage Reports

# Generate coverage report
pnpm test:coverage

# View report
open coverage/index.html

Best Practices

  1. Test Organization

    • Group related tests
    • Use descriptive names
    • Follow consistent patterns
    • Maintain test isolation
  2. Test Quality

    • Write focused tests
    • Test edge cases
    • Avoid test interdependence
    • Keep tests maintainable
  3. Mocking

    • Mock external dependencies
    • Use realistic test data
    • Clean up after tests
    • Document mock behavior
  4. Performance

    • Run tests in parallel
    • Mock heavy operations
    • Use test filtering
    • Optimize setup/teardown

Common Patterns

Testing Hooks

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('useCounter', () => {
  const { result } = renderHook(() => useCounter());

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

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

Testing Forms

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

test('LoginForm submission', async () => {
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  fireEvent.change(screen.getByLabelText('Email'), {
    target: { value: 'test@example.com' },
  });

  fireEvent.click(screen.getByText('Submit'));

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
  });
});

Testing API Calls

import { server } from '@/test/server';
import { rest } from 'msw';

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

test('API call', async () => {
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(ctx.json({ data: 'test' }));
    })
  );

  const result = await fetchData();
  expect(result).toEqual({ data: 'test' });
});

Notes

  • Tests run in JSDOM environment
  • Coverage thresholds enforced
  • Mocks are automatically reset
  • Tests run in parallel
  • Watch mode available
  • Coverage reports generated