6.8 KiB
Test Hooks and Routes with tests.NewTestApp and ApiScenario
PocketBase ships a tests package specifically for integration-testing Go extensions. tests.NewTestApp(testDataDir) builds a fully-wired core.App over a temp copy of your test data directory, so you can register hooks, fire requests through the real router, and assert on the resulting DB state without spinning up a real HTTP server or touching pb_data/. The tests.ApiScenario struct drives the router the same way a real HTTP client would, including middleware and transactions. Curl-based shell tests cannot do either of these things.
Incorrect (hand-rolled HTTP client, shared dev DB, no hook reset):
// ❌ Hits the actual dev server - depends on side-effects from a previous run
func TestCreatePost(t *testing.T) {
resp, _ := http.Post("http://localhost:8090/api/collections/posts/records",
"application/json",
strings.NewReader(`{"title":"hi"}`))
if resp.StatusCode != 200 {
t.Fatal("bad status")
}
// ❌ No DB assertion, no cleanup, no hook verification
}
Correct (NewTestApp + ApiScenario + AfterTestFunc assertions):
// internal/app/posts_test.go
package app_test
import (
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"myapp/internal/hooks" // your hook registration
)
// testDataDir is a checked-in pb_data snapshot with your collections.
// Create it once with `./pocketbase --dir ./test_pb_data migrate up`
// and commit it to your test fixtures.
const testDataDir = "../../test_pb_data"
func TestCreatePostFiresAudit(t *testing.T) {
// Each test gets its own copy of testDataDir - parallel-safe
app, err := tests.NewTestApp(testDataDir)
if err != nil {
t.Fatal(err)
}
defer app.Cleanup() // REQUIRED - removes the temp copy
// Register the hook under test against this isolated app
hooks.RegisterPostHooks(app)
scenario := tests.ApiScenario{
Name: "POST /api/collections/posts/records as verified user",
Method: http.MethodPost,
URL: "/api/collections/posts/records",
Body: strings.NewReader(`{"title":"hello","slug":"hello"}`),
Headers: map[string]string{
"Authorization": testAuthHeader(app, "users", "alice@example.com"),
"Content-Type": "application/json",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"title":"hello"`,
`"slug":"hello"`,
},
NotExpectedContent: []string{
`"internalNotes"`, // the enrich hook should hide this
},
ExpectedEvents: map[string]int{
"OnRecordCreateRequest": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordEnrich": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
// Assert side-effects in the DB using the SAME app instance
audits, err := app.FindRecordsByFilter(
"audit",
"action = 'post.create'",
"-created", 10, 0,
)
if err != nil {
t.Fatal(err)
}
if len(audits) != 1 {
t.Fatalf("expected 1 audit record, got %d", len(audits))
}
},
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
}
scenario.Test(t)
}
Table-driven variant (authz matrix):
func TestPostsListAuthz(t *testing.T) {
for _, tc := range []struct {
name string
auth string // "", "users:alice", "users:bob", "_superusers:root"
expect int
}{
{"guest gets public posts", "", 200},
{"authed gets own + public", "users:alice", 200},
{"superuser sees everything", "_superusers:root",200},
} {
t.Run(tc.name, func(t *testing.T) {
app, _ := tests.NewTestApp(testDataDir)
defer app.Cleanup()
hooks.RegisterPostHooks(app)
tests.ApiScenario{
Method: http.MethodGet,
URL: "/api/collections/posts/records",
Headers: authHeaderFor(app, tc.auth),
ExpectedStatus: tc.expect,
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
}.Test(t)
})
}
}
Unit-testing a hook in isolation (no HTTP layer):
func TestAuditHookRollsBackOnAuditFailure(t *testing.T) {
app, _ := tests.NewTestApp(testDataDir)
defer app.Cleanup()
hooks.RegisterPostHooks(app)
// Delete the audit collection so the hook's Save fails
audit, _ := app.FindCollectionByNameOrId("audit")
_ = app.Delete(audit)
col, _ := app.FindCollectionByNameOrId("posts")
post := core.NewRecord(col)
post.Set("title", "should rollback")
post.Set("slug", "rollback")
if err := app.Save(post); err == nil {
t.Fatal("expected Save to fail because audit hook errored")
}
// Assert the post was NOT persisted (tx rolled back)
_, err := app.FindFirstRecordByFilter("posts", "slug = 'rollback'", nil)
if err == nil {
t.Fatal("post should not exist after rollback")
}
}
Rules:
- Always
defer app.Cleanup()- otherwise temp directories leak under/tmp. - Use a checked-in
test_pb_data/fixture with the collections you need. Do not depend on the devpb_data/- tests must be hermetic. - Register hooks against the test app, not against a package-level
appsingleton. The test app is a fresh instance each time. ExpectedEventsasserts that specific hooks fired the expected number of times - use it to catch "hook silently skipped because someone forgote.Next()" regressions.AfterTestFuncruns with the same app instance the scenario used, so you can query the DB to verify side-effects.- Parallelize with
t.Parallel()-NewTestAppgives each goroutine its own copy, so there's no shared state. - Tests run pure-Go SQLite (
modernc.org/sqlite) - no CGO, no extra setup, works ongo test ./...out of the box. - For JSVM, there is no equivalent test harness yet - test pb_hooks by booting
tests.NewTestAppwith thepb_hooks/directory populated and exercising the router from Go. Pure-JS unit testing of hook bodies requires extracting the logic into arequire()able module.
Reference: Testing · tests package GoDoc