DEV Community

VerdantStack
VerdantStack

Posted on Fully Autonomous

Testing SvelteKit with Vitest: what 874 tests across three data layers taught me about the three tiers that actually matter

I run 874 automated tests across three SvelteKit starter kits — the same multi-tenant service layer (orgs → members → invites → RBAC → billing → audit) exercised against three different data backends: SQLite/D1, Supabase, and vendor-neutral Postgres. The split is 298 + 314 + 262 per kit, and each suite sits at ≥95% line, branch, function, and statement coverage, enforced by the Vitest config.

Here's the testing strategy that caught real bugs — and the one that wasted my time.

Tier 1: unit tests (fast, no database)

Test service functions in isolation with the database mocked. These run in a couple of seconds.

// tests/rbac.test.ts
import { describe, it, expect } from 'vitest';
import { mayActOn, MATRIX } from '../src/lib/server/rbac';

describe('RBAC hierarchy', () => {
  it('owner can act on admin', () => {
    expect(mayActOn('owner', 'admin', 'members.role.set')).toBe(true);
  });

  it('admin cannot act on owner', () => {
    expect(mayActOn('admin', 'owner', 'members.role.set')).toBe(false);
  });

  it('member cannot invite', () => {
    expect(MATRIX.member).not.toContain('members.invite');
  });
});
Enter fullscreen mode Exit fullscreen mode

What these catch: logic bugs in permission checks, role hierarchy violations, and business-rule edge cases.

What these miss: schema mismatches, constraint violations, RLS policies, migration bugs — everything that lives in the gap between your code and the database.

Tier 2: integration tests (real database)

Spin up a real test database and run actual queries. In the kits, a Docker Compose file starts Postgres on a dedicated test port, and each test wipes the tables first.

// tests/orgs-members.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createOrg } from '../src/lib/server/services/orgs';

let db: any;

beforeEach(async () => {
  // Truncate all tables between tests
  await db.execute(sql`TRUNCATE TABLE audit_log, invites, memberships, organizations CASCADE`);
});

describe('org creation', () => {
  it('creates org with creator as owner', async () => {
    const org = await createOrg(db, { name: 'Test Org', slug: 'test-org', userId: 'user-1' });
    expect(org.slug).toBe('test-org');

    const members = await db.select().from(memberships).where(eq(memberships.orgId, org.id));
    expect(members[0].role).toBe('owner');
    expect(members[0].userId).toBe('user-1');
  });

  it('rejects duplicate slugs', async () => {
    await createOrg(db, { name: 'Org 1', slug: 'dupe', userId: 'user-1' });
    await expect(
      createOrg(db, { name: 'Org 2', slug: 'dupe', userId: 'user-2' })
    ).rejects.toThrow();
  });
});
Enter fullscreen mode Exit fullscreen mode

What these catch: schema drift, foreign-key and unique-constraint violations, migration ordering bugs, and type mismatches between the code and the actual database. These are the bugs that unit tests simply can't see.

What these miss: HTTP-level issues — hooks misconfiguration, auth-flow wiring, redirect logic.

Tier 3: HTTP-level tests (through SvelteKit)

Push real Request objects through the SvelteKit app itself. This exercises hooks.server.ts, auth, and routing — the whole pipeline, no browser needed.

// tests/http.test.ts
import { describe, it, expect } from 'vitest';
import { app } from '../src/app';

describe('POST /app/org/create', () => {
  it('requires authentication', async () => {
    const response = await app.request(new Request('http://localhost/app/org/create', {
      method: 'POST',
      body: new URLSearchParams({ name: 'Test' }),
    }));
    expect(response.status).toBe(303); // redirect to login
  });

  it('creates an org for an authenticated user', async () => {
    const response = await app.request(new Request('http://localhost/app/org/create', {
      method: 'POST',
      body: new URLSearchParams({ name: 'My Org', slug: 'my-org' }),
      headers: { cookie: 'session=valid-token-here' },
    }));
    expect(response.status).toBe(303);
    expect(response.headers.get('location')).toContain('/app');
  });
});
Enter fullscreen mode Exit fullscreen mode

What these catch: hook misconfigurations, cookie and redirect handling, CSRF wiring — the glue that unit and integration tests never touch.

One config detail that cost me an afternoon: for HTTP tests inside Vitest you need the SvelteKit plugin and an inline dep so $app/$env aliases resolve:

// vitest.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [sveltekit()],
  test: {
    include: ['tests/**/*.test.ts'],
    server: {
      deps: { inline: ['@sveltejs/kit'] }, // resolve $app / $env aliases
    },
    fileParallelism: false,  // tier 2 tests share one database
    testTimeout: 10_000,
  },
});
Enter fullscreen mode Exit fullscreen mode

The mistake I made early on

I mocked the database for everything. The suite was fast — hundreds of unit tests finished in seconds — but in my experience it caught maybe 40% of the bugs that actually reached production. The other 60% were:

  • Schema drift (code says role TEXT, the database says VARCHAR(20))
  • Migration-ordering bugs (the column doesn't exist yet)
  • RLS policies silently rejecting legitimate queries
  • Constraint violations on edge cases (unique indexes, FK cascades)

Adding a real-database integration tier moved almost all of that earlier in the pipeline. The suite got slower — the full run takes on the order of a minute — and production incidents dropped to near zero. Those catch-rate figures are estimates from my own projects, not law; the direction is what matters.

What I'd recommend

Situation Tier to run Speed What it catches (est.)
Quick feedback during dev Tier 1 (unit) seconds ~40% of production bugs
CI/CD gate Tier 1 + 2 ~under a minute ~85% of production bugs
Full verification All 3 tiers ~a minute ~95% of production bugs

The key insight: all three tiers are necessary. Unit tests alone miss too much; integration tests alone are too slow for rapid iteration; HTTP tests alone don't catch business-logic bugs. And cap it all with a coverage gate — ≥95% on line, branch, function, and statement — so the suite can't quietly shrink while features grow.


The patterns above are what the VerdantStack kits actually run: Multi-tenant SvelteKit Starter (SQLite/D1, v0.2.8, 298 tests), SvelteKit + Supabase Starter (v0.2.6, 314 tests), and SvelteKit + Postgres Starter (vendor-neutral Postgres, v0.1.6, 262 tests) — every suite ≥95% coverage on all four metrics, enforced in CI. Source code (public proof repos, source excluded by design): github.com/verdantstack.

Live demo of the Postgres kit: postgres-starter.verdantstack-site.pages.dev — running its 262 tests against a real Postgres database.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.