Tests live in tests/ as *.test.js files and run with test_run (or gipity test). A test calls your deployed functions over REST (ctx.fn.call) and/or imports your app's own modules directly. 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 - both functions/ and src/ are 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. 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.
Tests run against an isolated, auto-reset test database - you don't write teardown.
ctx.fn.call/callAsinvoke your real deployed functions, 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 run, so 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 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 returns{ status: 'suppressed' }and enqueues nothing, so a test never burns real compute/credits or runs a job'son_completeagainst your real data.- 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, keep files independent
Each run starts from a clean, seeded database, so cross-run pollution is gone. But test files still run concurrently against that one shared test database, so don't let two files fight over the same row:
- 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 schema - so you own your starting state instead of depending on a shared global:setup(async (ctx) => { ctx.fixtures.item = await ctx.fn.call('save-item', { name: 'base ' + ctx.testId }); }); 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); }); - Namespace anything uniquely-named with
ctx.testId(a stable per-file id) so a sibling file can't collide on a name / email / key. ctx.users.alice/ctx.users.bobare shared sign-in identities. Their app data is isolated per run, but two files both callingcallAs(alice, …)still touch the same in-schema row - if both mutate alice's profile they can race. Give each file its own entity, or key the data byctx.testId.- Verify by reading back the exact thing you created, by id - not by scanning a shared list. A listing/search/inbox endpoint is shared, growing, and paged; under concurrency your just-created row can fall off page 1, so
list.items.some(i => i.id === mine)passes in isolation and flakes in a full run. Point-read instead -get/infoby the id the create call returned. - Make write functions return the id(s) they create, so the test has something to point-read. (A "deliver" that links files into a recipient inbox should return those node ids; the test then asserts
node-info(id), never "find my file by name in the inbox".) - Don't assert deltas on shared global state ("the limit grew"). Assert the absolute value (
plan === 'pro'⇒ storage limit === 250 GB), not a change relative to an unknown starting point. Same for whole-table tallies (COUNT/SUM "totals"): they move while sibling files insert - scope the aggregate to rows you created (filter byctx.testId) instead of asserting the table-wide number.
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 - and cleaning up after yourself
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 - the user opens their app and finds your smoke-test rows sitting in 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). - Mark every live verification row with a recognizable marker, then delete by that marker when done and confirm the empty state:
gipity fn call submit-request '{"email":"smoke@example.com","order":"1042"}' --anon
# ... drive the flow ...
gipity db query "DELETE FROM requests WHERE email = 'smoke@example.com'"
gipity db query "SELECT count(*)::int AS remaining FROM requests" # expect 0
Use one fixed marker per run (an email like smoke@example.com, or a smoke- prefix on names) so the teardown is a single targeted DELETE - never a table-wide wipe.
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.