PocketBase Testing Guide: Testing Your PocketBase Backend
PocketBase is an open-source backend in a single Go binary: embedded SQLite database, realtime subscriptions, file storage, auth, and an extensible Go framework — all in one 30MB executable. It's become popular for indie projects, prototypes, and small teams who want a real backend without the operational overhead.
Testing PocketBase-backed applications has a few distinct challenges: the database is SQLite (which runs in-memory easily), PocketBase can be embedded directly in Go test binaries, and the JavaScript SDK mirrors the REST API closely. This guide covers all three layers.
How PocketBase Testing Works
PocketBase offers two extension models:
- REST API only — you use PocketBase as a black-box server and call its REST API from your frontend or backend
- Go framework — you embed PocketBase in your Go binary and add hooks, routes, and migrations
Testing strategy differs between these two. API-only apps test against a running PocketBase server; Go framework apps can use PocketBase's embedded test utilities.
Unit Testing Go Hooks
If you're using PocketBase as a Go framework, you can write proper Go unit tests for your hooks and custom routes.
Basic PocketBase Test App
// testutils/app.go
package testutils
import (
"os"
"testing"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/tests"
)
// NewTestApp creates an isolated PocketBase app for testing.
func NewTestApp(t *testing.T) *tests.TestApp {
t.Helper()
// Create a temp directory for the test database
testDir, err := os.MkdirTemp("", "pb-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() {
os.RemoveAll(testDir)
})
app, err := tests.NewTestApp(testDir)
if err != nil {
t.Fatalf("failed to create test app: %v", err)
}
t.Cleanup(func() {
app.Cleanup()
})
return app
}Testing a Record Hook
Suppose you have a hook that automatically sets a slug field when a post is created:
// hooks/posts.go
package hooks
import (
"strings"
"github.com/pocketbase/pocketbase/core"
)
func RegisterPostHooks(app core.App) {
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordEvent) error {
title := e.Record.GetString("title")
slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
e.Record.Set("slug", slug)
return e.Next()
})
}Testing this hook:
// hooks/posts_test.go
package hooks_test
import (
"testing"
"github.com/pocketbase/pocketbase/core"
"yourapp/hooks"
"yourapp/testutils"
)
func TestPostSlugGeneration(t *testing.T) {
app := testutils.NewTestApp(t)
hooks.RegisterPostHooks(app)
// Create a collection and record via the test app
collection, err := app.FindCollectionByNameOrId("posts")
if err != nil {
t.Fatalf("posts collection not found: %v", err)
}
record := core.NewRecord(collection)
record.Set("title", "Hello World Post")
record.Set("content", "Test content")
if err := app.Save(record); err != nil {
t.Fatalf("failed to save record: %v", err)
}
if got := record.GetString("slug"); got != "hello-world-post" {
t.Errorf("expected slug = %q, got %q", "hello-world-post", got)
}
}
func TestPostSlugWithSpecialChars(t *testing.T) {
app := testutils.NewTestApp(t)
hooks.RegisterPostHooks(app)
collection, _ := app.FindCollectionByNameOrId("posts")
record := core.NewRecord(collection)
record.Set("title", "C++ Testing Guide")
if err := app.Save(record); err != nil {
t.Fatalf("failed to save record: %v", err)
}
slug := record.GetString("slug")
if slug == "" {
t.Error("slug should not be empty")
}
}Testing Custom API Routes
PocketBase lets you register custom routes. Test them using the tests.ApiScenario helper:
// routes/stats_test.go
package routes_test
import (
"net/http"
"testing"
"github.com/pocketbase/pocketbase/tests"
"yourapp/routes"
"yourapp/testutils"
)
func TestStatsEndpoint(t *testing.T) {
app := testutils.NewTestApp(t)
routes.RegisterRoutes(app)
scenarios := []tests.ApiScenario{
{
Name: "unauthenticated returns 401",
Method: http.MethodGet,
URL: "/api/custom/stats",
ExpectedStatus: 401,
ExpectedContent: []string{`"message":"Unauthorized"`},
},
{
Name: "admin can access stats",
Method: http.MethodGet,
URL: "/api/custom/stats",
RequestHeaders: map[string]string{
"Authorization": "Bearer " + tests.GenerateTestAdminToken(app),
},
ExpectedStatus: 200,
ExpectedContent: []string{`"totalPosts"`, `"totalUsers"`},
},
}
for _, scenario := range scenarios {
scenario.Test(t, app)
}
}Integration Testing via the REST API
For apps that treat PocketBase as a black-box API server (no Go customization), run PocketBase in a test process and call its REST API.
Starting PocketBase in Tests
// tests/integration/setup.ts
import { spawn, ChildProcess } from 'child_process';
import { PocketBase } from 'pocketbase';
import { waitForPort } from './waitForPort';
let pb_process: ChildProcess;
export async function startPocketBase(): Promise<PocketBase> {
pb_process = spawn('./pocketbase', ['serve', '--http=127.0.0.1:8090', '--dir=./test-pb-data'], {
stdio: 'pipe',
});
await waitForPort(8090, 10_000);
const pb = new PocketBase('http://127.0.0.1:8090');
// Authenticate as admin for test setup
await pb.admins.authWithPassword(
process.env.PB_ADMIN_EMAIL!,
process.env.PB_ADMIN_PASSWORD!,
);
return pb;
}
export async function stopPocketBase() {
pb_process?.kill();
}Writing Integration Tests
// tests/integration/records.test.ts
import { PocketBase } from 'pocketbase';
import { startPocketBase, stopPocketBase } from './setup';
let pb: PocketBase;
beforeAll(async () => {
pb = await startPocketBase();
});
afterAll(async () => {
await stopPocketBase();
});
describe('Posts collection', () => {
afterEach(async () => {
const records = await pb.collection('posts').getFullList();
for (const record of records) {
await pb.collection('posts').delete(record.id);
}
});
it('creates a post record', async () => {
const post = await pb.collection('posts').create({
title: 'Integration Test Post',
content: 'Test body',
published: false,
});
expect(post.id).toBeDefined();
expect(post.title).toBe('Integration Test Post');
expect(post.published).toBe(false);
});
it('filters published posts', async () => {
await pb.collection('posts').create({ title: 'Published', content: '', published: true });
await pb.collection('posts').create({ title: 'Draft', content: '', published: false });
const result = await pb.collection('posts').getList(1, 50, {
filter: 'published = true',
});
expect(result.items).toHaveLength(1);
expect(result.items[0].title).toBe('Published');
});
it('sorts by created date descending', async () => {
await pb.collection('posts').create({ title: 'Older', content: '' });
await new Promise((r) => setTimeout(r, 100));
await pb.collection('posts').create({ title: 'Newer', content: '' });
const result = await pb.collection('posts').getList(1, 50, {
sort: '-created',
});
expect(result.items[0].title).toBe('Newer');
});
});Testing PocketBase Auth
// tests/integration/auth.test.ts
import { PocketBase, ClientResponseError } from 'pocketbase';
let pb: PocketBase;
beforeAll(async () => {
pb = new PocketBase('http://127.0.0.1:8090');
});
const testEmail = `test-${Date.now()}@example.com`;
describe('User authentication', () => {
it('registers a new user', async () => {
const user = await pb.collection('users').create({
email: testEmail,
password: 'SecurePass123!',
passwordConfirm: 'SecurePass123!',
name: 'Integration Tester',
});
expect(user.email).toBe(testEmail);
expect(user.name).toBe('Integration Tester');
});
it('authenticates with valid credentials', async () => {
const auth = await pb.collection('users').authWithPassword(
testEmail,
'SecurePass123!',
);
expect(auth.record.email).toBe(testEmail);
expect(auth.token).toBeDefined();
expect(pb.authStore.isValid).toBe(true);
});
it('rejects invalid password', async () => {
await expect(
pb.collection('users').authWithPassword(testEmail, 'wrongpassword'),
).rejects.toBeInstanceOf(ClientResponseError);
});
it('can refresh token', async () => {
await pb.collection('users').authWithPassword(testEmail, 'SecurePass123!');
const refreshed = await pb.collection('users').authRefresh();
expect(refreshed.token).toBeDefined();
});
});Testing Realtime Subscriptions
PocketBase's realtime subscriptions use SSE under the hood. Test them with a small helper:
// tests/integration/realtime.test.ts
import { PocketBase } from 'pocketbase';
it('receives realtime create event', async () => {
const pb = new PocketBase('http://127.0.0.1:8090');
// authenticate...
const received: unknown[] = [];
await pb.collection('posts').subscribe('*', (event) => {
received.push(event);
});
await pb.collection('posts').create({ title: 'Realtime Test', content: '' });
// Wait for the event to arrive
await new Promise((r) => setTimeout(r, 500));
expect(received).toHaveLength(1);
expect((received[0] as any).action).toBe('create');
expect((received[0] as any).record.title).toBe('Realtime Test');
await pb.collection('posts').unsubscribe();
});JavaScript SDK Unit Testing
For frontend code that calls PocketBase, mock the SDK:
// __mocks__/pocketbase.ts
export default class PocketBase {
authStore = {
isValid: false,
token: '',
model: null,
clear: jest.fn(),
};
collection(name: string) {
return {
getList: jest.fn(),
getFullList: jest.fn(),
getOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
authWithPassword: jest.fn(),
subscribe: jest.fn(),
unsubscribe: jest.fn(),
};
}
admins = {
authWithPassword: jest.fn(),
};
}Then in your component tests:
// src/components/PostList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import PocketBase from 'pocketbase';
import { PostList } from './PostList';
jest.mock('pocketbase');
const mockPb = new PocketBase('http://localhost');
test('displays posts from PocketBase', async () => {
const mockPosts = {
items: [
{ id: '1', title: 'First Post', published: true },
{ id: '2', title: 'Second Post', published: true },
],
totalItems: 2,
page: 1,
};
jest.spyOn(mockPb.collection('posts'), 'getList').mockResolvedValue(
mockPosts as any,
);
render(<PostList pb={mockPb} />);
await waitFor(() => {
expect(screen.getByText('First Post')).toBeInTheDocument();
expect(screen.getByText('Second Post')).toBeInTheDocument();
});
});Testing Patterns That Work Well with PocketBase
Use in-memory SQLite for speed. PocketBase's embedded SQLite runs without a separate process, which means integration tests start fast. Your full integration suite can run in seconds rather than minutes.
Test collection rules, not just data. PocketBase's collection-level access rules (createRule, listRule, viewRule, updateRule, deleteRule) are PocketBase filter expressions. Test them explicitly by calling the API as different user roles and verifying the expected 403 or 404 responses.
Reset state with full-list deletes. Between tests, query all records and delete them. PocketBase has no built-in "truncate" command, so this is the cleanest approach for small test datasets.
Avoid shared test databases. Each test file or suite should get its own --dir pointing to a fresh temp directory. This prevents test interference and makes parallel execution safe.
For production PocketBase apps, HelpMeTest provides 24/7 monitoring and automated test execution — so you know immediately when a collection rule change breaks auth or a hook regression slips into production.