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/callAs invoke your real deployed functions, but during a test run they read/write a throwaway copy of your database (built from your own migrations/, plus any seed files the sql phase declares via an explicit seed: <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 automatic seeds/ folder - don't drop a seeds/ dir and expect it to load): (1) test.preserve (simplest): keep the INSERT in a migration and list the table under test.preserve in gipity.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 the sql phase at an explicit seed: <dir> (see deploy); the reset re-runs those .sql files 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):

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:

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.

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.