PocketBase — Testing#
Test metrics#
- Test files: 180
- Source files: 262 (442 total Go files minus 180 test files)
- Ratio (test files / source files): 0.69 — exceptionally high; nearly one test file per source file
- Test frameworks: stdlib
testingonly — no testify, gomock, ginkgo, or any third-party assertion library
Test organization#
Placement#
Both same-package white-box tests (package hook) and external black-box tests (package apis_test, package core_test, package store_test, package forms_test). The choice follows the principle of testing the public API in most packages; only low-level utility packages like tools/hook use the white-box form.
Helper packages#
github.com/pocketbase/pocketbase/tests (5 Go files) — the central test infrastructure:
app.go—TestAppwrapscore.BaseApp, clones the fixture directory per test, bootstraps a real SQLite database, and registers hook handlers for every app lifecycle event to populateEventCalls map[string]intapi.go—ApiScenariostruct +Test(t)/Benchmark(b)methods; drives HTTP integration tests viahttptest.NewRecorder()and a real routermailer.go—TestMailerstub captures outgoing mail messages in memory for assertionrequest.go—MockMultipartDatahelper builds multipart/form-data request bodies from field maps and file field namesvalidation_errors.go—TestValidationErrorsasserts that a validation error set contains exactly the expected field keysdynamic_stubs.go—StubOTPRecords,StubMFARecords,StubLogsDatainsert deterministic fixture rows into the live DB before auth-flow tests
Fixtures#
tests/data/ contains committed SQLite databases (data.db, auxiliary.db) and a storage/ tree with real image and text files. NewTestApp() uses TempDirClone() to deep-copy the entire data/ directory into a os.MkdirTemp location for each test invocation, giving every test a pristine, isolated copy of the state.
Test patterns#
Table-driven tests#
- Prevalence: Heavy — 144 of 180 test files contain a
scenarios := []...slice - Style: Named struct slice for API tests (
[]tests.ApiScenario{...}); anonymous struct slices for lower-level unit tests ([]struct { input ...; expected ... }) - Example:
apis/record_crud_test.go—TestRecordCrudListbuilds ascenarios []tests.ApiScenario, then iterates withfor _, scenario := range scenarios { scenario.Test(t) }. Eachscenario.Test(t)callst.Run(scenario.Name, ...)internally, producing a proper sub-test tree.
The ApiScenario struct fields encode the full test contract:
type ApiScenario struct {
Name string
Method string
URL string
Body io.Reader
Headers map[string]string
Delay time.Duration // for async side-effects
Timeout time.Duration
ExpectedStatus int
ExpectedContent []string // substrings that MUST appear
NotExpectedContent []string // substrings that MUST NOT appear
ExpectedEvents map[string]int // hook → call count; "*": 0 = no other events
TestAppFactory func(t testing.TB) *TestApp
BeforeTestFunc func(t testing.TB, app *TestApp, e *core.ServeEvent)
AfterTestFunc func(t testing.TB, app *TestApp, res *http.Response)
}Mocking approach#
- Strategy: No mocks — real implementations everywhere.
TestAppbootstraps an actualcore.BaseAppwith SQLite. External services are replaced with in-process stubs:TestMailerimplementscore.Mailerin memory; injected via theOnMailerSendhook atPriority: -99999(lower than any user handler, ensuring it always wins)- OAuth2 providers are faked via
BeforeTestFunc(registers a stub provider on the app before the request fires)
- Assessment: This is the deliberate design decision documented in the patterns analysis: “Tests use
core.BaseAppdirectly without mocking.” The reasoning is that SQLite is fast enough in-process to make real-DB tests viable with zero mock maintenance cost.
Integration tests#
- Present: Yes — all API-layer tests are integration tests in everything but name
- How:
httptest.NewRecorder()+ a fully constructed router (apis.NewRouter(testApp)) against a real in-process SQLite database with committed fixture data. TheOnServehook is manually triggered to ensure middleware registration is identical to production. - Separation: No separate build tags or directories. Integration tests live alongside unit tests in the same
_test.gofiles. The distinction is implicit:apis/tests always useTestApp;tools/tests rarely need it.
Hook event assertions#
A distinctive pattern unique to PocketBase: every ApiScenario can assert not just the HTTP response, but also which hook events fired and how many times:
ExpectedEvents: map[string]int{
"*": 0, // no other events
"OnRecordsListRequest": 1,
"OnRecordEnrich": 3,
},The wildcard "*": 0 asserts that no events other than the listed ones were fired — catching accidental hook triggers in production code. This tests the event system’s correctness as deeply as it tests business logic.
Benchmark support#
ApiScenario.Benchmark(b *testing.B) method runs the same scenario definition under b.Run, allowing any HTTP scenario to be benchmarked without duplication. No benchmark files were found to be actively using this in the committed tree, but the infrastructure is in place.
Test quality observations#
What’s done well#
- Complete isolation via temp-dir cloning: every test gets a clean SQLite snapshot; no shared mutable state between tests.
defer testApp.Cleanup()is enforced viaDisableTestAppCleanup = falseby default. ApiScenarioas a DSL: the struct-based scenario system makes HTTP integration tests declarative and readable — you can scan 50 scenarios at a glance and understand what’s being tested without parsing test logic.- Hook event assertions: testing that
OnRecordCreatefired exactly once per record save catches bugs that a pure HTTP response check would miss (e.g., event handlers silently swallowed). - Stdlib only: no dependency on testify means no version drift, no assertion library opinion differences, and the codebase stays compilable with just
go test ./.... - Real SQLite, not mocks: eliminates an entire class of mock/reality divergence bugs. The patterns analysis references a known risk (mock tests passing while real migrations fail); PocketBase sidesteps this entirely.
t.Parallel()at the function level: API test functions callt.Parallel()allowing parallel test function execution.
What could improve#
- No benchmark coverage: the
Benchmarkinfrastructure onApiScenariois unused — there are no committed benchmark measurements for hot paths like record list or auth. - No fuzz tests: the filter/sort parser (
tools/search) is a natural candidate for fuzzing; none exists. - Slow test suite: cloning SQLite + bootstrapping
core.BaseAppper scenario is expensive. With 144 scenario-heavy files each spawning multiple sub-tests, the fullgo test ./...run takes non-trivial time (the CI workflow has no test timeout override, relying on the default 10-minute limit). - Fixture maintenance burden: the committed
data.dbis a binary SQLite file. Schema migrations must keep it in sync manually; divergence would silently fail tests rather than produce a clear “migration needed” error. - No contract tests for the
core.Appinterface: since the “fat interface” approach deliberately discourages external implementations, there are no consumer-driven contract tests to ensure the interface doesn’t drift from what callers need.
Patterns worth emulating#
ApiScenariodeclarative HTTP testing struct — applicable to any project with a REST API. TheExpectedEventshook-count assertions can generalize to any observable side-effect system.- TestApp via temp-dir fixture clone — the pattern of committing a real DB snapshot and deep-copying per test gives integration-level fidelity without external infrastructure. Works best with embedded or fast local databases (SQLite, bbolt, etc.).
- Stub injection via the hook system — rather than constructor injection or build-time mocks, PocketBase swaps in test doubles (mailer stub) by registering a high-priority hook handler that replaces the real service. Clean, zero-interface-change technique for testing side effects.