GameCraftGameCraft

Testing Guide

Write tests with Vitest - unit tests, integration tests, and best practices for ProductReady

Testing Guide

ProductReady uses Vitest for fast, modern testing. Learn how to write unit tests, integration tests, and test tRPC endpoints.

Why Vitest? Lightning-fast test execution, ESM-first, TypeScript support out of the box, and compatible with Jest APIs.


Quick Overview

Testing stack:

  • Vitest - Test runner and framework
  • Testing Library - React component testing
  • MSW - API mocking (if needed)
  • Playwright - E2E tests (optional)

What to test:

  • ✅ tRPC routers (business logic)
  • ✅ Database queries
  • ✅ React components
  • ✅ Utility functions
  • ✅ API integrations

Installation

Already included in ProductReady! Check package.json:

{
  "devDependencies": {
    "vitest": "^2.0.0",
    "@testing-library/react": "^14.0.0",
    "@testing-library/user-event": "^14.0.0",
    "@vitejs/plugin-react": "^4.0.0"
  }
}

Running Tests

# Run all tests
pnpm test

# Watch mode (re-run on file changes)
pnpm test:watch

# Run specific test file
pnpm test src/server/routers/posts.test.ts

# Coverage report
pnpm test:coverage

# UI mode (visual test runner)
pnpm test:ui

Configuration

Vitest is configured in vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

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

Test Structure

Basic Test

// 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('2025-01-15');
    expect(formatDate(date)).toBe('Jan 15, 2025');
  });

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

Grouping Tests

describe('User utilities', () => {
  describe('validateEmail', () => {
    it('accepts valid emails', () => {
      expect(validateEmail('user@example.com')).toBe(true);
    });

    it('rejects invalid emails', () => {
      expect(validateEmail('not-an-email')).toBe(false);
    });
  });

  describe('formatUsername', () => {
    it('capitalizes first letter', () => {
      expect(formatUsername('john')).toBe('John');
    });
  });
});

Testing tRPC Routers

Setup Test Caller

// src/test/helpers.ts
import { appRouter } from '~/server/routers/_app';
import { createInnerTRPCContext } from '~/server/trpc';
import type { User } from '~/db/schema';

export function createTestCaller(user?: User) {
  const ctx = createInnerTRPCContext({
    session: user ? { user } : null,
  });

  return appRouter.createCaller(ctx);
}

Test Example

// src/server/routers/posts.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTestCaller } from '~/test/helpers';
import { db } from '~/db';
import { posts, users } from '~/db/schema';

describe('posts router', () => {
  let testUser: User;
  let caller: ReturnType<typeof createTestCaller>;

  beforeEach(async () => {
    // Create test user
    [testUser] = await db.insert(users).values({
      email: 'test@example.com',
      name: 'Test User',
    }).returning();

    caller = createTestCaller(testUser);
  });

  afterEach(async () => {
    // Cleanup
    await db.delete(posts);
    await db.delete(users);
  });

  describe('create', () => {
    it('creates a post', async () => {
      const post = await caller.posts.create({
        title: 'Test Post',
        content: 'Test content',
      });

      expect(post.title).toBe('Test Post');
      expect(post.authorId).toBe(testUser.id);
    });

    it('requires authentication', async () => {
      const unauthCaller = createTestCaller(); // No user

      await expect(
        unauthCaller.posts.create({
          title: 'Test',
          content: 'Test',
        })
      ).rejects.toThrow('UNAUTHORIZED');
    });

    it('validates input', async () => {
      await expect(
        caller.posts.create({
          title: '', // Empty title
          content: 'Test',
        })
      ).rejects.toThrow();
    });
  });

  describe('list', () => {
    it('returns posts', async () => {
      // Create test posts
      await db.insert(posts).values([
        { title: 'Post 1', content: 'Content 1', authorId: testUser.id },
        { title: 'Post 2', content: 'Content 2', authorId: testUser.id },
      ]);

      const result = await caller.posts.list();

      expect(result).toHaveLength(2);
      expect(result[0].title).toBe('Post 1');
    });
  });

  describe('update', () => {
    it('updates own post', async () => {
      const [post] = await db.insert(posts).values({
        title: 'Original',
        content: 'Content',
        authorId: testUser.id,
      }).returning();

      const updated = await caller.posts.update({
        id: post.id,
        title: 'Updated',
      });

      expect(updated.title).toBe('Updated');
    });

    it('prevents updating others posts', async () => {
      const [otherUser] = await db.insert(users).values({
        email: 'other@example.com',
        name: 'Other',
      }).returning();

      const [post] = await db.insert(posts).values({
        title: 'Other Post',
        content: 'Content',
        authorId: otherUser.id,
      }).returning();

      await expect(
        caller.posts.update({
          id: post.id,
          title: 'Hacked',
        })
      ).rejects.toThrow('FORBIDDEN');
    });
  });
});

Testing React Components

Basic Component Test

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

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

  it('applies variant classes', () => {
    render(<Button variant="destructive">Delete</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('destructive');
  });

  it('handles click events', async () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click</Button>);
    
    await userEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it('can be disabled', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Testing with tRPC Hooks

// src/components/post-list.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PostList } from './post-list';
import { trpc } from '~/lib/trpc/client';

// Mock tRPC client
vi.mock('~/lib/trpc/client', () => ({
  trpc: {
    posts: {
      list: {
        useQuery: vi.fn(),
      },
    },
  },
}));

describe('PostList', () => {
  it('displays loading state', () => {
    vi.mocked(trpc.posts.list.useQuery).mockReturnValue({
      data: undefined,
      isLoading: true,
      error: null,
    } as any);

    render(<PostList />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('displays posts', async () => {
    vi.mocked(trpc.posts.list.useQuery).mockReturnValue({
      data: [
        { id: '1', title: 'Post 1', content: 'Content 1' },
        { id: '2', title: 'Post 2', content: 'Content 2' },
      ],
      isLoading: false,
      error: null,
    } as any);

    render(<PostList />);

    await waitFor(() => {
      expect(screen.getByText('Post 1')).toBeInTheDocument();
      expect(screen.getByText('Post 2')).toBeInTheDocument();
    });
  });

  it('displays error state', () => {
    vi.mocked(trpc.posts.list.useQuery).mockReturnValue({
      data: undefined,
      isLoading: false,
      error: new Error('Failed to load'),
    } as any);

    render(<PostList />);
    expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
  });
});

Database Testing

In-Memory Database

Use separate test database:

// src/test/setup.ts
import { beforeAll, afterAll } from 'vitest';
import { sql } from 'drizzle-orm';
import { db } from '~/db';

beforeAll(async () => {
  // Create test tables
  await db.execute(sql`
    CREATE TABLE IF NOT EXISTS users (...)
  `);
});

afterAll(async () => {
  // Cleanup
  await db.execute(sql`DROP TABLE IF EXISTS users CASCADE`);
});

Transaction Rollback

Better approach - rollback after each test:

import { beforeEach, afterEach } from 'vitest';
import { db } from '~/db';

let testTransaction: any;

beforeEach(async () => {
  testTransaction = await db.transaction();
});

afterEach(async () => {
  await testTransaction.rollback();
});

Mocking

Mock Functions

import { vi } from 'vitest';

const mockFn = vi.fn();
mockFn('hello');

expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenCalledTimes(1);

Mock Modules

// Mock entire module
vi.mock('~/lib/email', () => ({
  emailClient: {
    send: vi.fn().mockResolvedValue({ success: true }),
  },
}));

// Test
import { emailClient } from '~/lib/email';

test('sends email', async () => {
  await sendWelcomeEmail('user@example.com');
  
  expect(emailClient.send).toHaveBeenCalledWith(
    expect.objectContaining({
      to: 'user@example.com',
    })
  );
});

Mock Environment Variables

import { vi } from 'vitest';

vi.stubEnv('RESEND_API_KEY', 'test-key');

// Or
process.env.RESEND_API_KEY = 'test-key';

Async Testing

Promises

it('resolves with data', async () => {
  const data = await fetchData();
  expect(data).toBeDefined();
});

it('rejects with error', async () => {
  await expect(failingFunction()).rejects.toThrow('Error message');
});

Timeouts

it('completes within time limit', async () => {
  await expect(longRunningTask()).resolves.toBeDefined();
}, { timeout: 10000 }); // 10 seconds

Snapshot Testing

Useful for component output:

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

it('matches snapshot', () => {
  const { container } = render(<MyComponent />);
  expect(container).toMatchSnapshot();
});

Update snapshots:

pnpm test -- -u

Coverage

Generate Report

pnpm test:coverage

View report:

  • Open coverage/index.html in browser

Coverage Thresholds

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      lines: 80,
      functions: 80,
      branches: 80,
      statements: 80,
    },
  },
});

Best Practices

✅ Do's

  • Test behavior, not implementation - Focus on what users see
  • Use descriptive test names - "it should..." format
  • Test edge cases - Empty arrays, null values, errors
  • Keep tests isolated - Each test should be independent
  • Clean up after tests - Remove test data
  • Mock external dependencies - APIs, email, payments
  • Test error paths - Not just happy paths

❌ Don'ts

  • Don't test library code - Trust that React, tRPC work
  • Don't over-mock - Test real integrations when possible
  • Don't skip cleanup - Can cause flaky tests
  • Don't test implementation details - Internal state, private methods
  • Don't make tests dependent - Test order shouldn't matter

Common Patterns

Test Factory

// src/test/factories.ts
export function createTestUser(overrides = {}) {
  return {
    id: 'test-id',
    email: 'test@example.com',
    name: 'Test User',
    ...overrides,
  };
}

export function createTestPost(overrides = {}) {
  return {
    id: 'test-post-id',
    title: 'Test Post',
    content: 'Test content',
    authorId: 'test-id',
    ...overrides,
  };
}

Usage:

const user = createTestUser({ name: 'John' });
const post = createTestPost({ authorId: user.id });

Helper Functions

// src/test/helpers.ts
export async function createAuthenticatedCaller(userOverrides = {}) {
  const user = await db.insert(users).values(
    createTestUser(userOverrides)
  ).returning();

  return createTestCaller(user);
}

E2E Testing (Optional)

For critical user flows:

pnpm add -D @playwright/test
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test('user can sign up and login', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  // Sign up
  await page.click('text=Sign Up');
  await page.fill('input[name="email"]', 'test@example.com');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button:has-text("Sign Up")');
  
  // Should redirect to dashboard
  await expect(page).toHaveURL(/.*dashboard/);
  await expect(page.locator('text=Welcome')).toBeVisible();
});

CI/CD Integration

GitHub Actions

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v3
        with:
          node-version: 20
          cache: 'pnpm'
      
      - run: pnpm install
      - run: pnpm test:coverage
      
      # Upload coverage
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

Debugging Tests

Run Single Test

pnpm test -- -t "creates a post"

Debug in VS Code

Add to .vscode/launch.json:

{
  "type": "node",
  "request": "launch",
  "name": "Debug Vitest",
  "runtimeExecutable": "pnpm",
  "runtimeArgs": ["test", "--run", "--no-coverage"],
  "console": "integratedTerminal"
}

Console Logs

it('debugs value', () => {
  const value = computeValue();
  console.log('Value:', value); // Shown in test output
  expect(value).toBe(42);
});

Next Steps

  • Write your first test - Start with utility functions
  • Test tRPC routers - Cover business logic
  • Add to CI/CD - Run tests on every commit
  • Track coverage - Aim for 80%+ coverage
  • Learn Playwright - For E2E testing

Start by testing tRPC routers - they contain your business logic and are easy to test with the createTestCaller helper.

On this page