Skip to Content
Testing

Testing

A procedure’s .fn method calls the handler without the HTTP round trip. SSR, server actions and tests use the same method, so unit tests need no server.

Setup

Any test runner works: Vitest, Jest, the Node.js test runner and others. The examples use Vitest.

The vovk package is ES modules only. Jest set up with next/jest can’t load it as is, so add transpilePackages: ['vovk'] to the Next.js config.

Vitest doesn’t read the paths of tsconfig.json, so it can’t resolve @/client on its own. With Vite 8, which a new Vitest install uses, turn on resolve.tsconfigPaths. With an older Vite, use the vite-tsconfig-paths plugin or resolve.alias.

vitest.config.ts
import { defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { tsconfigPaths: true }, });

With Vite 8, Vitest also drops the decorators on static fields, such as procedures, when useDefineForClassFields is off: .fn() then runs without them, auth guards included. It is off by default for a target below ES2022, such as the ES2017 that create-next-app writes. Set "useDefineForClassFields": true in tsconfig.json, or "target": "ES2022".

Node.js strips TypeScript types but doesn’t compile decorators, so the Node.js test runner needs a loader that does, such as tsx : node --import tsx --test. tsx also reads the paths of tsconfig.json, so @/client resolves. Without a loader, Node.js runs only the generated client (it strips types by default since 22.18): set "module": "nodenext" and "allowImportingTsExtensions": true (it needs "noEmit": true) in tsconfig.json, and import the client by a relative path. To use the client from another project, bundle it.

Testing with .fn

Given this controller:

src/modules/user/user-controller.ts
import { z } from 'zod'; import { procedure, get, post, prefix } from 'vovk'; @prefix('users') export default class UserController { @get('{id}') static getUser = procedure({ params: z.object({ id: z.string() }), output: z.object({ id: z.string(), name: z.string() }), }).handle(async ({ vovk }) => { const { id } = vovk.params(); return { id, name: 'John' }; }); @post() static createUser = procedure({ body: z.object({ name: z.string() }), output: z.object({ id: z.string(), name: z.string() }), }).handle(async ({ vovk }) => { const { name } = await vovk.body(); return { id: 'new-id', name }; }); }

Test it directly:

src/modules/user/user-controller.test.ts
import { describe, it, expect } from 'vitest'; import UserController from './user-controller'; describe('UserController', () => { it('gets a user by ID', async () => { const user = await UserController.getUser.fn({ params: { id: '42' }, }); expect(user).toEqual({ id: '42', name: 'John' }); }); it('creates a user', async () => { const user = await UserController.createUser.fn({ body: { name: 'Alice' }, }); expect(user).toEqual({ id: 'new-id', name: 'Alice' }); }); });

.fn runs the whole procedure, with validation and decorators, but without HTTP. That fits tests with mocked or stubbed data: there is no server to start, no network latency and no cold start, so the tests run fast. A test runs outside a Next.js request, so headers() and cookies() from next/headers throw there: mock next/headers for a decorator that reads them.

The trade-off: .fn() skips proxy.js (middleware.js in earlier Next.js versions). Logic that lives there, such as authentication checks, rate limiting or added headers, doesn’t run in .fn() calls. To test that layer, use integration tests with RPC modules against a running dev server instead.

Testing Validation

Procedures validate their input, so you can test that invalid data is rejected:

src/modules/user/user-controller.test.ts
import { describe, it, expect } from 'vitest'; import { HttpException } from 'vovk'; import UserController from './user-controller'; describe('UserController validation', () => { it('rejects invalid body', async () => { await expect( UserController.createUser.fn({ // @ts-expect-error name must be a string body: { name: 123 }, }) ).rejects.toThrow(HttpException); }); });

Integration Testing with RPC Modules

For end-to-end tests through HTTP, call the generated RPC modules against a running dev server. The client calls /api by default, and fetch in Node.js takes no relative URL, so give it the server’s address with apiRoot, or set outputConfig.origin.

src/modules/user/user-controller.e2e.test.ts
import { describe, it, expect } from 'vitest'; import { UserRPC } from '@/client'; const LocalUserRPC = UserRPC.withDefaults({ apiRoot: 'http://localhost:3000/api' }); describe('UserController E2E', () => { it('gets a user via HTTP', async () => { const user = await LocalUserRPC.getUser({ params: { id: '42' } }); expect(user).toEqual({ id: '42', name: 'John' }); }); });
Last updated on