Ontology SDK
Testing
Unit and integration tests for code that uses the TypeScript client.
The package ships testing.ts with two fakes. Both plug into createClient through its fetch option, so your tests run without a server.
Mock fetch: fixed answers
Use createMockFetch() when a test needs one specific answer:
import { createClient } from "@convergehq/osdk-<ontology>";
import { createMockFetch } from "@convergehq/osdk-<ontology>/testing";
const mock = createMockFetch();
mock
.whenFetchOne("Customer", "c1", { $rid: "customer/c1", name: "Ada" })
.whenFetchOne("Customer", "gone", null) // answers 404
.whenAction("updateTier"); // answers VALID
const client = createClient({ baseUrl: "https://cnvrg.si/api", token: "test", fetch: mock.fetch });
expect((await client.objects["Customer"].fetchOne("c1")).name).toBe("Ada");
whenFetchLinks(type, key, link, objects) and whenQuery(name, value) stub links and queries. whenUpload(meta) and whenAttachment(rid, meta, content) stub attachments. There are also whenFetchPage, whenAggregate and whenBatchAction. A request with no stub answers 404 with errorCode: "NOT_STUBBED". mock.calls lists every request the client made, so you can assert on what was sent.
mock.clearStubs() drops every stub and recorded call between tests.
createMockClient() does both steps at once: it is a mock fetch and a client in one object, so you stub and read on the same thing.
import { createMockClient, createMockOsdkObject } from "@convergehq/osdk-<ontology>/testing";
const client = createMockClient();
client.whenFetchLinks("Customer", "c1", "orders", [{ $rid: "order/o1", orderId: "o1" }]);
const ada = createMockOsdkObject("Customer", { id: "c1", name: "Ada" }, { client });
expect((await ada.$link.orders.fetchPage()).data).toHaveLength(1);
createMockOsdkObject(type, properties, options) builds an object as the client returns one, for code that takes objects: $rid ("<type in lowercase>/<primary key>" unless you pass options.$rid), $apiName, $primaryKey and $link. properties must hold the primary key, or it throws. Its $link reads through options.client, which can be a mock client or a mock fetch. Without a client, every link read answers 404.
A stub matches only the HTTP method and the path. whenFetchPage("Customer", ...) answers every fetchPage on that type, whatever its where, and whenQuery answers the same value whatever the parameters. When two stubs match, the newest wins. To check that your code sent the right filter or parameters, assert on mock.calls. whenAction answers VALID unless you pass a result, for example { validation: { result: "INVALID" }, edits: null }. It always answers with status 200: to test an action that fails with 422, use the integration server with a handler that throws.
Integration server: seeded objects
Use createIntegrationServer() when a test needs filters, paging, aggregates and actions to behave like the real API, on data you seed yourself:
import { createIntegrationServer } from "@convergehq/osdk-<ontology>/testing";
const server = createIntegrationServer({ primaryKeys: { Customer: "id" } });
server.seed((s) => {
s.create("Customer", { id: "c1", tier: "gold", revenue: 1200 });
s.create("Customer", { id: "c2", tier: "silver", revenue: 300 });
});
server.whenAction("updateTier", (p, s) => s.update("Customer", String(p.customer), { tier: p.tier }));
const client = createClient({ baseUrl: "https://cnvrg.si/api", token: "test", fetch: server.fetch });
const gold = await client.objects["Customer"].where({ tier: "gold" }).fetchPage();
expect(gold.data).toHaveLength(1);
An action handler that throws makes the action fail with 422, and the server rolls back every change it made. With $validateOnly the handler still runs, and its changes are always rolled back. server.seed.snapshot() and server.seed.set(snapshot) save and restore the data between tests. server.seed.set() with no argument empties the store, a clean reset in beforeEach.
server.seed(fn) returns what fn returns, and s.create returns a ref ({ $apiName, $primaryKey }). s.link(ref, "orders", [ref, ...]) links with refs, and server.seed.ref(type, key) gives the ref of a seeded object, or undefined.
Without primaryKeys for a type, each object you create needs a $primaryKey field. Otherwise create throws "object has no primary key". A query handler that throws makes the query fail with 400 and the error's message.
The integration server also keeps uploads: client.attachments.upload, get and read work against it with no stubs. client.media.get returns the reference from a stubbed or seeded object, but fetchContents, fetchMetadata and client.media.upload are served by neither fake and answer 404, so test media code against a real server.
Links and queries:
server.seed((s) => s.link("Customer", "c1", "orders", "Order", "o1"));
server.whenQuery("customer_lifetime_value", (p) => ({ customer_rid: p.customer_rid, orders: 1 }));
A seeded link goes one way: seed the reverse link too if your code reads it. Like the real API, a link type you never seeded on that object type answers 404, not an empty list. s.unlink(...) removes one, and deleting an object drops its links. Links are part of the data, so snapshot(), set() and an action's rollback cover them, and an action's edits report addedLinksCount and deletedLinksCount.
Fixtures from the generated metadata
The client exports $metadata: each type's primary key, property types and links. createSeedWithMetadata($metadata, fn) builds a typed fixture without a server. s.create checks the properties against the generated types and fills each property you leave out with its type's default: "" for text, 0 for numbers, false, [] for arrays and vectors, 1970-01-01 for dates and timestamps, and a point at [0, 0] for geo properties. It returns { output, context }, where context is what fn returned:
import { $metadata } from "@convergehq/osdk-<ontology>";
import { createIntegrationServer, createSeedWithMetadata } from "@convergehq/osdk-<ontology>/testing";
const fixture = createSeedWithMetadata($metadata, (s) => {
const ada = s.create("Customer", { id: "c1", name: "Ada" });
const order = s.create("Order", { orderId: "o1" });
s.link(ada, "orders", [order]);
return { ada, order };
});
const server = createIntegrationServer({ metadata: $metadata });
beforeEach(() => server.seed.set(fixture.output));
createIntegrationServer({ metadata }) takes each type's primary key from the metadata, so primaryKeys is not needed. server.seed.addAll(output) adds a fixture to what is already seeded. If one of its objects is already there, it throws and adds nothing.
Seeds can also s.delete(type, key). server.calls lists every request, and server.clearStubs() drops the action handlers and calls while keeping the data. Like the real API, the server refuses page sizes outside 1–1000 and batches outside 1–100.
Both fakes match an equality filter on the text value. The real server reads a value made of letters, a /, then letters, digits, _ or - (such as a/b) as an object reference, so such a filter on a text property matches there only through a regex clause (see TypeScript). Test those filters against a real server.
The real server's aggregate rows can carry more fields than group and metrics. Assert on those two fields only, so the same test passes against both.
What local tests do not cover
Both fakes run your code against data you control, as one all-powerful user. They do not check permissions, markings, masked properties or row-level policies, and they do not run your deployed functions or action logic (you stub those). Keep at least one test against a real Converge environment for the paths where access rules matter.