Task guide
Test a s-m-r-t application
Install the Vitest plugin. Give each test its own rolled-back database. Use one configuration to cover the model, tenant boundary, generated surfaces, and components.
Verified against s-m-r-t 0.42.4
Install the plugin and write the config
@happyvertical/smrt-vitest is the required entry point for testing a s-m-r-t project. Its Vite plugin scans your sources, builds the manifest the models need, and registers classes from every @happyvertical/smrt-* dependency. Without it, tests fail with "No field metadata found" or an unregistered-class error, because the decorators never produced field metadata for the test run.
- The plugin regenerates the manifest once, at Vitest startup.
- It also discovers the manifests of your installed s-m-r-t packages.
- On Vite 8 it restores legacy decorator transformation, which rolldown otherwise skips.
- It sets test.retry to 2 under CI and 0 locally; override with SMRT_VITEST_RETRY.
import { smrtVitestPlugin } from '@happyvertical/smrt-vitest';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [smrtVitestPlugin()],
test: {
globals: true,
environment: 'node',
setupFiles: ['@happyvertical/smrt-vitest/setup']
}
});Add the dependency
The plugin is a development dependency. It carries smrt-core and the SQL adapter it needs, so nothing else has to be installed for database tests.
pnpm add -D @happyvertical/smrt-vitest
# Every s-m-r-t package ships in lockstep. Match the version your app already
# depends on rather than taking whatever "latest" resolves to today.Write the object you are going to test
Nothing about the model changes for tests. The same class the application uses is the class under test, and the plugin scans it from src by default.
import { ObjectRegistry, SmrtCollection, SmrtObject, smrt }
from '@happyvertical/smrt-core';
@smrt({ api: true, mcp: true })
export class Article extends SmrtObject {
title = '';
body = '';
status = 'draft';
}
export class ArticleCollection extends SmrtCollection<Article> {
static readonly _itemClass = Article;
}
ObjectRegistry.registerCollection('Article', ArticleCollection);Give every test its own rolled-back database
createIsolatedTestDbFromManifest reads the manifest the plugin just generated, creates the tables in foreign-key order, opens a transaction, and hands back a transaction-scoped handle. cleanup() rolls that transaction back, so the next test starts empty without dropping or recreating anything. Pass the handle to the collection as db and the collection works exactly as it does in the application.
- includeObjects narrows schema creation to the classes this file needs.
- Classes that share a table through single-table inheritance are merged into one CREATE TABLE.
- createIsolatedTestDb({ schema }) is the same thing with raw DDL when you would rather write it yourself.
- createTestDb() exists for the rare test that must observe committed state; it has no transaction isolation.
import {
createIsolatedTestDbFromManifest,
getAdapterDisplayName,
type IsolatedTestDbResult
} from '@happyvertical/smrt-vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Article, ArticleCollection } from '../Article';
describe(`Article (${getAdapterDisplayName()})`, () => {
let ctx: IsolatedTestDbResult;
let articles: ArticleCollection;
beforeEach(async () => {
ctx = await createIsolatedTestDbFromManifest({ includeObjects: ['Article'] });
articles = await ArticleCollection.create({ db: ctx.db });
});
afterEach(async () => {
await ctx.cleanup();
});
it('creates and reads back an article', async () => {
const created = await articles.create({ title: 'First post', body: 'Hello' });
expect(created).toBeInstanceOf(Article);
const found = await articles.get(created.id as string);
expect(found?.title).toBe('First post');
expect(found?.status).toBe('draft');
});
it('starts from a clean transaction each test', async () => {
expect(await articles.list()).toHaveLength(0);
});
});Run it
The plugin prints its generated result before Vitest starts. Use these lines to confirm that the manifest found the classes. An object count of zero means the scan globs are wrong. Model assertions after that result have no meaning.
pnpm vitest run
# [smrt-vitest] Generating test manifest...
# [smrt-vitest] ✓ Generated manifest with 2 object(s)
# [smrt-vitest] Loaded manifests from 1/1 packages
# [smrt-vitest] ✓ Local manifest: 2 objects
#
# Test Files 1 passed (1)
# Tests 2 passed (2)Pick the adapter with an environment variable
The helpers resolve the adapter from the environment instead of a flag in each test. Use TEST_DB_ADAPTER to set the adapter explicitly. Otherwise, DATABASE_URL selects PostgreSQL. Without either value, each worker uses a unique temporary SQLite file. Thus, one test file covers both engines.
- getTestAdapter() returns the resolved sqlite or postgres identifier.
- getAdapterDisplayName() gives a label for the describe block, so failures say which engine ran.
- isPostgresAvailable() is a direct check on DATABASE_URL for tests you want to skip locally.
- Local file-backed SQLite runs without durability settings, because the databases are thrown away.
# Default: one SQLite temp file per worker.
pnpm vitest run
# Same suite against PostgreSQL.
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app_test pnpm vitest run
# Force SQLite even when DATABASE_URL is present.
TEST_DB_ADAPTER=sqlite pnpm vitest runTest the tenant boundary, not just the happy path
A multi-tenant application should have tests that fail when isolation regresses. enableTenancy() registers the collection interceptor that filters reads and validates writes; run the body inside withTenant() to establish the context a request would normally carry. The three assertions below are the ones worth owning, because each corresponds to a different way isolation can break.
- A read from another tenant returns nothing rather than raising.
- A cross-tenant get() resolves to null.
- A write whose tenantId disagrees with the context throws TenantIsolationError.
- An operation with no context at all throws TenantContextError.
import { createIsolatedTestDbFromManifest } from '@happyvertical/smrt-vitest';
import { disableTenancy, enableTenancy, withTenant }
from '@happyvertical/smrt-tenancy';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocumentCollection } from '../Document';
describe('tenant isolation', () => {
let ctx, documents;
beforeEach(async () => {
enableTenancy({ rawQueryPolicy: 'allow' });
ctx = await createIsolatedTestDbFromManifest({ includeObjects: ['Document'] });
documents = await DocumentCollection.create({ db: ctx.db });
});
afterEach(async () => {
disableTenancy();
await ctx.cleanup();
});
it('hides another tenant from list()', async () => {
await withTenant({ tenantId: 'acme' }, () =>
documents.create({ title: 'Acme plan', tenantId: 'acme' }));
await withTenant({ tenantId: 'globex' }, async () => {
await documents.create({ title: 'Globex plan', tenantId: 'globex' });
const rows = await documents.list();
expect(rows.map((r) => r.title)).toEqual(['Globex plan']);
});
});
it('refuses a cross-tenant get()', async () => {
const acme = await withTenant({ tenantId: 'acme' }, () =>
documents.create({ title: 'Acme plan', tenantId: 'acme' }));
await withTenant({ tenantId: 'globex' }, async () => {
expect(await documents.get(acme.id)).toBeNull();
});
});
it('rejects a write aimed at another tenant', async () => {
await withTenant({ tenantId: 'acme' }, async () => {
await expect(
documents.create({ title: 'Smuggled', tenantId: 'globex' })
).rejects.toThrow(/Tenant isolation violation/);
});
});
});Test the generated surfaces in process
The MCP application server is an ordinary object with listTools and callTool. The SvelteKit mount is an ordinary request handler. Neither needs a running server during tests. Thus, the model test suite can also check the tool catalog and its policy.
- The same shape covers the refusals: drop the Mcp-Method header and assert a 400 with code -32020.
- Generated REST routes are also plain request handlers, so import the +server module and call its exported method directly.
- Assert the denied cases and the permitted cases. Otherwise, a tool catalog with too many entries can fail without a visible error.
import { createMcpAppServer } from '@happyvertical/smrt-app-mcp';
import { mountMcpRoute } from '@happyvertical/smrt-app-mcp/sveltekit';
import { createIsolatedTestDbFromManifest } from '@happyvertical/smrt-vitest';
import { describe, expect, it } from 'vitest';
import '../../objects/Article';
const META = {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': {}
};
it('offers only read-only tools to an anonymous caller', async () => {
const ctx = await createIsolatedTestDbFromManifest({ includeObjects: ['Article'] });
const POST = mountMcpRoute(
createMcpAppServer({
smrtOptions: () => ({ db: ctx.db }),
serverInfo: { name: 'my-app', version: '0.1.0' },
allowedClassNames: ['Article'],
publicToolPatterns: () => ['article_*']
})
);
const response = await POST({
locals: {},
url: new URL('https://app.test/api/mcp'),
request: new Request('https://app.test/api/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', 'mcp-method': 'tools/list' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: META }
})
})
});
const body = await response.json();
expect(response.status).toBe(200);
expect(body.result.tools.map((t) => t.name)).toEqual(['article_get', 'article_list']);
await ctx.cleanup();
});Test components in the same run
Component tests enable a DOM for each file instead of changing the complete project to jsdom. Thus, database tests keep the faster node environment. The svelte-setup entry adds jest-dom matchers, Testing Library cleanup, and a jsdom dialog polyfill. It first checks for a document, so it stays inactive in node-environment files.
- Add the setup entry alongside your existing one: setupFiles: [..., "@happyvertical/smrt-vitest/svelte-setup"].
- The /svelte subpath re-exports render, screen, fireEvent, within, and userEvent from one import.
- expectNoA11yViolations runs axe with color contrast disabled, because jsdom does not paint.
// @vitest-environment jsdom
import { render, screen, userEvent, expectNoA11yViolations }
from '@happyvertical/smrt-vitest/svelte';
import { describe, it } from 'vitest';
import ArticleCard from './ArticleCard.svelte';
describe('ArticleCard', () => {
it('publishes from the card', async () => {
const { container } = render(ArticleCard, {
props: { title: 'First post', status: 'draft' }
});
await userEvent.click(screen.getByRole('button', { name: 'Publish' }));
await expectNoA11yViolations(container);
});
});Run the whole thing in CI
A single job covers both engines if you run the suite twice, or one engine if that matches your deployment. The PostgreSQL service below is what makes DATABASE_URL meaningful; without it the same workflow silently tests SQLite only.
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app_test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Tests (SQLite)
run: pnpm vitest run
- name: Tests (PostgreSQL)
run: pnpm vitest run
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app_testThe failures that are worth recognising
Most confusing test failures in a s-m-r-t project come from the manifest or from module state rather than from the assertion that reported them. These four account for nearly all of it.
- "No field metadata found" or an unregistered class means the plugin is missing from this config, or the scan globs never reached your sources.
- Watch mode can keep the manifest generated at startup. A new field can then appear to be ignored. Restart Vitest after you add classes or fields.
- A module-level singleton cache inside a collection survives between tests and ignores fresh mocks; call vi.resetModules() in beforeEach and await import(...) inside the test instead of importing at the top.
- A create() on a tenant-scoped class whose tenant field is declared non-nullable fails validation before the interceptor can populate it. Either pass tenantId explicitly, or declare the field nullable so the context fills it.