> ## Documentation Index
> Fetch the complete documentation index at: https://ship.paralect.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing

> Ship ships no test harness in apps/api — by design. Here's the recommended way to add one: a real Postgres test database, Drizzle, and oRPC handlers called directly.

## 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](https://vitest.dev/), a throwaway PostgreSQL database, [Drizzle](https://orm.drizzle.team/), and oRPC handlers called directly.

<Note>
  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.
</Note>

## What to test

<CardGroup cols={2}>
  <Card title="Worth testing" icon="shield-check">
    * 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
  </Card>

  <Card title="Skip it" icon="ban">
    * Plain pass-through CRUD endpoints
    * Throwaway prototypes and MVPs
    * Anything the Zod schema already guarantees
    * Static UI and content
  </Card>
</CardGroup>

## 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

| Flavour         | Hits the DB? | Test against                                                      |
| --------------- | ------------ | ----------------------------------------------------------------- |
| **Unit**        | No           | A pure `method`, a Zod schema, a utility                          |
| **Integration** | Yes          | A 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

<Steps>
  <Step title="Add Vitest">
    ```bash theme={null}
    pnpm --filter api add -D vitest
    ```
  </Step>

  <Step title="Add a test script">
    In `apps/api/package.json`:

    ```json theme={null}
    {
      "scripts": {
        "test": "vitest run",
        "test:watch": "vitest"
      }
    }
    ```
  </Step>

  <Step title="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`:

    ```bash .env.test theme={null}
    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:

    ```bash theme={null}
    psql postgresql://root:root@localhost:5433/postgres -c 'CREATE DATABASE "api-test";'
    ```
  </Step>

  <Step title="Load the env and apply the schema before tests">
    Create `apps/api/vitest.config.ts`:

    ```ts vitest.config.ts theme={null}
    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`:

    ```ts test/global-setup.ts theme={null}
    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' });
    }
    ```
  </Step>
</Steps>

## 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:

```ts users.schema.test.ts theme={null}
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:

```ts users.db.test.ts theme={null}
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();
  });
});
```

<Tip>
  `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](#mocking-external-services)).
</Tip>

## 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`](https://orpc.unnoq.com/docs/client/server-side) 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.

```ts list.test.ts theme={null}
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](/docs/how-ship-works) emits into `router.ts`.

<Note>
  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](/docs/how-ship-works) for how files map to procedures.
</Note>

## 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:

```ts update.test.ts theme={null}
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:

```ts theme={null}
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:

```yaml .github/workflows/test.yml theme={null}
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.
