Tests live in tests/ as *.test.js files and run with test_run (or gipity test). A test calls your functions over REST (ctx.fn.call) and/or imports your app's own modules directly. You do NOT need to gipity deploy before testing - the run registers your functions from the current working tree first, so a freshly-written or just-edited function is callable immediately. Every template has a test story - frontend-only ones included. For writing the functions under test see app-development.
In test files, do NOT import node:test or node:assert - the harness provides test() and assert as globals. Writing import { test } from 'node:test' or import assert from 'node:assert/strict' will cause a duplicate-identifier SyntaxError.
Import the module you're testing - functions/, src/, AND tests/ are all mounted beside the test. A function helper (import { sanitize } from '../../functions/song-render/sanitize.js') and a frontend module (import { decideWinner } from '../src/js/game.js', including kits under src/packages/) both resolve. That's how a frontend-only app - a game, a camera app - unit-tests its own logic: keep the rules in a module that doesn't touch document at import time, and test it directly. Everything under tests/ is mounted beside your test files - only *.test.js/*.test.mjs execute as tests, so shared helper modules (import { makeUser } from '../helpers/seed.js') and binary fixtures (readFileSync('tests/fixtures/sample.pdf')) both just work; never base64-inline a fixture into a test file. The harness is Node, not a browser: there's no DOM, so verify rendering with gipity page inspect <url> / gipity page eval instead.
Writing a test file
Create a test file for each function. ctx.fn.call / callAs return your function's return value directly - the { data } envelope is unwrapped, exactly like the client Gipity.fn - so read result.field (an error your function returns is result.error). They throw on a 4xx/5xx.
// tests/api/get-items.test.js
test('get-items returns items for a category', async (ctx) => {
const result = await ctx.fn.call('get-items', { category: 'fruit' });
assert.ok(Array.isArray(result), 'should return an array');
});
test('get-items handles missing category', async (ctx) => {
const result = await ctx.fn.call('get-items', {});
assert.ok(result.error, 'should return an error');
});
Test happy path AND edge cases. Run with test_run. Filter: test_run filter_path=api/get-items. To see which test files exist (and which a filter would select) WITHOUT running them, use gipity test list [path] - cheap discovery, no sandbox run.
Every test FILE runs against its own auto-reset database - you don't write teardown.
ctx.fn.call/callAsinvoke your real functions (registered from the current working tree at run start), but during a test run they read/write a throwaway copy of your database (built from your ownmigrations/, plus any seed files thesqlphase declares via an explicitseed: <dir>key), never the live one. The harness resets it - truncate + reseed - before every file, so files can't see each other's rows, and rows a test creates never reach the deployed app, never show on the live page, and never need cleaning up. Just write the assertion:test('save-item then it appears in the list', async (ctx) => { const { short_guid } = await ctx.fn.call('save-item', { name: 'test widget' }); const list = await ctx.fn.call('list-items', {}); assert.ok(list.some(i => i.short_guid === short_guid)); });
ctx.cleanup(fn)still runs after the suite (reverse order) but is now only for side-effects outside that database. Reference/lookup rows that must survive the reset have two supported paths (there is no automaticseeds/folder - don't drop aseeds/dir and expect it to load): (1)test.preserve(simplest): keep theINSERTin a migration and list the table undertest.preserveingipity.yaml; the reset's truncate skips preserved tables, so the rows stay. Use this for runtime-written config rows too. (2) a seed dir: point thesqlphase at an explicitseed: <dir>(see deploy); the reset re-runs those.sqlfiles after truncating.
test.preserveshape ingipity.yaml(list every table the reset's truncate must skip):test: preserve: - app_settings - plans
Test mode: ctx.isTest
Inside a test run your functions see ctx.isTest === true - a first-class flag (never sniff the request IP for "am I a test"). Use it to branch test-only behavior, e.g. skip your app's own rate limiting so a function-heavy suite doesn't throttle itself:
if (!ctx.isTest) await enforceRateLimit(ctx); // tests bypass your limiter
The platform also honors it to suppress outbound push: notify() becomes a no-op that returns { suppressed: true } during tests, so a suite can never spam your real subscribers - you don't have to gate notifications yourself.
What else is isolated during a test run (so async side-effects can't escape the throwaway schema):
- Record triggers don't fire. A write your test makes lands in the test schema, so the platform skips the record-change → workflow trigger for it. Your workflow logic isn't exercised end-to-end by a test write - assert on the function's own output instead, and test workflow steps directly where you can.
jobs.submit()is suppressed - it enqueues nothing (so a test never burns real compute/credits or runs a job'son_completeagainst your real data) but still returns a well-formed{ run_guid, status: 'suppressed' }with a fresh, uniquerun_guidper call (a realjr_…guid, just like a live submit). Your submit function's normalreturn { run_guid }path works unchanged under test - and because each guid is unique, app code that stores or matches a DB row on run_guid works exactly as in prod, so don't synthesize a fake run_guid in app code to keep the pipeline testable. (A laterjobs.status(run_guid)returns "not found" since nothing ran, so assert on the submit response, not on a completed job.)- Generative services still run for real.
llm(),image(),tts(), etc. make real provider calls and bill real credits during a test (so you can assert on a real response). If you don't want that cost on every run, gate them yourself withif (!ctx.isTest)or stub the call whenctx.isTest. - The platform per-IP rate limit is waived. All test runs share one sandbox egress IP, so the platform's 300-requests/5-min per-IP limit (on function calls,
ctx.services.*, uploads, and records) is skipped for a project's own test run — a call-heavy suite won't 429 itself, and concurrent runs from other projects don't throttle each other. (This is separate from your app's own limiter, which you still gate withctx.isTestas shown above.)
Request context in a test: your function sees a real caller IP
ctx.fn.call / callAs are real HTTPS requests made from the test sandbox, so the function under test receives a genuine request context - including an x-forwarded-for caller IP (the sandbox's egress IP, physically located wherever the platform runs). It is not an empty or synthetic context.
This matters for any function that branches on the caller's request rather than on its params. A geolocation function that falls back from GPS coords to a visitor-IP lookup will, in a test, take the visitor-IP branch - never the "no IP at all" branch:
// Function: coords -> GPS; else caller IP -> 'ip'; else server IP -> 'server-ip'
test('falls back to an IP lookup when no coords are given', async (ctx) => {
const r = await ctx.fn.call('log-location', {}); // no coords
assert.equal(r.source, 'ip'); // ✅ the harness call HAS a caller IP
// assert.equal(r.source, 'server-ip'); // ❌ never reached from a test
});
To exercise a branch that depends on the absence of request context, gate it with ctx.isTest in the function and assert the gated behavior - don't expect the harness to strip the IP.
Own your fixtures
Every test file gets its own database, reset before it starts, so files can't interfere - two files may safely write the same singleton/config row, hold the same fixed ids, or both mutate ctx.users.alice's data. Write the obvious assertion; no naming discipline needed.
- Build the data a test needs in
setup(fn)and stash it onctx.fixtures.setupruns once before the file's tests, against the freshly reset database - so you own your starting state:setup(async (ctx) => { ctx.fixtures.item = await ctx.fn.call('save-item', { name: 'base' }); }); test('get-item returns it', async (ctx) => { const got = await ctx.fn.call('get-item', { id: ctx.fixtures.item.id }); assert.equal(got.id, ctx.fixtures.item.id); }); - Don't assert deltas on state outside your app's database ("the limit grew") - plan/usage counters are account-wide and other runs move them. Assert the absolute value (
plan === 'pro'⇒ storage limit === 250 GB). Your own tables are yours alone, so table-wide COUNT/SUM assertions are fine.
There is no read-after-write lag to design around. The app DB is a single primary with strong read-after-write consistency: a value you just wrote is visible to the very next read, in this call and any later one. So a freshly-written value that reads back missing or stale is a real bug - a non-atomic multi-write a concurrent call interleaved with, a wrong LEFT JOIN, or a delete that raced your insert - never "replication lag". Don't paper over it with setTimeout/retry/poll loops; find the race.
Testing auth: user functions
ctx.fn.call() is unauthenticated (it carries only a public app token), so for a function with auth: user it exercises the logged-out path - the call rejects with 401 LOGIN_REQUIRED (a non-member calling an auth: member function rejects with 403 FORBIDDEN). The thrown error carries err.status and err.code and puts both tokens in its message - assert on those stable tokens, never on the human message text:
test('save-doc rejects anonymous callers', async (ctx) => {
await assert.rejects(() => ctx.fn.call('save-doc', { title: 't', body: 'b' }), /401 LOGIN_REQUIRED/);
});
For any function that PERSISTS user data, the anonymous gate is not enough - it never writes a row, so save's happy path stays unverified. Make the primary assertion a callAs happy-path test: save a record as ctx.users.alice, then read it back (list/get) and assert the row is present and owner-scoped:
test('save-activity persists and reads back the row', async (ctx) => {
const saved = await ctx.fn.callAs(ctx.users.alice, 'save-activity', { kind: 'run', minutes: 30 });
const list = await ctx.fn.callAs(ctx.users.alice, 'list-activity', {});
assert.ok(list.some(a => a.short_guid === saved.short_guid), 'alice reads back her saved row');
});
To test the signed-in path and cross-user isolation, use ctx.fn.callAs(user, name, params). The harness provides two ready-made signed-in fixture users, ctx.users.alice and ctx.users.bob:
test('a user only sees their own documents', async (ctx) => {
const saved = await ctx.fn.callAs(ctx.users.alice, 'save-doc', { title: 'mine', body: '...' });
const mine = await ctx.fn.callAs(ctx.users.alice, 'list-docs', {});
const other = await ctx.fn.callAs(ctx.users.bob, 'list-docs', {});
assert.ok(mine.some(d => d.short_guid === saved.short_guid), 'alice sees her doc');
assert.ok(!other.some(d => d.short_guid === saved.short_guid), 'bob does not see it');
});
For auth: member functions, call as the app owner - ctx.fn.callAs(ctx.users.owner, …) - the owner is a member of their own project, and their test-run writes go to the same throwaway test database. alice/bob are not members, so they double as the negative case:
test('only a member can approve', async (ctx) => {
await assert.rejects(() => ctx.fn.callAs(ctx.users.alice, 'approve-request', { id: 1 }), /403 FORBIDDEN/);
const done = await ctx.fn.callAs(ctx.users.owner, 'approve-request', { id: ctx.fixtures.req.id });
assert.equal(done.status, 'approved');
});
Verifying against the live app
Prefer gipity test for behavior coverage: it runs against a throwaway database, so nothing leaks. But a final end-to-end sanity pass often drives the live deployed app, and there the writes are real.
- Undo the writes with a checkpoint - don't hand-scrub rows.
gipity db checkpointsnapshots every table,gipity db restoreputs them all back (--keepto re-run the same test).gipity page eval --restore-dbwraps one browser run in that pair automatically. The snapshot lives in a hidden schema, so the app never sees it, and restore is a single transaction - no marker columns, no targetedDELETE, nothing left in the user's data.
gipity db checkpoint
gipity fn call submit-request '{"email":"smoke@example.com","order":"1042"}' --anon
# ... drive the flow, run the workflow, click through the page ...
gipity db restore # every table back exactly as the user left it
- Call functions the way the right persona would.
gipity fn call <name> '<json>'calls as your signed-in account (the owner persona). For a public function a signed-out visitor hits, usegipity fn call <name> '<json>' --anon- it takes the same anonymous path a real visitor does and returns the same unwrapped value (no rawcurl, no{"data":{...}}envelope to special-case). - Sending an image / audio / document to a function? Use
--file <field>=@<path>, not a hand-rolled base64. For a vision, OCR, transcription, or upload function that expects file bytes,gipity fn call ocr --file image=@receipt.pngreads the file, base64-encodes it, and attaches it underimagein the JSON body - nobase64 | python json.dumpsdance, and no "Argument list too long" from stuffing a photo into a shell argument. Repeat--filefor multiple attachments and combine with an inline body:gipity fn call ocr '{"lang":"en"}' --file page=@scan.jpg. (Same flag ongipity job runfor a long-running job.)
Testing file serving with ctx.upload
To test anything that serves a stored file - a download link, a share page, an <audio>/<img> source, a storage.fileUrl / Gipity.fileUrl resolution - you need a real file guid. Don't fabricate a storage_guid string: storage.fileUrl(guid) (and the durable serve endpoint) reject a guid that doesn't point at a real stored blob, so a made-up value makes the serve path fail.
ctx.upload(name, opts?) creates a real stored file server-side (no presigned round-trip) and returns its metadata - { guid, url, size, is_public, content_type, variants, ... }, the same shape Gipity.upload returns. The guid genuinely resolves through storage.fileUrl / Gipity.fileUrl. The file auto-cleans after the suite (no ctx.cleanup needed for it).
test('a private file resolves to a durable, serve-able URL', async (ctx) => {
// Make a real file, then register it the way your app does.
const up = await ctx.upload('song.mp3', { contentType: 'audio/mpeg', sizeBytes: 1234 });
const { node_id } = await ctx.fn.callAs(ctx.users.alice, 'upload-register', {
name: 'song.mp3', storage_guid: up.guid, size_bytes: 1234, content_type: 'audio/mpeg',
});
ctx.cleanup(() => ctx.fn.callAs(ctx.users.alice, 'nodes-delete', { node_ids: [node_id] }));
// A function that resolves a share/delivery now returns a working URL.
const share = await ctx.fn.call('public-resolve', { node_id });
assert.ok(share.url.includes('/serve?t='), 'branded serve URL, not a raw S3 link');
});
opts: { contentType?, public?, sizeBytes?, bytesBase64?, table?, recordId?, path? }. Pass sizeBytes to synthesize that many zero bytes (default 8) - synthesized bytes aren't decodable media, so no variants are generated even with an image content type - or bytesBase64 for real content, e.g. a tiny PNG to exercise a thumbnail variant. public: true yields a permanent CDN URL; the default (private) yields a durable serve URL that re-signs storage per request. Files are small-only (max 5 MB) - this is the fixture path, not the 30 GB presigned flow.