TinyKit Pro Docs

Testing Strategy

TinyKit Pro includes a comprehensive testing infrastructure using Vitest for unit and integration tests, and Playwright for end-to-end tests.

TinyKit Pro includes a comprehensive testing infrastructure using Vitest for unit and integration tests, and Playwright for end-to-end tests.

Test Infrastructure Overview

Test TypeFrameworkConfigCommand
Backend Unit TestsVitest + convex-testconvex/vitest.config.tsbun test:backend
Frontend Unit TestsVitest + Testing Libraryvitest.config.tsbun test:frontend
All Unit TestsVitest-bun test
E2E TestsPlaywrightplaywright.config.tsbun test:e2e

Quick Start

# Run all tests
bun test

# Run backend tests (Convex functions)
bun test:backend

# Run frontend tests (React components)
bun test:frontend

# Run tests with watch mode
bun test:watch

# Run tests with UI
bun test:ui

# Run tests with coverage
bun test:coverage
bun test:backend:coverage
bun test:frontend:coverage

# Run E2E tests
bun test:e2e
bun test:e2e:ui

Backend Testing (Convex Functions)

Configuration

Backend tests use the convex-test package with edge-runtime environment:

// convex/vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["**/*.test.ts"],
    exclude: ["_generated/**", "node_modules/**"],
    environment: "edge-runtime",
    globals: true,
    testTimeout: 10000,
    server: {
      deps: {
        inline: ["convex-test"],
      },
    },
  },
});

Writing Backend Tests

Basic Query Test

import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import schema from "../schema";
import { modules } from "../test.setup";

describe("faqs/public/queries", () => {
  it("lists active FAQs", async () => {
    const t = convexTest(schema, modules);

    // Seed test data
    await t.run(async (ctx) => {
      await ctx.db.insert("faqs", {
        question: "Test question?",
        answer: "Test answer",
        isActive: true,
        displayOrder: 1,
      });
    });

    // Run query
    const faqs = await t.query(api.faqs.public.queries.listActive, {});

    expect(faqs).toHaveLength(1);
    expect(faqs[0].question).toBe("Test question?");
  });
});

Testing Mutations with Authentication

import { convexTest } from "convex-test";
import { vi, describe, it, expect, beforeEach } from "vitest";
import schema from "../schema";
import { modules } from "../test.setup";

// Mock Better Auth user helpers
vi.mock("../users/helpers", async (importOriginal) => {
  const original = await importOriginal<typeof import("../users/helpers")>();
  return {
    ...original,
    getCurrentUserId: vi.fn(),
    getUserById: vi.fn(),
  };
});

import { getCurrentUserId, getUserById } from "../users/helpers";
const mockedGetCurrentUserId = vi.mocked(getCurrentUserId);
const mockedGetUserById = vi.mocked(getUserById);

describe("users/private/mutations", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("updates user profile when authenticated", async () => {
    const t = convexTest(schema, modules);

    // Mock authenticated user
    mockedGetCurrentUserId.mockResolvedValue("user_123");
    mockedGetUserById.mockResolvedValue({
      _id: "user_123",
      name: "Test User",
      email: "test@example.com",
      role: "user",
      emailVerified: true,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });

    await t.run(async (ctx) => {
      vi.spyOn(ctx, "runQuery").mockResolvedValue(0);

      // Test the mutation
      await t.mutation(api.users.private.mutations.updateProfile, {
        name: "Updated Name",
      });
    });

    // Verify changes...
  });

  it("throws when not authenticated", async () => {
    const t = convexTest(schema, modules);

    mockedGetCurrentUserId.mockResolvedValue(null);

    await t.run(async (ctx) => {
      await expect(
        t.mutation(api.users.private.mutations.updateProfile, {
          name: "Test",
        }),
      ).rejects.toThrow("Authentication required");
    });
  });
});

Testing Access Control

import { convexTest } from "convex-test";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { requireAccess, hasAccess } from "./requireAccess";

describe("access control", () => {
  it("allows admin access to admin-only functions", async () => {
    const t = convexTest(schema, modules);

    mockedGetCurrentUserId.mockResolvedValue("admin_123");
    mockedGetUserById.mockResolvedValue({
      _id: "admin_123",
      role: "admin",
      // ... other fields
    });

    await t.run(async (ctx) => {
      vi.spyOn(ctx, "runQuery").mockResolvedValue(0);

      const result = await requireAccess(ctx, { userRole: ["admin"] });
      expect(result.userRole).toBe("admin");
    });
  });

  it("denies non-admin access to admin functions", async () => {
    const t = convexTest(schema, modules);

    mockedGetCurrentUserId.mockResolvedValue("user_123");
    mockedGetUserById.mockResolvedValue({
      _id: "user_123",
      role: "user",
      // ... other fields
    });

    await t.run(async (ctx) => {
      vi.spyOn(ctx, "runQuery").mockResolvedValue(0);

      await expect(requireAccess(ctx, { userRole: ["admin"] })).rejects.toThrow(
        "Required user role: admin",
      );
    });
  });
});

Test Fixtures

Create reusable test fixtures for consistent test data:

// convex/test.fixtures.ts
export const TEST_USERS = {
  admin: {
    subject: "admin_123",
    email: "admin@example.com",
    role: "admin",
  },
  regularUser: {
    subject: "user_123",
    email: "user@example.com",
    role: "user",
  },
  orgOwner: {
    subject: "owner_123",
    email: "owner@example.com",
    role: "user",
  },
};

export const MOCK_BETTER_AUTH_USERS = {
  admin: {
    _id: TEST_USERS.admin.subject,
    name: "Admin User",
    email: TEST_USERS.admin.email,
    role: "admin",
    emailVerified: true,
    createdAt: Date.now(),
    updatedAt: Date.now(),
  },
  regularUser: {
    _id: TEST_USERS.regularUser.subject,
    name: "Regular User",
    email: TEST_USERS.regularUser.email,
    role: "user",
    emailVerified: true,
    createdAt: Date.now(),
    updatedAt: Date.now(),
  },
};

Backend Mock Utilities

TinyKit Pro provides comprehensive mock utilities for external services in convex/test.mocks.ts:

Stripe Mocks

import {
  mockStripe,
  createMockStripeCustomer,
  createMockStripeSubscription,
  createMockCheckoutSession,
} from "./test.mocks";

// Mock Stripe in tests
vi.mock("@convex-dev/stripe", () => ({
  StripeSubscriptions: mockStripe.StripeSubscriptions,
}));

describe("billing functions", () => {
  it("creates checkout session", async () => {
    const t = convexTest(schema, modules);

    // Use mock factory with custom data
    const mockSession = createMockCheckoutSession({
      customer: "cus_test_123",
      url: "https://checkout.stripe.com/custom",
    });

    // Test billing functions...
  });
});

Resend Email Mocks

import { mockResend, createMockResendResponse } from "./test.mocks";

// Mock Resend in tests
vi.mock("@convex-dev/resend", () => ({
  Resend: mockResend.Resend,
}));

describe("email notifications", () => {
  it("sends email successfully", async () => {
    const t = convexTest(schema, modules);

    // Mock will return { id: "email_..." } automatically
    await t.action(api.emails.sendWelcomeEmail, {
      to: "user@example.com",
      name: "Test User",
    });

    expect(mockResend.Resend).toHaveBeenCalled();
  });
});

Storage Mocks

import {
  mockConvexStorage,
  createMockStorageId,
  createMockStorageUrl,
} from "./test.mocks";

describe("file operations", () => {
  it("generates storage URL", async () => {
    const t = convexTest(schema, modules);

    await t.run(async (ctx) => {
      // Mock storage operations
      const storageId = createMockStorageId();
      const url = await mockConvexStorage.getUrl(storageId);

      expect(url).toMatch(/^https:\/\/convex\.cloud\/storage\//);
    });
  });
});

Complete Context Mocking

import { createMockConvexContext } from "./test.mocks";

describe("functions with storage and auth", () => {
  it("processes user files", async () => {
    const t = convexTest(schema, modules);

    // Create complete mock context
    const mockCtx = createMockConvexContext({
      user: {
        _id: "user_123",
        email: "test@example.com",
        role: "user",
        name: "Test User",
      },
      storageUrls: {
        kg_storage_123: "https://custom-url.com/file.jpg",
      },
    });

    // Use in tests...
  });
});

Frontend Testing (React Components)

Configuration

Frontend tests use happy-dom environment with React Testing Library:

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "happy-dom",
    globals: true,
    setupFiles: ["./src/test/setup.tsx"],
    include: ["src/**/*.test.{ts,tsx}"],
    exclude: ["**/node_modules/**", "convex/**", "e2e/**"],
  },
});

Writing Frontend Tests

Component Test Example

import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { Button } from "@/components/ui/button";

describe("Button component", () => {
  it("renders with correct text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button")).toHaveTextContent("Click me");
  });

  it("calls onClick handler when clicked", () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByRole("button"));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

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

Testing with Convex Hooks

TinyKit Pro provides pre-built mock utilities for Convex React hooks in src/test/mocks/:

import { render, screen, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach } from "vitest";
import {
  mockUseQuery,
  mockUseMutation,
  mockUsePaginatedQuery,
  setQueryLoaded,
  setQueryLoading,
  resetConvexMocks,
} from "@/test/mocks";

// Mock Convex hooks with pre-built mocks
vi.mock("convex/react", () => ({
  useQuery: mockUseQuery,
  useMutation: mockUseMutation,
  usePaginatedQuery: mockUsePaginatedQuery,
}));

describe("UserProfile component", () => {
  beforeEach(() => {
    resetConvexMocks(); // Clean state between tests
  });

  it("displays user name when loaded", async () => {
    // Use helper to set loaded state
    setQueryLoaded({
      name: "Test User",
      email: "test@example.com",
    });

    render(<UserProfile />);

    await waitFor(() => {
      expect(screen.getByText("Test User")).toBeInTheDocument();
    });
  });

  it("shows loading state", () => {
    // Use helper to set loading state
    setQueryLoading();

    render(<UserProfile />);

    expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument();
  });

  it("handles mutation", async () => {
    const updateProfile = mockUseMutation();

    render(<ProfileForm />);

    fireEvent.click(screen.getByText("Save"));

    await waitFor(() => {
      expect(updateProfile).toHaveBeenCalledWith({
        name: "Updated Name",
      });
    });
  });
});

Testing with Better Auth

import { render, screen, waitFor } from "@testing-library/react";
import { vi, describe, it, expect } from "vitest";
import {
  mockAuthClient,
  mockAuthClientLoggedOut,
  createMockAuthClient,
  createMockUser,
} from "@/test/mocks";

// Mock Better Auth client
vi.mock("@/lib/auth-client", () => ({
  authClient: mockAuthClient,
}));

describe("AuthenticatedComponent", () => {
  it("shows user info when authenticated", async () => {
    render(<UserMenu />);

    await waitFor(() => {
      expect(screen.getByText("Test User")).toBeInTheDocument();
      expect(screen.getByText("test@example.com")).toBeInTheDocument();
    });
  });

  it("shows sign in prompt when logged out", () => {
    // Use logged out variant
    vi.mocked(mockAuthClient).getSession.mockResolvedValue(null);
    vi.mocked(mockAuthClient).useSession.mockReturnValue({
      data: null,
      isPending: false,
      error: null,
    });

    render(<UserMenu />);

    expect(screen.getByText("Sign In")).toBeInTheDocument();
  });

  it("handles custom user", () => {
    // Create custom mock with specific user data
    const customClient = createMockAuthClient({
      id: "admin_123",
      email: "admin@example.com",
      role: "admin",
      name: "Admin User",
    });

    vi.mocked(mockAuthClient).getSession = customClient.getSession;

    render(<AdminPanel />);

    expect(screen.getByText("Admin User")).toBeInTheDocument();
  });
});

Frontend Mock Utilities Reference

All frontend mocks are exported from @/test/mocks for easy import:

Convex Mocks

import {
  // Core mocks
  mockUseQuery,
  mockUseMutation,
  mockUseAction,
  mockUsePaginatedQuery,
  mockUseConvex,

  // Helpers
  setQueryLoading,
  setQueryLoaded,
  setMutationError,
  setPaginatedResults,
  resetConvexMocks,

  // Factories
  createMockQueryFn,
  createMockMutationFn,
} from "@/test/mocks";

Better Auth Mocks

import {
  // Types
  type MockUser,
  type MockSession,

  // Factories
  createMockUser,
  createMockSession,
  createMockAuthClient,

  // Complete mock client
  mockAuthClient,

  // Variants
  mockAuthClientLoggedOut,
  mockAuthClientLoading,

  // Utilities
  resetAuthMocks,
} from "@/test/mocks";

E2E Testing (Playwright)

Running E2E Tests

# Run all E2E tests
bun test:e2e

# Run with UI mode
bun test:e2e:ui

# Run specific test file
bunx playwright test e2e/auth.spec.ts

# Run in headed mode (see browser)
bunx playwright test --headed

Writing E2E Tests

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("user can sign in", async ({ page }) => {
    await page.goto("/auth/sign-in");

    await page.fill('input[name="email"]', "test@example.com");
    await page.fill('input[name="password"]', "password123");
    await page.click('button[type="submit"]');

    // Should redirect to home
    await expect(page).toHaveURL("/home");
    await expect(page.getByText("Dashboard")).toBeVisible();
  });

  test("shows error for invalid credentials", async ({ page }) => {
    await page.goto("/auth/sign-in");

    await page.fill('input[name="email"]', "wrong@example.com");
    await page.fill('input[name="password"]', "wrongpassword");
    await page.click('button[type="submit"]');

    await expect(page.getByText(/invalid credentials/i)).toBeVisible();
  });
});

Test Coverage

TinyKit Pro enforces 80% coverage thresholds for all code (lines, functions, branches, statements).

Coverage Requirements

Both frontend and backend tests enforce these thresholds in CI/CD:

coverage: {
  thresholds: {
    lines: 80,
    functions: 80,
    branches: 80,
    statements: 80,
  },
}

CI/CD Integration: Tests will fail in CI if coverage drops below 80% for any metric.

Viewing Coverage Reports

# Generate coverage for all tests
bun test:coverage

# Generate coverage for backend only
bun test:backend:coverage

# Generate coverage for frontend only
bun test:frontend:coverage

Coverage reports are generated in:

  • Backend: convex/coverage/ directory
  • Frontend: coverage/ directory

Open coverage/index.html in a browser to view detailed HTML reports.

Coverage Exclusions

Backend (from convex/vitest.config.ts):

  • convex/_generated/** - Generated files
  • convex/betterAuth/_generated/** - Better Auth generated
  • convex/betterAuth/generatedSchema.ts - Generated schema
  • convex/**/schema.ts - Schema definitions (type-only)
  • convex/**/validators.ts - Validator definitions (type-only)
  • convex/**/*.test.ts - Test files
  • convex/test.*.ts - Test utilities
  • convex/convex.config.ts - Config file
  • convex/auth.config.ts - Auth config
  • convex/files.ts - Simple utilities
  • convex/migrations.ts - Migration files

Frontend (from vitest.config.ts):

  • src/components/ui/** - shadcn/ui components (external)
  • src/components/magicui/** - MagicUI components (external)
  • src/test/** - Test setup and mocks
  • **/*.test.{ts,tsx} - Test files
  • src/app/**/page.tsx - Next.js pages (tested via E2E)
  • src/app/**/layout.tsx - Next.js layouts (tested via E2E)

Best Practices

Backend Testing

  1. Mock Better Auth: Always mock user helpers when testing authenticated functions
  2. Use Test Fixtures: Create reusable fixtures for consistent test data
  3. Test Access Control: Verify both success and failure paths for authorization
  4. Isolate Tests: Each test should be independent and not rely on state from other tests

Frontend Testing

  1. Test User Behavior: Focus on what users see and do, not implementation details
  2. Use Semantic Queries: Prefer getByRole, getByText over getByTestId
  3. Mock External Dependencies: Mock Convex hooks and API calls
  4. Test Loading/Error States: Verify UI handles loading and error conditions

E2E Testing

  1. Test Critical Paths: Focus on most important user journeys
  2. Use Page Objects: Create reusable page object patterns for common interactions
  3. Handle Async Operations: Use proper waitFor and assertions
  4. Clean Test Data: Ensure tests don't leave behind data that affects other tests

Debugging Tests

Backend Tests

# Run with verbose output
bun test:backend -- --reporter=verbose

# Run specific test
bun test:backend -- --grep "allows admin access"

# Debug mode
bun test:backend -- --inspect-brk

Frontend Tests

# Run with UI for debugging
bun test:ui

# Run specific file
bun test:frontend -- src/components/Button.test.tsx

E2E Tests

# Debug mode with browser visible
bunx playwright test --headed --debug

# Trace viewer for failed tests
bunx playwright show-trace trace.zip

CI Integration

Tests run automatically in CI. Example GitHub Actions workflow:

- name: Run Tests
  run: |
    bun test
    bun test:e2e

← Back to Technical Documentation

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro