Testing
Your tests run against a real deployment of your code — the release the push just built, loaded beside the one already serving, with your database, your secrets and your gateway in front of it. The new release is graded before it is given any traffic: green and it takes over, red and it is discarded, the previous release keeps serving, and the push reports the refusal. Nothing is mocked, because the failures worth catching in a backend only exist in the real stack — a row-level-security policy that hides everyone's rows, a schema change that landed while a query still reads the old column, an endpoint that returns the right shape with the wrong data.
Write a test
The name is what selects it, not the directory. A suite the deploy runs is named *.e2e.test.* — anywhere in the project, at any depth — and it lives beside the module it grades, modules/todos/todos.e2e.test.ts. Everything under tests/ travels too, also at any depth, which is where a project that predates the convention keeps its deploy suites. The recognised extensions are .test.ts, .test.js, .test.mts and .test.mjs; a helper module beside a suite is pulled in by whichever suite imports it.
A plain *.test.ts that is not .e2e. and not under tests/ is a unit test. It stays home — the deploy never sends it. That line is not cosmetic: it was once everything, and a project whose 160 CI suites travelled with the push had them run inside the runtime's own 384 MB container, where one suite alone wanted 425 MB. The kernel killed the process grading the release and that project could not ship at all.
Suites are ordinary bun:test files — the deploy grades them with bun test, so that is the runner to write against — with nothing to install and nothing to configure.
// modules/todos/todos.e2e.test.ts
import { beforeAll, describe, expect, it } from "bun:test";
import { api, testRun } from "@palbase/backend/test";
// `testRun` says what THIS run was given. With no stack to call — `npm test`,
// `palbase test --unit` — the whole file is skipped, because there is nothing
// for it to measure. Ask it for a SKIP CONDITION, never as a way out of an
// assertion that failed.
describe.skipIf(!testRun.stack)("todos, through the stack being graded", () => {
beforeAll(async () => {
// An identity this deploy minted for this run — see Test Users.
await api.signInAs("demo");
});
it("creates a todo and reads it back", async () => {
const created = await api.post<{ id: string }>("/todos", { title: "Milk" });
const got = await api.get<{ title: string }>(`/todos/${created.id}`);
expect(got.title).toBe("Milk");
});
});
api is already pointed at the release under test. Inside a deploy you never configure it.
Identities
signInAs(name) signs in as an account the deploy minted for this run: the platform generates the credentials, seeds the account from the template of the same name, and purges every one of them when the run ends — whatever the verdict. Switching between two identities costs no request at all, because the mint already issued each one's access token and signInAs simply swaps the bearer. That is deliberate: a suite that re-logs-in on every switch sends a burst of logins from one address and trips the login rate limiter, failing tests for a reason that has nothing to do with the code under test.
The names come from the templates this Environment holds — the same list palbase test-user templates prints. A stack with no templates mints no identities, which is not an error: every test that needs no login still runs, and signInAs throws naming the identities the run does have. See Test Users.
api.signIn({ email, password }) still takes credentials you supply, for a test that means to exercise the login rail itself. It really does POST /auth/login.
Types are yours
There is no generated client to keep in step, because you already wrote the types:
// modules/todos/dto/todo.ts
import { z } from "@palbase/backend";
export const Todo = z.object({ id: z.string(), title: z.string(), done: z.boolean() });
export type Todo = z.infer<typeof Todo>;
// modules/todos/todos-types.e2e.test.ts
import { expect, it } from "bun:test";
import { api } from "@palbase/backend/test";
import type { Todo } from "./dto/todo";
it("answers with todos that have titles", async () => {
const todos = await api.get<Todo[]>("/todos");
expect(todos.every((t) => t.title.length > 0)).toBe(true);
});
You can go further and check the answer against the very schema the endpoint declares — the same value the route names as its return type:
// modules/todos/todos-shape.e2e.test.ts
import { it } from "bun:test";
import { api } from "@palbase/backend/test";
import { Todo } from "./dto/todo";
it("still answers in the shape the route declares", async () => {
Todo.array().parse(await api.get("/todos")); // throws if the server drifted
});
Test what only a real stack can prove
This is the reason the tests run where they do. Nothing about the following can pass by accident, and no amount of typechecking replaces it:
import { expect, it } from "bun:test";
import { api, TestApiError } from "@palbase/backend/test";
it("one user cannot read another user's todo", async () => {
await api.signInAs("demo");
const mine = await api.post<{ id: string }>("/todos", { title: "Mine" });
await api.signInAs("crew");
// NARROWING, not a cast: `as TestApiError` would make a wrong answer look
// like the right type, and `instanceof` stops it here.
const refused = await api.get(`/todos/${mine.id}`).then(
() => null,
(e: unknown) => e,
);
if (!(refused instanceof TestApiError)) {
throw new Error("another user's todo was readable");
}
expect(refused.status).toBe(404);
});
Flows
A flow is just await. The response of one call is the input to the next, and your types carry through:
import { expect, it } from "bun:test";
import { api } from "@palbase/backend/test";
type Account = { id: string };
type Transfer = { id: string; status: string };
it("settles a transfer", async () => {
const [account] = await api.get<Account[]>("/accounts?limit=1");
const transfer = await api.post<Transfer>("/transfers", { from: account.id, to: "acc_2", amount: 100 });
const receipt = await api.get<Transfer>(`/transfers/${transfer.id}`);
expect(receipt.status).toBe("settled");
});
api.requests holds every call the suite made, in order, with its status and duration — so a failure is read as a chain rather than as one assertion.
The API surface
@palbase/backend/test exports eight values, and this section is about the first three. api, createTestApi and TestApiError are the HTTP half; testRun reads what the run was given (above); isolated, fakeDatabase, withServices and loadStringsTable are the unit half (below).
| Call | Notes |
|---|---|
api.get<T>(path, opts?) | opts is { headers } |
api.post<T>(path, body?, opts?) | |
api.patch<T> / api.put<T> / api.delete<T> | |
api.query<T>(path, body?, opts?) | HTTP QUERY — a safe, idempotent read whose filter travels in the body |
api.signInAs(name) | Reuses the token the mint already issued — no network call; answers with that identity |
testRun.stack / testRun.minted(name) | What this run was given: is there a stack to call, was a login minted under that fixture name |
api.signIn({ email, password }) | POST /auth/login, then carries the bearer |
api.signOut() | POST /auth/logout, then drops the bearer |
api.asAnonymous() | Drops the bearer without calling the server |
api.requests | Every call made, in order: { method, path, status, ms } |
Every call sends apikey and x-palbase-candidate; a bearer is added once you have signed in. A non-2xx answer throws a TestApiError carrying status, error (the wire code), data (the payload of an error your code threw) and body (the whole envelope exactly as the server sent it).
Asserting on a failure
An error your code threw puts its payload on data:
import { expect, it } from "bun:test";
import { api, TestApiError } from "@palbase/backend/test";
it("reports the payload of an error the code threw", async () => {
// throw new TodoLocked({ retryAfter: 30 })
const err: unknown = await api.patch("/todos/t_1", { done: true }).catch((e: unknown) => e);
if (!(err instanceof TestApiError)) throw new Error("the patch was not refused");
expect(err.status).toBe(409);
expect(err.error).toBe("todo_locked");
expect((err.data as { retryAfter: number }).retryAfter).toBe(30);
});
Input refused at the boundary — before your handler ran — answers with one entry per field in a top-level fields array:
import { expect, it } from "bun:test";
import { api, TestApiError } from "@palbase/backend/test";
it("reports one entry per refused field", async () => {
const err: unknown = await api.post("/todos", { title: "" }).catch((e: unknown) => e);
if (!(err instanceof TestApiError)) throw new Error("an empty title was accepted");
expect(err.status).toBe(400);
expect(err.error).toBe("bad_request");
expect(err.body.fields).toEqual([
{ field: "title", message: "String must contain at least 1 character(s)" },
]);
});
Warning:
ErrorEnvelopedeclares an optionaldetails?: Array<{ field, message }>, and it is never populated — the engine sendsfields, at the top level, with the codebad_request. An assertion written againsterr.body.detailsorerr.error === "validation_error"fails on every run. The envelope type carries an open index signature precisely soerr.body.fieldsis readable anyway. See Responses & Errors.
What the deploy does with them
palbase push sends one request that carries the code and the schema together, and the stack does the rest in this order:
- Applies the schema and stores the pushed source under the build's digest.
- Mints one identity per template this Environment holds, and puts them in the run's environment as JSON. Failure here refuses the push with
test_identities_unavailable. - Loads the new release as a candidate — a second loaded app inside the same process, addressable only by a random token minted for this run. Failure here refuses with
candidate_failed. - Checks every
@Uploadbucket against the buckets the stack actually holds, before a single test runs — storage does not create a bucket on demand, so a name that does not exist is a route that deploys and then 404s the first file anyone uploads. - Runs the bundled suites with
bun test, one bundle per suite, inside a budget that defaults to 5 minutes. - Green: promotes. The candidate takes traffic first and the old release's pool is drained after, so there is no window where a request meets a closed pool. Red: discards. The live release never moved.
- Purges the run's identities, pass or fail.
| Refusal code | What happened |
|---|---|
tests_failed | a suite failed; the candidate was discarded |
tests_timed_out | the run did not finish inside the budget and was stopped — a hang and a failure are different news |
candidate_failed | the release could not be loaded, or the @Upload bucket check refused it |
test_identities_unavailable | the run's logins could not be minted |
schema_incompatible | the schema change would break the release that is still serving while the swap happens — split it across two deploys |
The CLI prints the refusal's own description verbatim, then push refused (<code>) — nothing was swapped, the previous release keeps serving.
Warning: the first deploy to an Environment is the one exception, and it says so in its own error. There is no previous release to keep serving and nothing to roll back to, so the release is activated first and graded after. A failure there reports that the new release is live and could not be rolled back — fix forward.
Note: your tests write to your real database, as the identities the deploy minted. That is the point: it is the same data path your app uses. Those accounts are flagged
is_test, so they are excluded from your MAU and your bill, and they are purged at the end of the run.
Projects without tests
Deploy suites are optional, and a project with none deploys normally — the suite list is simply empty and the candidate is promoted once it loads. Everything else in the sequence above still runs: the schema gate, the identity mint, the @Upload bucket check and the load itself.
Running them yourself
You normally do not need to. If you want to point a suite at a live Environment by hand, createTestApi is exported and the client reads four environment variables that the deploy otherwise sets for you:
| Variable | What it is |
|---|---|
PALBASE_TEST_BASE_URL | where to send requests — your Environment's address |
PALBASE_TEST_API_KEY | sent as the apikey header (the publishable key) |
PALBASE_TEST_CANDIDATE | sent as x-palbase-candidate — selects the release under test; live grades the release that is serving |
PALBASE_TEST_IDENTITIES | JSON { "<name>": { id, email, password, accessToken } } for signInAs |
The first three are required: the client refuses to construct without them, naming the one that is missing. A suite that means to grade the live release says so with PALBASE_TEST_CANDIDATE=live — which is what palbase test --live sets.
Warning: the candidate token is minted per deploy and is the only thing standing between an unreleased build and ordinary traffic. A request carrying a wrong or stale token is served the live release rather than refused — so a hand-run suite against a real Environment runs against production code and writes production data. There is no way to address a candidate that is not currently loaded.
Unit tests: the constructor is the seam
Not everything needs a deployment. The decisions — which rows, whose, in what order — live in a @Injectable() service, and a service names what it needs as ordinary constructor parameters. A test hands in a stand-in instead, and needs no database at all.
These files live beside the code they test, inside modules/<domain>/, and their names carry no .e2e. — that is what keeps the deploy from collecting them. npm test runs bun test.
Prefer palbase test, which runs both layers and grades the unit one properly:
| Command | What it runs |
|---|---|
palbase test | both layers |
palbase test --unit | the service layer only — no stack needed |
palbase test --live | the HTTP layer only; mints this run's identities, exports the PALBASE_TEST_* variables, and deletes the identities and their data afterwards, pass or fail |
The unit layer calls bun test directly — the runner a deploy grades your suites with, whatever package.json's test script says — and it reads the run's SUMMARY rather than just the exit code. That matters because bun test exits 0 both when something calls process.exit() mid-suite and when it discovered no test at all; palbase test refuses either instead of reporting a pass.
isolated() — substitute a constructor dependency
// modules/notes/notes.service.ts
import { Database, Injectable } from "@palbase/backend";
import type { Tables } from "@palbase/backend/env";
export type Note = Tables["notes"]["row"];
export abstract class NoteRepo {
abstract findMany(where: { user_id: string }): Promise<Note[]>;
}
@Injectable()
export class DbNoteRepo extends NoteRepo {
findMany(where: { user_id: string }): Promise<Note[]> {
return Database.public.notes.findMany({ where });
}
}
@Injectable()
export class NoteService {
constructor(private readonly notes: NoteRepo) {}
list(userId: string): Promise<Note[]> {
return this.notes.findMany({ user_id: userId });
}
}
// modules/notes/notes.service.test.ts
import { describe, expect, it } from "bun:test";
import { isolated } from "@palbase/backend/test";
import { type Note, NoteRepo, NoteService } from "./notes.service";
class FakeRepo extends NoteRepo {
readonly seen: unknown[] = [];
async findMany(where: { user_id: string }): Promise<Note[]> {
this.seen.push(where);
return [];
}
}
describe("NoteService", () => {
it("asks only for the caller's notes", async () => {
const repo = new FakeRepo();
const svc = isolated().with(NoteRepo, repo).get(NoteService);
await svc.list("u_1");
expect(repo.seen).toEqual([{ user_id: "u_1" }]);
});
});
isolated() rebuilds the graph with the override in place and touches no process-wide state, so the next test in the file does not meet whatever this one substituted. The substitution is deep — it works the same when the overridden class is two hops below the class under test — which is what makes the constructor worth using as the seam. Module boundaries are deliberately not enforced here: a unit test gets a graph, not a second opinion about your architecture.
The abstraction is the token and the implementation is the provider, so a test overrides the abstract class (NoteRepo), which is exactly the name the service asked for.
withServices() — substitute a platform service
isolated() reaches constructor dependencies. The platform services — Database, Storage, Cache, Log, Notifications, Secrets, Documents, Flags, Auth, Realtime — are ambient: imported, never injected, and resolved out of the current request's scope. Code that imports one directly is tested by opening a scope that carries your stand-in:
// modules/notes/notes.repo.test.ts
import { expect, it } from "bun:test";
import { fakeDatabase, withServices } from "@palbase/backend/test";
import { DbNoteRepo } from "./notes.service";
it("reads the caller's notes off Database.public.notes", async () => {
const fake = fakeDatabase();
fake.seed("notes", [{ id: "n_1", user_id: "u_1", body: "hello" }]);
// `fake.raw` — the runtime wraps the RAW client, so passing `fake.db` here
// would wrap an already-wrapped surface and every `$op` would miss.
const rows = await withServices({ Database: fake.raw }, () =>
new DbNoteRepo().findMany({ user_id: "u_1" }),
);
expect(rows).toHaveLength(1);
});
A service you do not supply is not undefined and not a silent no-op: touching it throws, naming itself and the fix, so a test that quietly grew a dependency on Cache fails saying Cache.
Code that calls t() is tested in a language by handing withServices the table and the caller's Accept-Language — loadStringsTable(root) reads the same palbase/strings/ the stack serves (Localization):
// modules/billing/decline.test.ts
import { expect, it } from "bun:test";
import { t } from "@palbase/backend";
import { loadStringsTable, withServices } from "@palbase/backend/test";
// The code under test: the key is the sentence with its placeholder; the
// amount arrives with the call.
function declineMessage(amount: number): string {
return t("Kartınız reddedildi ({{amount}} ₺)", { amount });
}
it("answers in the caller's language", async () => {
const strings = await loadStringsTable(process.cwd());
const message = withServices({}, () => declineMessage(50), { strings, acceptLanguage: "en" });
expect(message).toBe("Your card was declined (50 ₺)");
});
fakeDatabase() answers from memory and is built with the same constructor the real Database uses, so its surface cannot drift from the real one. It does not interpret SQL: $query() records what it was asked and answers no rows. Assert on fake.queries when the SQL is the thing under test, and put anything that depends on what SQL returns in a live suite under tests/. It also enforces no RLS, no constraints and no unique violations — which is the other half of why the tests above this section exist.
npm test answers "is the logic right". It is not the deploy's own validation — decorators, return types, SDK major — which answers "would this ship".
Related
- Test Users — the templates the run's identities are minted from
- Responses & Errors — the two 400 shapes the assertions above depend on
- Deploying — what
palbase pushsends, and what it prints when a suite refuses it - Row-Level Security — the isolation a two-identity test is proving
- CLI: Test Users — minting, listing and cloning accounts outside a deploy