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

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

2

Add a test script

In apps/api/package.json:
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
The local Postgres from pnpm infra already listens on :5433 — you only need a separate database name. Create it once:
4

Load the env and apply the schema before tests

Create apps/api/vitest.config.ts:
vitest.config.ts
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

Where tests live

Colocate tests with the code they cover. Use the .test.ts suffix so the Vitest include glob picks them up:

Unit test: a method or schema

Pure logic needs no database. Import the function or Zod schema and assert:
users.schema.test.ts

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