DEV Community

Lucas Martin
Lucas Martin

Posted on

Test Your supabase-js Code Without a Server: An In-Process Backend for Vitest

Most Supabase apps end up with one of two testing strategies:

  1. Mock supabase-js. Fast, but the mocks test what you think the database does. RLS, constraints, and defaults never run.
  2. Test against a running backend. Accurate, but now every test run needs Docker (or a cloud project), ports, and cleanup between runs.

There's a third option: run the backend inside the test process itself. No server, no ports, no network. Your real supabase-js code talks to real Postgres, with your real policies, and the whole thing disappears when the test file finishes.

This guide sets that up with Vitest and tinbase, an open-source Supabase-compatible backend that can run in-process on PGlite (Postgres compiled to WebAssembly).

tinbase is alpha and built for development and testing. It covers a large part of the supabase-js surface, but not all of it; check the known gaps in the README before relying on a specific feature.

How does an in-process backend work?

Normally, supabase-js sends HTTP requests to a server. But it lets you pass a custom fetch. tinbase exposes a backend.fetch function that handles those requests directly, so you can wire them together:

your code → supabase-js → backend.fetch → Postgres (PGlite), all in one process
Enter fullscreen mode Exit fullscreen mode

Nothing listens on a port, so tests can run in parallel without colliding, and there's nothing to clean up afterward.

Setup

npm install -D vitest tinbase
npm install @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

A helper that creates a fresh backend

// test/backend.ts
import { createBackend } from 'tinbase';
import { createClient } from '@supabase/supabase-js';

const migrations = [
  {
    name: '20261015000000_notes',
    sql: `
      create table public.notes (
        id bigint generated always as identity primary key,
        user_id uuid not null default auth.uid(),
        body text not null check (length(body) > 0)
      );

      alter table public.notes enable row level security;

      create policy "own notes" on public.notes
        for all to authenticated
        using ((select auth.uid()) = user_id)
        with check ((select auth.uid()) = user_id);
    `,
  },
];

export async function createTestBackend() {
  const backend = await createBackend({ migrations });

  // Each call returns a separate client with its own session
  const newClient = () =>
    createClient('http://localhost', backend.anonKey, {
      auth: { persistSession: false },
      global: { fetch: (input, init) => backend.fetch(new Request(input, init)) },
    });

  return { backend, newClient };
}
Enter fullscreen mode Exit fullscreen mode

The migrations here are inline for clarity. In a real project you'd load the files from supabase/migrations/; tinbase's Node helpers include loadSupabaseProject for that, so check the README for its current usage.

Your first tests

Anonymous sign-in gives each client a real authenticated user without email confirmation, which is perfect for tests.

// test/notes.test.ts
import { beforeAll, describe, expect, it } from 'vitest';
import { createTestBackend } from './backend';

let newClient: Awaited<ReturnType<typeof createTestBackend>>['newClient'];

beforeAll(async () => {
  ({ newClient } = await createTestBackend());
});

describe('notes', () => {
  it('creates and reads a note', async () => {
    const alice = newClient();
    await alice.auth.signInAnonymously();

    const { error } = await alice.from('notes').insert({ body: 'hello' });
    expect(error).toBeNull();

    const { data } = await alice.from('notes').select('body');
    expect(data).toEqual([{ body: 'hello' }]);
  });

  it('rejects empty notes (check constraint)', async () => {
    const alice = newClient();
    await alice.auth.signInAnonymously();

    const { error } = await alice.from('notes').insert({ body: '' });
    expect(error).not.toBeNull();
  });
});
Enter fullscreen mode Exit fullscreen mode

The second test is the kind a mock can't give you: the check constraint lives in the database, and here it actually runs.

Testing RLS for real

Two clients, two users, one assertion that matters:

it('users cannot see each other’s notes', async () => {
  const alice = newClient();
  const bob = newClient();
  await alice.auth.signInAnonymously();
  await bob.auth.signInAnonymously();

  await alice.from('notes').insert({ body: 'alice secret' });

  const { data } = await bob.from('notes').select('body');
  expect(data).not.toContainEqual({ body: 'alice secret' });
});

it('users cannot write notes as someone else', async () => {
  const alice = newClient();
  const bob = newClient();
  await alice.auth.signInAnonymously();
  const { data: bobUser } = await bob.auth.signInAnonymously();

  const { error } = await alice
    .from('notes')
    .insert({ body: 'forged', user_id: bobUser.user!.id });

  expect(error).not.toBeNull(); // RLS with check rejects it
});
Enter fullscreen mode Exit fullscreen mode

Remember that a blocked select returns an empty list, not an error. That's why the first test checks what came back, not whether it failed.

One backend per file, or per test?

Each in-process backend runs its own Postgres, and PGlite reserves a fair amount of memory (hundreds of MB in tinbase's benchmarks). So:

  • Per test file (beforeAll) is the sensible default. Files run in separate Vitest workers, so they're isolated from each other.
  • Within a file, keep tests independent by using fresh users (as above) rather than resetting the database. New anonymous users never see each other's rows, so tests don't interfere.
  • Per test (beforeEach) works for small suites where you truly need an empty database, but watch memory and run time.

What to test this way, and what not to

Good fits:

  • Queries and filters your app depends on
  • Constraints, defaults, and triggers
  • RLS policies, with multiple users
  • Data-layer functions (createNote, listNotes) exactly as your app calls them

Keep a check against the real thing for:

  • Features tinbase doesn't implement yet (see the README's known gaps, e.g. MFA, phone auth, some PostgREST features)
  • Performance and concurrency: PGlite is single-connection, so it's not a load test
  • A final pre-deploy pass against the official Supabase stack or a staging project

Wrapping up

Mocks are fast but blind; a running server is accurate but heavy. An in-process backend sits in between: real Postgres, real policies, your real supabase-js calls, and nothing to start, stop, or clean up.

Try it on the table you're least sure about, and write the RLS test first. It's usually the one that finds something.

Source, benchmarks, and known gaps: tinbase on GitHub. How do you test your Supabase code today: mocks, a running backend, or not at all? Tell me in the comments.

Top comments (0)