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 testing only — 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.goTestApp wraps core.BaseApp, clones the fixture directory per test, bootstraps a real SQLite database, and registers hook handlers for every app lifecycle event to populate EventCalls map[string]int
  • api.goApiScenario struct + Test(t) / Benchmark(b) methods; drives HTTP integration tests via httptest.NewRecorder() and a real router
  • mailer.goTestMailer stub captures outgoing mail messages in memory for assertion
  • request.goMockMultipartData helper builds multipart/form-data request bodies from field maps and file field names
  • validation_errors.goTestValidationErrors asserts that a validation error set contains exactly the expected field keys
  • dynamic_stubs.goStubOTPRecords, StubMFARecords, StubLogsData insert 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.goTestRecordCrudList builds a scenarios []tests.ApiScenario, then iterates with for _, scenario := range scenarios { scenario.Test(t) }. Each scenario.Test(t) calls t.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. TestApp bootstraps an actual core.BaseApp with SQLite. External services are replaced with in-process stubs:
    • TestMailer implements core.Mailer in memory; injected via the OnMailerSend hook at Priority: -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.BaseApp directly 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. The OnServe hook 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.go files. The distinction is implicit: apis/ tests always use TestApp; 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 via DisableTestAppCleanup = false by default.
  • ApiScenario as 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 OnRecordCreate fired 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 call t.Parallel() allowing parallel test function execution.

What could improve#

  • No benchmark coverage: the Benchmark infrastructure on ApiScenario is 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.BaseApp per scenario is expensive. With 144 scenario-heavy files each spawning multiple sub-tests, the full go 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.db is 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.App interface: 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#

  1. ApiScenario declarative HTTP testing struct — applicable to any project with a REST API. The ExpectedEvents hook-count assertions can generalize to any observable side-effect system.
  2. 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.).
  3. 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.