Testing your app
Run Vitest on an app built on the framework — the server-only stub, why the framework packages are inlined, keeping models out of CI, and what to test.
At the end of this page pnpm test runs your app’s own tests: the seams you bind, your tools and your server actions, with no model calls and no browser.
Add the setup
intelligo add vitest
pnpm add -D vitestThen add "test": "vitest run" to the scripts in package.json. intelligo add vitest writes two files and records them in intelligo.manifest.json like every other generated file, so upgrade --check can tell you when the template moves.
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const root = path.dirname(fileURLToPath(import.meta.url));
/**
* The app's test runner.
*
* - `@` resolves the way the app's tsconfig resolves it.
* - `server-only` throws unless the bundler applies React's
* `react-server` condition, which a test does not; the stub stands in
* for it so a test can import server code.
* - The `@intelligo-dev/*` packages ship compiled ESM that imports
* `server-only` itself. Vitest leaves node_modules to Node, where the
* alias above never applies, so they are inlined: transformed by
* Vitest like the app's own source, with the alias in effect.
*/
export default defineConfig({
resolve: {
alias: {
"@": root,
"server-only": path.join(root, "tests/stubs/server-only.ts"),
},
},
test: {
environment: "node",
include: ["**/*.test.{ts,tsx}"],
exclude: ["node_modules/**", ".next/**"],
server: {
deps: {
inline: [/node_modules\/@intelligo-dev\//],
},
},
},
});The second file, tests/stubs/server-only.ts, is an empty module.
Why the framework packages are inlined
The framework’s server modules begin with import "server-only", the guard that fails a build which pulls server code into the browser. Outside a react-server build the real package throws on import. An alias to the stub fixes that for your own files — but Vitest hands anything under node_modules to Node untransformed, and Node never sees a Vite alias. The first test that imports lib/intelligo.ts would then fail inside @intelligo-dev/billing on the guard, not on anything it tests.
server.deps.inline makes Vitest transform the @intelligo-dev/* packages itself, so the alias reaches their imports too. Mock a package by its specifier (vi.mock("@intelligo-dev/core/documents")), never by a path inside node_modules: the packages ship compiled ESM, and the file layout is not part of their contract.
No model calls in CI
Mock the AI SDK provider you use, so a test never needs a key and never spends one:
import { vi } from "vitest";
vi.mock("@ai-sdk/anthropic", () => ({
anthropic: vi.fn(() => ({ modelId: "anthropic/claude-sonnet-4-6" })),
}));For a whole chat turn, @intelligo-dev/chat/testing exports createStubLanguageModel: a language model that answers from a function of the last user message, can emit a tool call, and reports token usage like a real one. The scaffold’s chat streams from it until you configure a provider.
Environment read at import time
A module that reads the environment when it loads — a config object, a provider client built at the top of a file — sees whatever was set before the import. Stub the variables first and import afterwards, with a fresh module registry per test:
import { beforeEach, expect, it, vi } from "vitest";
beforeEach(() => {
vi.resetModules();
vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://app.example.com");
});
it("builds links from the public URL", async () => {
const { appUrl } = await import("@/lib/app-url");
expect(appUrl("/login")).toBe("https://app.example.com/login");
});What is worth testing
Most of an app built on the framework is installed source the registry already tests, and intelligo sync --check keeps it the registry’s. What only your tests reach is what you decided:
- The seams you bind —
lib/chat-server-config.tsruns on a registered model id and gates on a feature key your plans declare;lib/chat-config.tsxnames starters your messages define. The reference app’slib/__tests__has one of each. - Your tools and prompts — the functions an agent calls, with the model mocked.
- Your Server Actions — parse, call the service, map its typed error. Mock the service’s package and assert the mapping.
- Your own tables — against a real Postgres, skipped when there is none:
describe.skipIf(!process.env.TEST_DATABASE_URL).
Your own tables, against a database
A table scoped to a workspace or a user needs a real users row and a real organization row to point at. Before the suite, insert one of each with random ids (a users row needs id, name, email, email_verified, created_at and updated_at; an organization row needs id, name, slug, created_at and updated_at), add a member row only when the code under test checks membership, and after it delete the organization and the user: their rows cascade. The memory-audit trail is append-only and keeps its rows.
Point TEST_DATABASE_URL at a database of its own, migrated with intelligo migrate, never at the app’s DATABASE_URL: the fixtures write and delete. Name these files *.integration.test.ts and keep them out of pnpm test’s default run with an exclude in vitest.config.ts, so a plain test run needs no database.
Driving the chat from a script
An eval or a smoke scenario talks to /api/chat the way the browser does. Sign in with Better-Auth’s POST /api/auth/sign-in/email, sending an Origin header equal to NEXT_PUBLIC_APP_URL (the CSRF check refuses a request without one), and keep the session cookie it sets. Post each turn to /api/chat with that cookie and the same Origin, in the body the AI SDK’s DefaultChatTransport sends: { id, messages, trigger: "submit-message" }, where id is the conversation and messages ends with the new user UIMessage (the transport reads the earlier turns from what it stored). The answer is the AI SDK’s UI message stream: the SDK’s stream reader turns it into the assistant message with its text, tool and data-* parts. The turn’s token usage is on the message’s metadata.usage.
A plan for a test user
grantPlan from @intelligo-dev/billing puts a workspace on a plan, the same call the admin console makes. Call it from a script that runs the composition root first. A running server caches feature flags for up to a minute, so a grant shows there after that, or at once after a restart.