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:54323Permission 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
- Never use production credentials in CI. Every platform has a way to run locally — use it.
- Seed test data declaratively. Define your test fixtures as code, not manual setup.
- Clean up after each test. Delete records created in tests to prevent interference.
- Test permissions in separate suites. Permission tests are slow (multiple auth contexts). Keep them separate from unit tests for fast feedback loops.
- 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.