Testing SvelteKit with Vitest
SvelteKit ships with first-class Vitest support, but the default scaffold gives you a single smoke.test.ts and a note about adding more. This guide shows the testing patterns we use in our multi-tenant SvelteKit starter — 77 passing tests covering auth flows, RBAC enforcement, rate limiting, invite claims, seat limits, and the error mapper. The approach is layered: pure unit tests for business logic, service-level tests against in-memory SQLite, and a small HTTP-level suite for the error-to-response mapper.
Vitest config for SvelteKit
The starter's vitest.config.ts extends SvelteKit's Vite config via a vitest/config import and adds a test block. The key settings are environment: "node" (server-side tests run outside a browser), a $lib alias matching SvelteKit's, and the test include pattern. There is deliberately no setup file — every test creates its own database:
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
$lib: fileURLToPath(new URL('./src/lib', import.meta.url))
}
},
test: {
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
environment: 'node',
hookTimeout: 30_000,
testTimeout: 30_000
}
});Because there is no shared setup file, each test file creates its own createDb(':memory:') in beforeEach and lets it go out of scope after — no cross-suite state, no migrations to orchestrate (the kit auto-applies migrations at boot).
Testing server-side logic as pure functions
The most valuable tests are the ones that don't touch SvelteKit at all. RBAC, rate limiting, key normalization, and permission checks are all pure functions that take inputs and return verdicts. Testing them directly is fast, deterministic, and covers the core business rules:
import { can, mayActOn, mayGrant } from '$lib/server/rbac';
it('owner has every permission', () => {
for (const perm of PERMISSIONS) {
expect(can('owner', perm)).toBe(true);
}
});
it('admin cannot manage billing', () => {
expect(can('admin', 'billing.manage')).toBe(false);
});
it('actors act only on strictly lower ranks', () => {
expect(mayActOn('admin', 'admin')).toBe(false);
expect(mayActOn('admin', 'member')).toBe(true);
expect(mayActOn('member', 'owner')).toBe(false);
});These tests run in microseconds. They don't need a database, a request object, or the SvelteKit runtime. Pinning the matrix and rank table with six to ten unit cases catches the most expensive class of bugs — permission drift — before any HTTP test runs.
Testing the error mapper at the HTTP level
The starter's http.test.ts is the closest thing to HTTP-level testing in the shipped suite — it pins the behavior of errorToFail, the single function every form action funnels through. Testing it directly catches the most expensive class of bug: an action whose side effects already committed, but whose redirect was swallowed into a 500. The real defect this file guards against is documented in its header:
it('rethrows SvelteKit redirect control-flow objects instead of converting them to 500', () => {
const redirectThrow = { type: 'redirect', status: 302, location: '/app/org/x' };
expect(() => errorToFail(redirectThrow)).toThrow();
});
it('maps forbidden/hierarchy/no_membership codes to 403', () => {
for (const code of ['forbidden', 'hierarchy_violation', 'no_membership']) {
const r = errorToFail({ code, message: 'nope' });
expect(r.status).toBe(403);
}
});Two details matter. First, the assertion is on the typed error code, not on a prose message — the code is the contract, the message is UI copy that will change. Second, every service that throws a coded error is guaranteed to map to the right HTTP status because the mapper is exercised across all its branches. To test a full action including SvelteKit's form parsing, you'd need a running SvelteKit instance — the kit keeps its shipped suite dependency-free and fast, which is what makes 77 tests practical.
Testing hooks: handle and handleFetch
SvelteKit hooks are middleware — they run on every request and modify headers, redirect unauthenticated users, or rewrite fetch URLs. The shipped kit's hook only attaches the session user to locals (it's deliberately minimal), so hook testing is a general pattern rather than shipped test code. If you extend the hook with redirects, test it without a full server — construct a Request, pass it through a minimal resolve stub, and inspect what the hook returns:
// General pattern — the shipped hook has no redirects to test.
import { handle } from './your-hook';
it('redirects unauthenticated users to /login', async () => {
const request = new Request('http://localhost/app', {
headers: { cookie: '' }
});
const response = await handle({
request,
resolve: async () => new Response('ok')
});
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/login');
});Hooks are small and critical. A focused test for each branch (authenticated, unauthenticated, expired session, missing cookie) is cheaper than debugging a redirect loop in production.
Fake-clock testing for time-dependent code
Time-dependent code — session expiry, invite token lifetime, rate-limiter sliding windows — is untestable with real clocks. Tests either sleep (slow, flaky) or compare timestamps that are always "now" (meaningless). The fix is dependency injection: the module accepts a now() function, and tests pass a controllable counter.
function fakeClock() {
let t = 1_000_000;
return {
now: () => t,
tick: (ms: number) => { t += ms; }
};
}
it('invite token expires after 7 days', () => {
const clock = fakeClock();
const token = createInviteToken({ now: clock.now, expiresInMs: 7 * 86_400_000 });
expect(token.isExpired()).toBe(false);
clock.tick(7 * 86_400_000 + 1);
expect(token.isExpired()).toBe(true);
});The rate limiter uses the same pattern — an injectable now() that tests advance via tick(). This makes sliding-window expiry testable at millisecond granularity without real sleeps: the shipped test slides attempt 1 out of the window with tick(30_000), tick(29_999), then tick(1) — exactly 60,000ms, the configured window — and asserts the attempt is no longer counted.
Testing database queries with in-memory SQLite
Every test file creates its own in-memory SQLite database in beforeEach via createDb(':memory:') — the same factory the app uses, which applies the checked-in migrations at boot. This means tests can write freely without affecting other tests, and each test starts from a clean, deterministic state:
import { beforeEach, describe, expect, it } from 'vitest';
import { createDb, type Db } from '$lib/server/db';
import { createUser } from '$lib/server/auth';
import { createOrg } from '$lib/server/services/orgs';
let db: Db;
let owner: { id: string };
beforeEach(async () => {
db = createDb(':memory:');
owner = await createUser(db, { email: 'owner@example.com', password: 'long-enough-1', name: 'Owner' });
});There is no shared setup file and no fixture directory — the seed helpers are inline per test file, building realistic org hierarchies (owner, admin, member, outsider) in a few lines. Because the database is in memory, seeding is fast and the state is deterministic. No fixture files, no shared dumps, no "test passes locally but fails in CI because someone edited the seed data."
Testing auth, RBAC, invites, rate limiting, and the mapper together
The starter's 77-test suite covers the areas where security-critical code meets untrusted input. Here's how the seven test files break down:
- rbac.test.ts (6) — matrix pinning and hierarchy: owner-everything, admin boundaries, member view-only, act-on/grant rank pairs, typed throws.
- orgs-members.test.ts (17) — org creation, membership management, role changes, removal, leaving, ownership transfer, and the last-owner invariant. Each asserts on typed error codes.
- invites-seats.test.ts (8) — invite lifecycle (create, peek, reject, revoke), single-use claim via conditional UPDATE, expiry, and seat-limit enforcement through the billing seam.
- ratelimit.test.ts (8) — sliding-window expiry with deterministic clocks, key normalization, pre-hash rejection, and eviction under spoofed-key floods.
- auth.test.ts (8) — password hashing, session creation, hashed token storage, logout invalidation.
- http.test.ts (4) — the error mapper: redirect control-flow preservation, code→status mapping (403/404/400/500), and its regression guard.
- smoke.test.ts (1) — build verification.
The test structure mirrors the source structure: tests/ has one file per module, each file seeds its own database instance, and no test depends on the order other tests ran.
Testing invite claims end-to-end
Invite claims are the intersection of tokens, RBAC, seats, and database atomicity. Testing them requires seeding an org with a membership, creating an invite token, then exercising the claim path through the service — the same acceptInvite the route calls, with a real billing adapter for seat enforcement:
it('happy path: valid link joins with the invited role and is single-use', async () => {
const { token } = await createInvite(db, {
orgId, actorUserId: owner.id, actorRole: 'owner', role: 'member'
});
const peek = await peekInvite(db, token);
expect(peek).toMatchObject({ state: 'valid', role: 'member' });
const joined = await acceptInvite(db, { token, userId: joiner1.id, billing });
expect(joined.role).toBe('member');
// second use fails (invite-state check fires first)
await expect(acceptInvite(db, { token, userId: joiner2.id, billing }))
.rejects.toMatchObject({ code: 'already_accepted' });
});The single-use claim is the key assertion: acceptInvite runs a conditional UPDATE whose WHERE clause requires acceptedAtMs IS NULL AND revokedAtMs IS NULL, then inserts the membership. Two people hitting the same link cannot both get in — the second claim throws already_accepted. The seat test extends the same path: with planSeats: 3, a fourth join throws seat_limit, and removing a member frees the seat so the same link becomes usable again.
What this approach doesn't cover
- Browser-rendered components. These tests run server-side only. Component testing with
@testing-library/svelteis a separate layer — useful for UI logic, but not where security boundaries live. - End-to-end browser tests. Playwright or Cypress tests exercise the full stack including client-side hydration, form validation, and network timing. They're valuable but expensive — start with the server-side suite and add E2E for the critical paths after.
- Performance benchmarks. The in-memory SQLite setup is not representative of production database latency. Benchmark separately against a real database with realistic data volumes.
- Multi-instance behavior. The in-memory rate limiter is per-process. Testing its behavior under concurrent instances requires a shared store — a different test setup, and the first thing to add when you move beyond single-process deployment.
Production checklist
- Run vitest with SvelteKit's built-in adapter — the vitest.config.ts test block should set environment: "node" and alias $lib so server-side tests resolve the same paths as the app.
- Test server-side logic (services, RBAC, rate limiters) as pure functions first — no HTTP, no request objects, no SvelteKit runtime. The faster the feedback loop, the more boundaries you pin.
- Create a fresh in-memory SQLite database per test file (or per test) via createDb(':memory:') — no fixture files, no shared state, deterministic results. The kit's migrations auto-apply at boot.
- Test hooks by calling them with fabricated Request objects and a minimal resolve stub — hooks are middleware, not route handlers, and their test surface is small. The kit's own suite focuses on services and the error mapper.
- Use injectable clocks (now() injection) for any time-dependent code — sliding windows, session expiry, invite token lifetime. A test that sleeps is a test that flakes.
- Assert on typed error codes, not message strings — message copy changes during polish; error codes are the contract between service and caller.
- Pin every RBAC boundary with a test case — equal-rank action, upward grant, outsider access, last-owner invariant. If a boundary has no test, it has no enforcement.
- Keep test fixtures close to the test file — inline seed helpers over shared fixture directories. Shared fixtures become the thing nobody understands six months later.
- Count your green tests honestly — the starter ships 77 passing tests covering auth, RBAC, invites + seats, rate limiting, and the error mapper. A test count you can point at is more useful than a coverage percentage.
Related guides: Multi-tenant auth architecture · RBAC & role hierarchy · Rate limiting login
Related reading
- Multi-tenant Authentication — application-layer tenancy patterns
- Rate Limiting Login — testable rate limiter with fake clocks
- RBAC & Role Hierarchy — permission matrix and enforcement
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.