Skip to main content

There’s no test harness in the template

Ship’s apps/api ships without a test runner. There is no vitest.config.ts, no test framework in package.json, and no *.test.ts / *.spec.ts files in the scaffold — the only test-adjacent scripts in apps/api/package.json are tsc and the linters. That’s deliberate. Tests are a choice, not boilerplate, and the patterns Ship uses — file-mounted oRPC endpoints, a typed DbService, Zod contracts — make most logic trivial to test once you decide to. This page documents the recommended setup for the real stack: Vitest, a throwaway PostgreSQL database, Drizzle, and oRPC handlers called directly.
Everything below is a recommendation you opt into. None of these files exist in a fresh Ship project until you add them — so adapt freely to your project.

What to test

Worth testing

  • Non-trivial business logic in methods/
  • Authorization gates (canAccess / canEdit)
  • Endpoint input/output contracts
  • Mutation-event handlers/ (side effects)
  • Reused or hard-to-reason-about utilities

Skip it

  • Plain pass-through CRUD endpoints
  • Throwaway prototypes and MVPs
  • Anything the Zod schema already guarantees
  • Static UI and content

Why Vitest

The Ship toolchain is already Vite + TypeScript + ESM with the @/* path alias. Vitest reuses your Vite resolver, so it understands @/db, @/endpoint and the rest with zero extra config. It’s the natural fit for both apps/api and the TanStack Start web app.

Two flavours of test

FlavourHits the DB?Test against
UnitNoA pure method, a Zod schema, a utility
IntegrationYesA real Postgres + the db service, or an oRPC handler end-to-end
Unit tests need nothing special. Integration tests need a Postgres database — keep it separate from your dev DB so a test run can truncate tables freely.

Setup

1

Add Vitest

pnpm --filter api add -D vitest
2

Add a test script

In apps/api/package.json:
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}
3

Point tests at a throwaway database

Integration tests read DATABASE_URL (the same env var the app validates in src/config/index.ts). Give them their own database so a run can wipe tables without touching dev data. Create apps/api/.env.test:
.env.test
APP_ENV=development
API_URL=http://localhost:3001
WEB_URL=http://localhost:3002
DATABASE_URL=postgresql://root:root@localhost:5433/api-test
The local Postgres from pnpm infra already listens on :5433 — you only need a separate database name. Create it once:
psql postgresql://root:root@localhost:5433/postgres -c 'CREATE DATABASE "api-test";'
4

Load the env and apply the schema before tests

Create apps/api/vitest.config.ts:
vitest.config.ts
import { fileURLToPath } from 'node:url';

import { config as loadEnv } from 'dotenv';
import { defineConfig } from 'vitest/config';

loadEnv({ path: '.env.test' });

export default defineConfig({
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  test: {
    environment: 'node',
    include: ['src/**/*.test.ts'],
    globalSetup: './test/global-setup.ts',
    fileParallelism: false,
  },
});
The @ alias mirrors tsconfig.json, so @/db and friends resolve in tests exactly as they do at runtime. fileParallelism: false keeps integration tests that share one database from stepping on each other.Push the Drizzle schema into the test database once per run in apps/api/test/global-setup.ts:
test/global-setup.ts
import { execSync } from 'node:child_process';

export default function setup() {
  // Sync the current Drizzle schema into the test DB (drizzle-kit reads
  // DATABASE_URL, which vitest.config.ts loaded from .env.test).
  execSync('pnpm db:push --force', { stdio: 'inherit' });
}

Where tests live

Colocate tests with the code they cover. Use the .test.ts suffix so the Vitest include glob picks them up:
resources/users/
  users.schema.ts
  endpoints/
    list.ts
    list.test.ts          # endpoint contract + handler
  methods/
    get-by-email.ts
    get-by-email.test.ts   # pure business logic
  handlers/
    sync-analytics.ts
    sync-analytics.test.ts # mutation-event side effect

Unit test: a method or schema

Pure logic needs no database. Import the function or Zod schema and assert:
users.schema.test.ts
import { describe, expect, it } from 'vitest';

import { emailSchema, passwordSchema } from '@/resources/users/users.schema';

describe('users schema', () => {
  it('lowercases and trims the email', () => {
    expect(emailSchema.parse('  John@Acme.COM ')).toBe('john@acme.com');
  });

  it('rejects a password with no digit', () => {
    expect(() => passwordSchema.parse('onlyletters')).toThrow();
  });
});

Integration test: the db service

The generated db service from @/db is the real thing — it talks to Postgres over the DATABASE_URL you set in .env.test. Clean the table in beforeEach so tests stay isolated:
users.db.test.ts
import { beforeEach, describe, expect, it } from 'vitest';

import db from '@/db';

describe('users table', () => {
  beforeEach(async () => {
    // Hard-delete every row before each test (bypasses the soft-delete filter).
    await db.users.deleteMany({});
  });

  it('inserts and reads a user back', async () => {
    const created = await db.users.insertOne({
      id: crypto.randomUUID(),
      fullName: 'John Doe',
      email: 'john.doe@example.com',
    });

    const found = await db.users.findFirst({ where: { id: created.id } });

    expect(found?.email).toBe('john.doe@example.com');
  });

  it('soft-deletes are hidden by the deletedAt filter', async () => {
    const user = await db.users.insertOne({
      id: crypto.randomUUID(),
      fullName: 'Jane Doe',
      email: 'jane@example.com',
    });

    await db.users.updateOne({ id: user.id }, { deletedAt: new Date() });

    const visible = await db.users.findFirst({
      where: { id: user.id, deletedAt: null },
    });

    expect(visible).toBeUndefined();
  });
});
db.users.insertOne emits a typed MutationEvent, so this test also exercises any handler in resources/users/handlers/. If a handler reaches a real external service, mock it (see Mocking external services).

Integration test: an oRPC endpoint

Endpoints are oRPC procedures, so you can call them server-side without an HTTP server. Build a client over the generated router with createRouterClient from @orpc/server and pass the context an endpoint expects — that’s where context.user and the gates’ loaded entities live. This runs the full stack the request would: every global middleware, the .use(...) gates, input validation, your handler, and output validation.
list.test.ts
import { createRouterClient } from '@orpc/server';
import { beforeEach, describe, expect, it } from 'vitest';

import db from '@/db';
import { router } from '@/router';
import type { ORPCContext } from '@/types';

// The fields the endpoint's middlewares actually read. `isAdmin` checks
// `context.user.isAdmin`; the cookie/header helpers are no-ops in tests.
const adminContext = (user: ORPCContext['user']): ORPCContext => ({
  user,
  headers: {},
  getCookie: () => undefined,
  setCookie: () => {},
  deleteCookie: () => {},
  secure: false,
});

describe('users.list', () => {
  beforeEach(async () => {
    await db.users.deleteMany({});
  });

  it('returns a page of users for an admin', async () => {
    const admin = await db.users.insertOne({
      id: crypto.randomUUID(),
      fullName: 'Admin',
      email: 'admin@example.com',
      isAdmin: true,
    });

    await db.users.insertOne({
      id: crypto.randomUUID(),
      fullName: 'Member',
      email: 'member@example.com',
    });

    const client = createRouterClient(router, { context: adminContext(admin) });
    const result = await client.users.list({ page: 1, perPage: 10 });

    expect(result.count).toBe(2);
    expect(result.results).toHaveLength(2);
  });

  it('rejects a non-admin with FORBIDDEN', async () => {
    const member = await db.users.insertOne({
      id: crypto.randomUUID(),
      fullName: 'Member',
      email: 'member@example.com',
    });

    const client = createRouterClient(router, { context: adminContext(member) });

    await expect(client.users.list({ page: 1, perPage: 10 })).rejects.toThrow();
  });
});
This is the highest-value integration test: it proves the gate (isAdmin), the contract (paginationSchema in, listResultSchema(publicSchema) out), and the handler’s Drizzle query all agree — the same wiring codegen emits into router.ts.
Endpoints under a dynamic segment like [userId]/update.ts read their id from the validated input. Pass it the same way the typed client would — client.users.update({ userId, ...patch }). See the resource-owns-everything model for how files map to procedures.

Testing the gates directly

canAccess(key, load) loads an entity into context[key] or throws NOT_FOUND; canEdit(key, service) builds on it and throws NOT_FOUND on an ownership mismatch — so a non-owner can’t even tell the row exists. Cover both the allow and deny paths through a real endpoint that uses them:
update.test.ts
import { createRouterClient } from '@orpc/server';
import { beforeEach, describe, expect, it } from 'vitest';

import db from '@/db';
import { router } from '@/router';

import { userContext } from '@/test/helpers';

describe('users.update authorization', () => {
  beforeEach(async () => {
    await db.users.deleteMany({});
  });

  it('NOT_FOUND when editing someone else (no existence leak)', async () => {
    const [owner, intruder] = await db.users.insertMany([
      { id: crypto.randomUUID(), fullName: 'Owner', email: 'owner@example.com' },
      { id: crypto.randomUUID(), fullName: 'Intruder', email: 'intruder@example.com' },
    ]);

    const client = createRouterClient(router, { context: userContext(intruder) });

    await expect(
      client.users.update({ userId: owner.id, fullName: 'Hacked' }),
    ).rejects.toMatchObject({ code: 'NOT_FOUND' });
  });
});
Factor the context builder into a shared src/test/helpers.ts so every endpoint test reuses it instead of re-declaring the ORPCContext shape.

Mocking external services

Keep tests hermetic — never hit Resend, S3, Mixpanel or an OAuth provider. Vitest’s vi.mock replaces a module for the test file:
import { vi } from 'vitest';

vi.mock('@ship/emails', () => ({
  sendEmail: vi.fn().mockResolvedValue(undefined),
}));
For Redis-backed bits (Socket.IO emitter, rate limits) prefer asserting that your code would call them via a mock, rather than standing up a real broker in tests.

Running in CI

pnpm --filter api test is all CI needs, given a Postgres service and a .env.test that points at it. A GitHub Actions sketch:
.github/workflows/test.yml
jobs:
  api:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: root
          POSTGRES_PASSWORD: root
          POSTGRES_DB: api-test
        ports: ['5433:5432']
        options: >-
          --health-cmd pg_isready --health-interval 10s
          --health-timeout 5s --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter api test
        env:
          DATABASE_URL: postgresql://root:root@localhost:5433/api-test
globalSetup runs pnpm db:push against that service, so the schema is in place before the first test.

Best practices

  • Isolate every test. Truncate the relevant tables in beforeEach — don’t rely on test order.
  • Test through the contract. Calling an endpoint via createRouterClient exercises the gates, validation and handler together; that’s where bugs hide.
  • Name the behaviour. it('returns NOT_FOUND when editing another user'), not it('works').
  • Mock the edges, not the core. Stub Resend/S3/OAuth; let Postgres and the db service run for real.
  • Keep the test DB disposable. A run should be free to wipe it — never point .env.test at your dev database.