There’s no test harness in the template
Ship’sapps/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
| 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 |
Setup
Point tests at a throwaway database
Integration tests read The local Postgres from
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
pnpm infra already listens on :5433 — you only need a separate database name. Create it once:Load the env and apply the schema before tests
Create The
apps/api/vitest.config.ts:vitest.config.ts
@ 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
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 generatedrouter 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
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
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’svi.mock replaces a module for the test file:
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
createRouterClientexercises the gates, validation and handler together; that’s where bugs hide. - Name the behaviour.
it('returns NOT_FOUND when editing another user'), notit('works'). - Mock the edges, not the core. Stub Resend/S3/OAuth; let Postgres and the
dbservice run for real. - Keep the test DB disposable. A run should be free to wipe it — never point
.env.testat your dev database.
