BaaS Testing Guide: How to Test Firebase Alternatives (Appwrite, Nhost, PocketBase, Xata, Supabase)

BaaS Testing Guide: How to Test Firebase Alternatives (Appwrite, Nhost, PocketBase, Xata, Supabase)

Backend-as-a-Service (BaaS) platforms have become a default choice for teams who want authentication, databases, storage, and real-time subscriptions without building backend infrastructure from scratch. Platforms like Appwrite, Nhost, PocketBase, Xata, and Supabase each offer Firebase-style convenience with different trade-offs.

Testing BaaS-backed applications requires a different mental model than testing a custom REST API. You're not testing your backend code — the BaaS handles that. You're testing the integration between your application and the platform, plus the configuration decisions (permissions, queries, hooks) that are specific to your app.

This guide compares testing strategies across the major BaaS platforms and gives you a framework that applies regardless of which one you're using.

What You're Actually Testing

When your app uses a BaaS, the testing surface is:

Layer What to test
SDK calls Does your code call the right methods with the right arguments?
Query logic Do your queries filter, sort, and paginate correctly?
Permissions/RLS Can users access only what they should?
Auth flows Sign-up, sign-in, token refresh, sign-out
Hooks/triggers Custom logic that runs on database events
File storage Upload, retrieve, delete, URL generation
Real-time Subscription events fire correctly

Notice what's not on this list: whether the BaaS stores data correctly, whether its auth system issues valid JWTs, or whether its APIs are reliable. Those are the BaaS provider's responsibility. Don't test their code.

The Testing Pyramid for BaaS Apps

         /\
        /E2E\         ← 5-10 critical user journeys
       /------\
      / Integration\  ← Permissions, real queries, auth flows
     /------------\
    /  Unit Tests  \   ← Business logic with mocked SDK
   /--------------\

Unit tests (bottom): Mock the entire BaaS SDK. Test your service layer, transformations, and business logic with no network calls.

Integration tests (middle): Run against a local BaaS instance (Docker or CLI-based). Test permissions, real queries, auth flows, storage operations.

E2E tests (top): Full browser flows against a staging environment. Test the critical paths users actually take.

Mocking Strategies by Platform

Appwrite

Appwrite's node-appwrite and browser appwrite packages export classes. Mock the constructor:

jest.mock('node-appwrite', () => ({
  Client: jest.fn().mockImplementation(() => ({
    setEndpoint: jest.fn().mockReturnThis(),
    setProject: jest.fn().mockReturnThis(),
    setKey: jest.fn().mockReturnThis(),
  })),
  Databases: jest.fn().mockImplementation(() => ({
    listDocuments: jest.fn(),
    createDocument: jest.fn(),
    updateDocument: jest.fn(),
    deleteDocument: jest.fn(),
  })),
  ID: { unique: jest.fn(() => 'mock-id') },
  Query: {
    equal: jest.fn((k, v) => `equal(${k},${v})`),
    limit: jest.fn((n) => `limit(${n})`),
  },
}));

Nhost

Nhost uses a single NhostClient class. Mock graphql.request for query tests:

jest.mock('@nhost/nhost-js', () => ({
  NhostClient: jest.fn().mockImplementation(() => ({
    graphql: { request: jest.fn() },
    auth: {
      signIn: jest.fn(),
      signOut: jest.fn(),
      getAccessToken: jest.fn(),
      isAuthenticated: jest.fn().mockReturnValue(false),
    },
    storage: {
      upload: jest.fn(),
      getPublicUrl: jest.fn(),
    },
  })),
}));

PocketBase

PocketBase's SDK uses a class per collection. The cleanest mock strategy:

jest.mock('pocketbase', () => {
  return jest.fn().mockImplementation(() => ({
    collection: jest.fn().mockReturnValue({
      getList: jest.fn(),
      getOne: jest.fn(),
      create: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
      authWithPassword: jest.fn(),
    }),
    authStore: { isValid: false, token: '' },
  }));
});

Xata

Xata generates a typed client. Mock the generated class:

jest.mock('../src/xata', () => ({
  getXataClient: jest.fn(() => ({
    db: {
      posts: {
        filter: jest.fn().mockReturnThis(),
        sort: jest.fn().mockReturnThis(),
        getMany: jest.fn(),
        getFirst: jest.fn(),
        create: jest.fn(),
        update: jest.fn(),
        delete: jest.fn(),
        search: jest.fn(),
      },
    },
  })),
}));

Supabase

jest.mock('@supabase/supabase-js', () => ({
  createClient: jest.fn(() => ({
    from: jest.fn().mockReturnValue({
      select: jest.fn().mockReturnThis(),
      insert: jest.fn().mockReturnThis(),
      update: jest.fn().mockReturnThis(),
      delete: jest.fn().mockReturnThis(),
      eq: jest.fn().mockReturnThis(),
      order: jest.fn().mockReturnThis(),
      limit: jest.fn().mockReturnThis(),
      single: jest.fn(),
    }),
    auth: {
      signUp: jest.fn(),
      signInWithPassword: jest.fn(),
      signOut: jest.fn(),
      getUser: jest.fn(),
    },
    storage: {
      from: jest.fn().mockReturnValue({
        upload: jest.fn(),
        getPublicUrl: jest.fn(),
        remove: jest.fn(),
      }),
    },
  })),
}));

Local Instances for Integration Testing

Platform Local Dev Approach CI Setup
Appwrite Docker Compose (official image) Docker service in GitHub Actions
Nhost nhost dev CLI (Docker under the hood) nhost dev --detach in CI
PocketBase Single binary (./pocketbase serve) Download binary, run in background
Xata Cloud branch per PR xata branch create ci-${{ github.run_id }}
Supabase supabase start CLI supabase start in CI

PocketBase is the fastest to set up locally — download the binary, run it, and you have a full BaaS in seconds. Xata is the most "cloud-native" approach — no Docker at all, but requires internet access in CI.

PocketBase Local (Fastest to Start)

# Download the binary (one time)
wget https://github.com/pocketbase/pocketbase/releases/latest/download/pocketbase_linux_amd64.zip
unzip pocketbase_linux_amd64.zip

<span class="hljs-comment"># In CI, start it as a background service
./pocketbase serve --http=127.0.0.1:8090 --<span class="hljs-built_in">dir=./pb-test-data &

Supabase Local (Most Feature-Complete)

# Requires Supabase CLI + Docker
npx supabase start
<span class="hljs-comment"># Gives you: Postgres, Auth, Storage, Edge Functions, Realtime
<span class="hljs-comment"># With a local dashboard at http://localhost:54323

Permission Testing (The Most Important Tests)

Every BaaS platform has a permission model. Testing permissions is the highest-value integration test you can write — misconfigured permissions can expose user data to the wrong people.

A Universal Permission Test Pattern

describe('Row-level permissions', () => {
  let user1Token: string;
  let user2Token: string;
  let user1PostId: string;

  beforeAll(async () => {
    // Create two users and a post by user1
    user1Token = await signUpAndGetToken('user1@test.com', 'pass1');
    user2Token = await signUpAndGetToken('user2@test.com', 'pass2');
    user1PostId = await createPostAs(user1Token, { title: 'User1 Post' });
  });

  it('user1 can read their own post', async () => {
    const post = await getPostAs(user1Token, user1PostId);
    expect(post).not.toBeNull();
  });

  it('user2 cannot read user1's post', async () => {
    const result = await getPostAs(user2Token, user1PostId);
    // Depending on platform, this returns null or throws
    expect(result).toBeNull();
  });

  it('user1 can delete their own post', async () => {
    await expect(deletePostAs(user1Token, user1PostId)).resolves.not.toThrow();
  });

  it('user2 cannot delete user1's post', async () => {
    await expect(deletePostAs(user2Token, user1PostId)).rejects.toThrow();
  });
});

Adapt signUpAndGetToken, getPostAs, and deletePostAs to call your specific BaaS platform's API.

Auth Flow Testing Pattern

Auth flows are broadly similar across BaaS platforms:

// Universal auth test pattern — adapt method names per platform
describe('Auth flow', () => {
  const email = `test-${Date.now()}@example.com`;
  const password = 'ValidPass123!';

  it('registers successfully', async () => {
    const result = await platform.auth.signUp(email, password);
    expect(result.user.email).toBe(email);
  });

  it('signs in with correct credentials', async () => {
    const session = await platform.auth.signIn(email, password);
    expect(session.token).toBeDefined();
  });

  it('rejects wrong password', async () => {
    await expect(platform.auth.signIn(email, 'wrongpass')).rejects.toThrow();
  });

  it('can refresh access token', async () => {
    const original = await platform.auth.signIn(email, password);
    const refreshed = await platform.auth.refreshToken(original.refreshToken);
    expect(refreshed.token).toBeDefined();
    expect(refreshed.token).not.toBe(original.token);
  });

  it('signs out', async () => {
    await platform.auth.signIn(email, password);
    await platform.auth.signOut();
    expect(platform.auth.isAuthenticated()).toBe(false);
  });
});

File Storage Testing

// Universal storage test pattern
describe('File storage', () => {
  let fileId: string;

  it('uploads a file', async () => {
    const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
    const result = await platform.storage.upload(file);

    expect(result.id).toBeDefined();
    fileId = result.id;
  });

  it('generates a usable URL', async () => {
    const url = platform.storage.getUrl(fileId);
    const response = await fetch(url);
    expect(response.ok).toBe(true);
  });

  it('deletes the file', async () => {
    await platform.storage.delete(fileId);
    // Verify deletion — attempting to fetch should return 404
    const url = platform.storage.getUrl(fileId);
    const response = await fetch(url);
    expect(response.status).toBe(404);
  });
});

Choosing the Right Testing Depth

For prototypes and side projects: Unit tests only. Mock the SDK, test your business logic, deploy and observe.

For production apps with real users: Unit tests + integration tests for permissions and auth. Run integration tests against a local instance in CI.

For regulated or high-stakes apps: All three layers. Add explicit permission tests for every role combination. Consider contract tests to catch upstream API changes.

Platform Comparison: Testing Experience

Platform Local Testing Permission Testing Mock Quality SDK Type Safety
Appwrite Docker (medium setup) Collection rules Class-based, easy to mock TypeScript SDK
Nhost CLI (easy) Hasura RLS (powerful) Single client class GraphQL codegen
PocketBase Binary (instant) Collection rules SDK mockable TypeScript SDK
Xata Cloud branches (no Docker) Column-level Generated typed client Strong TypeScript
Supabase CLI (medium setup) PostgreSQL RLS (most powerful) Fluent chain Generated TypeScript

CI Best Practices for BaaS Apps

  1. Never use production credentials in CI. Every platform has a way to run locally — use it.
  2. Seed test data declaratively. Define your test fixtures as code, not manual setup.
  3. Clean up after each test. Delete records created in tests to prevent interference.
  4. Test permissions in separate suites. Permission tests are slow (multiple auth contexts). Keep them separate from unit tests for fast feedback loops.
  5. Rotate test credentials. Use generated emails per test run (test-${Date.now()}@example.com) to avoid conflicts.

For teams that want to run BaaS integration tests continuously against production environments, HelpMeTest provides scheduled test execution with alerts — so you know immediately when an Appwrite permission change or Supabase RLS policy update breaks a critical user flow.

Read more