The @osdk/unit-testing package is experimental. The package is published as @osdk/unit-testing and only exports through the /experimental subpath. The API surface may evolve before promotion to a stable name.
The @osdk/unit-testing package allows you to unit test code that takes an OSDK Client, including Foundry functions, without making a request to Foundry. The package provides:
createMockClient: A Client you stub with fluent .when, .whenObjectSet, and .whenQuery matchers.createMockOsdkObject: Builds fully shaped Osdk.Instance values, including $primaryKey, $title, $rid, $link, and $clone.createMockObjectSet: A standalone ObjectSet that you can pass anywhere a real one would go, or use as a many-link target for aggregations.createMockAttachment: A placeholder for attachment values.Install the package as a development dependency:
Copied!1npm install --save-dev @osdk/unit-testing
The package has the following peer dependencies, which should already be installed in your project:
@osdk/api@osdk/client@osdk/functionsThe package uses vitest internally for example tests; you can use any test runner in your own code.
All exports are available from the /experimental subpath:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14import { createMockAttachment, createMockClient, createMockObjectSet, createMockOsdkObject, } from "@osdk/unit-testing/experimental"; import type { AggregateStubBuilder, FetchOneStubBuilder, FetchPageStubBuilder, QueryStubBuilder, StubBuilderFor, } from "@osdk/unit-testing/experimental";
Consider a Foundry function that reads the first Employee from a page:
Copied!1 2 3 4 5 6 7 8 9 10 11 12import type { Osdk } from "@osdk/api"; import type { Client } from "@osdk/client"; import { Employee } from "your-app-sdk"; export async function basicFetchPage( client: Client, ): Promise<Osdk.Instance<Employee>> { const objects = await client(Employee).fetchPage(); const object = objects.data[0]; if (object == null) throw new Error("No objects returned"); return object; }
A unit test with the mock client is shown below:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24import { createMockClient, createMockOsdkObject, } from "@osdk/unit-testing/experimental"; import { describe, expect, it } from "vitest"; import { Employee } from "your-app-sdk"; import { basicFetchPage } from "./basicFetchPage.js"; describe("basicFetchPage", () => { it("returns the first Employee", async () => { const mockClient = createMockClient(); const mockEmployee = createMockOsdkObject(Employee, { employeeId: 1, fullName: "John", }); mockClient .when((stub) => stub(Employee).fetchPage()) .thenReturnObjects([mockEmployee]); const actual = await basicFetchPage(mockClient); expect(actual).toEqual(mockEmployee); }); });
The test does three things:
createMockClient() returns a MockClient that satisfies the Client interface; pass it anywhere your code expects a real client.createMockOsdkObject(Employee, { ... }) builds a real-shaped Osdk.Instance.mockClient.when(stub => stub(Employee).fetchPage()).thenReturnObjects([...]) records a stub. The stub argument is a Client-like factory; rebuild the same call chain that your code under test will execute.createMockOsdkObject builds a fully shaped Osdk.Instance<T> that you can pass to your code under test or place inside a .thenReturnObjects([...]) stub. The following sections cover the object shape, the links option (single, many, errors, and mock object sets), and createMockAttachment.
createMockOsdkObject takes three arguments:
Copied!1createMockOsdkObject(objectType, properties, options);
objectType: The generated object type constant from your SDK (for example, Employee). The mock reads its apiName and primaryKeyApiName from this value.properties: The property values you want on the mock. Must include the primary key property. Other properties are optional and are only relevant if your code reads them.options: Three optional fields:
links: Mock data for the object's $link accessor. See Links.titlePropertyApiName: The API name of the property that should back $title. See Set the title property.$rid: Override the auto-generated $rid. The default is "ri.mock.main.object.<apiName>.<primaryKey>".The returned mock has the same shape as a real OSDK instance:
| Field | Description |
|---|---|
$apiName, $objectType | The object type's API name. |
$primaryKey | The value of the primary key property in properties. |
$title | The value of the titlePropertyApiName property; undefined if not set. |
$rid | options.$rid if provided, otherwise an auto-generated mock RID. |
$objectSpecifier | "<apiName>:<primaryKey>". |
$link | A proxy backed by options.links. |
$clone(updates?) | Returns a fresh mock with merged property values. |
Mocks do not model the $as and $__EXPERIMENTAL__NOT_SUPPORTED_YET__* accessors; accessing them throws an error.
Copied!1 2 3 4 5 6 7 8 9 10 11 12import { createMockOsdkObject } from "@osdk/unit-testing/experimental"; import { Employee } from "your-app-sdk"; const emp = createMockOsdkObject( Employee, { employeeId: 1, fullName: "John Doe" }, { titlePropertyApiName: "fullName" }, ); emp.$primaryKey; // 1 emp.$title; // "John Doe" emp.$objectSpecifier; // "Employee:1"
You must include the primary key property. createMockOsdkObject reads objectType.primaryKeyApiName and throws an error if that key is not present in properties.
In the test environment, the OSDK does not know which property on a given object type is its title. If your code under test reads obj.$title, you must tell the mock which property to surface there by passing titlePropertyApiName:
Copied!1 2 3 4 5 6 7const emp = createMockOsdkObject( Employee, { employeeId: 1, fullName: "John Doe" }, { titlePropertyApiName: "fullName" }, ); emp.$title; // "John Doe"
titlePropertyApiName must name a property that you actually included in properties; createMockOsdkObject throws an error if it is missing. If you omit titlePropertyApiName entirely, $title is undefined.
The links option mirrors the link API names from the object type. Each value can be one of the following:
| Link multiplicity | Allowed values |
|---|---|
| Single | A mock object, or an Error instance. |
| Many | An array of mock objects, or a MockObjectSet (see Mock object sets). |
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16const office = createMockOsdkObject(Office, { officeId: "nyc", name: "New York Office", }); const employee = createMockOsdkObject( Employee, { employeeId: 1, fullName: "John Doe" }, { links: { officeLink: office } }, ); await employee.$link.officeLink.fetchOne(); // → office (await employee.$link.officeLink.fetchOneWithErrors()).value; // → office
Pass an Error instance to exercise the failure branch in the calling code. fetchOne() rejects with the error, and fetchOneWithErrors() resolves to { error }:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13const employee = createMockOsdkObject( Employee, { employeeId: 1 }, { links: { officeLink: new Error("link unavailable") } }, ); await expect(employee.$link.officeLink.fetchOne()).rejects.toThrow( "link unavailable", ); const result = await employee.$link.officeLink.fetchOneWithErrors(); result.error; // the Error result.value; // undefined
If your code accesses $link.someLink and you did not configure it, the accessor still exists. The fetchOne method rejects, and fetchOneWithErrors resolves with { error }, containing the link name, object type, and primary key:
Copied!1 2 3 4const employee = createMockOsdkObject(Employee, { employeeId: 1 }); await employee.$link.officeLink.fetchOne(); // rejects
Pass an array; the $link accessor exposes the same call shapes that a real many-link does:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24const peep1 = createMockOsdkObject(Employee, { employeeId: 10, fullName: "Alice", }); const peep2 = createMockOsdkObject(Employee, { employeeId: 11, fullName: "Bob", }); const employee = createMockOsdkObject( Employee, { employeeId: 1 }, { links: { peeps: [peep1, peep2] } }, ); (await employee.$link.peeps.fetchPage()).data; // → [peep1, peep2] await employee.$link.peeps.fetchOne(11); // → peep2 (matched by $primaryKey) for await (const peep of employee.$link.peeps.asyncIter()) { // peep1, peep2 }
fetchOne(primaryKey) throws an error when no array element has a matching $primaryKey. The aggregate() method is not supported on the array form; pass a MockObjectSet instead.
MockObjectSetA many-link can also be backed by a MockObjectSet instead of an array. Use this approach when your code calls aggregate(), where(), or other object-set methods on the link:
Copied!1 2 3 4 5const employee = createMockOsdkObject( Employee, { employeeId: 1 }, { links: { peeps: peepsSet } }, );
See Mock object sets for how to build peepsSet and stub calls on it.
createMockObjectSet(objectType) returns an ObjectSet<T> that you can pass anywhere a real one would go: directly into a function under test, or as the value of a many-link in createMockOsdkObject. By itself, the mock object set holds no data; you wire up its behavior by registering stubs against it on a MockClient.
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15import { createMockClient, createMockObjectSet, createMockOsdkObject, } from "@osdk/unit-testing/experimental"; const mockClient = createMockClient(); const peepsSet = createMockObjectSet(Employee); mockClient .whenObjectSet( peepsSet, (os) => os.aggregate({ $select: { $count: "unordered" } }), ) .thenReturnAggregation({ $count: 7 });
Now peepsSet.aggregate(...) resolves to { $count: 7 }. You can attach the same set to a parent mock object as a many-link:
Copied!1 2 3 4 5 6 7 8 9 10const employee = createMockOsdkObject( Employee, { employeeId: 1 }, { links: { peeps: peepsSet } }, ); const result = await employee.$link.peeps.aggregate({ $select: { $count: "unordered" }, }); result.$count; // 7
You can register fetchPage, where, and other call shapes on the same set; see Stub calls on a mock object set for the full builder reference.
For function inputs of type Attachment, createMockAttachment returns a placeholder value with the surface that your code can call against. Use it the same way you would use createMockOsdkObject:
Copied!1 2 3 4import { createMockAttachment } from "@osdk/unit-testing/experimental"; const blob = new Blob(["hello world!"], { type: "text/plain" }); const attachment = createMockAttachment("ri.attachments.main.attachment.abc", blob);
$clone is supported and returns a fresh frozen mock with merged properties. Updating the primary key to a different value throws an error.
Copied!1 2 3const updated = employee.$clone({ fullName: "Jane Doe" }); updated.$primaryKey; // unchanged updated.fullName; // "Jane Doe"
createMockClient() returns a value that satisfies the OSDK Client interface, plus four additional methods for setting up stubs:
client.when(callback): Stub a call rooted at the client. Pass a callback that rebuilds the chain that your code under test will make (for example, stub(Employee).where(...).fetchPage()). Returns a builder whose .thenReturn* matcher depends on the call shape.client.whenObjectSet(set, callback): Stub a call on a specific MockObjectSet (created with createMockObjectSet). Use this approach when your code is handed an object set directly, or for a many-link backed by a mock object set.client.whenQuery(query, params?): Stub a query call (a generated function on the Ontology). Returns a builder with .thenReturn(value) and .thenThrow(error).client.clearStubs(): Removes every stub registered on this client.Each registrar is covered in the following sections.
Use client.when(callback) to rebuild the call chain that your code under test will make. The argument is a Client-like factory; chain where, aggregate, fetchPage, fetchOne, and so on, just as your code would.
fetchPage with thenReturnObjectsCopied!1 2 3 4 5 6 7 8 9const mockClient = createMockClient(); const emp = createMockOsdkObject(Employee, { employeeId: 1, fullName: "John" }); mockClient .when((stub) => stub(Employee).fetchPage()) .thenReturnObjects([emp]); const page = await mockClient(Employee).fetchPage(); page.data; // [emp]
thenReturnObjects also wires up asyncIter. If your code iterates instead of paginating, the same stub serves both call shapes.
fetchOne with thenReturnObjectCopied!1 2 3mockClient .when((stub) => stub(Employee).fetchOne(1)) .thenReturnObject(emp);
aggregate with thenReturnAggregationCopied!1 2 3 4 5 6 7mockClient .when((stub) => stub(Employee) .where({ employeeId: { $eq: 5 } }) .aggregate({ $select: { "employeeLocation:exactDistinct": "asc" } }) ) .thenReturnAggregation({ employeeLocation: { exactDistinct: 3 } });
Stub $groupBy aggregations the same way; return an array of group rows:
Copied!1 2 3 4 5 6 7 8 9 10mockClient .when((stub) => stub(Employee).aggregate({ $select: { "employeeId:max": "unordered" }, $groupBy: { employeeId: "exact" }, }) ) .thenReturnAggregation([ { $group: { employeeId: 5 }, employeeId: { max: 5 } }, ]);
You can register as many stubs as needed. Stubs are matched against the calls your code makes; registration order does not affect matching.
When code receives an ObjectSet directly (not built from client(Type)), or when you are stubbing aggregate or fetch behavior for a many-link backed by a MockObjectSet, register stubs against the set itself:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19import { createMockObjectSet } from "@osdk/unit-testing/experimental"; const empSet = createMockObjectSet(Employee); const emp1 = createMockOsdkObject(Employee, { employeeId: 1, fullName: "Alice", }); const emp2 = createMockOsdkObject(Employee, { employeeId: 2, fullName: "Bob" }); mockClient .whenObjectSet(empSet, (os) => os.fetchPage()) .thenReturnObjects([emp1, emp2]); mockClient .whenObjectSet( empSet, (os) => os.aggregate({ $select: { $count: "unordered" } }), ) .thenReturnAggregation({ $count: 42 });
The same empSet can then be passed wherever your code expects an ObjectSet<Employee>: into a function under test, or as a many-link target on a parent mock object.
Queries (functions on the Ontology) can be stubbed as well:
Copied!1 2 3 4import { addOne } from "your-app-sdk"; mockClient.whenQuery(addOne, { n: 5 }).thenReturn(6); mockClient.whenQuery(addOne, { n: 99 }).thenThrow(new Error("boom"));
thenReturn(value) resolves the query promise to value, and thenThrow(error) rejects it. Different parameter objects can be stubbed independently:
Copied!1 2mockClient.whenQuery(addOne, { n: 10 }).thenReturn(11); mockClient.whenQuery(addOne, { n: 20 }).thenReturn(21);
Queries with array parameters follow the same pattern; match the parameters that your code passes:
Copied!1 2 3mockClient .whenQuery(queryTypeReturnsArray, { people: ["Alice", "Bob"] }) .thenReturn(["Alice - processed", "Bob - processed"]);
mockClient.clearStubs() removes every stub registered on the client. This is useful if you reuse a client across multiple it blocks; otherwise, construct a fresh createMockClient() for each test for isolation.
createMockClient only stubs Ontology calls (object types, queries, and object sets). It does not intercept the Foundry Platform APIs in @osdk/foundry.* and @osdk/internal.foundry.*; those calls go through the regular fetch path. To keep them off the network in tests, intercept them with MSW ↗.
You stub Platform SDK requests with a network request stubbing library. It uses a placeholder base URL:
https://mock.invalid/
Every Platform call your code makes will resolve against that origin. Provide MSW handlers for the specific paths that your function calls.
Install MSW as a development dependency:
Copied!1npm install --save-dev msw
Set up a Node server in your test file. The standard MSW lifecycle hooks reset handlers between tests so each it block is isolated:
Copied!1 2 3 4 5 6 7 8 9import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll } from "vitest"; const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
Set onUnhandledRequest: "error"; it surfaces accidental Platform calls without a handler instead of letting them fall through silently.
A function that loads the current user and gates on a username suffix:
Copied!1 2 3 4 5 6 7 8 9 10import type { Client } from "@osdk/client"; import { Users } from "@osdk/foundry.admin"; export async function requireAdminUser(client: Client): Promise<string> { const user = await Users.getCurrent(client); if (!user.username.endsWith("@admin")) { throw new Error(`User ${user.username} is not an admin`); } return user.username; }
The test stubs the Platform endpoint with MSW and uses createMockClient() for the Client:
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63import type { getCurrent } from "@osdk/foundry.admin/User"; import { createMockClient } from "@osdk/unit-testing/experimental"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { requireAdminUser } from "./requireAdminUser.js"; type User = Awaited<ReturnType<typeof getCurrent>>; const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); describe("requireAdminUser", () => { it("resolves when the current user is an admin", async () => { server.use( http.get( "https://mock.invalid/api/v2/admin/users/getCurrent", () => HttpResponse.json( { id: "user-1", username: "alice@admin", givenName: "Alice", familyName: "Admin", realm: "default", status: "ACTIVE", attributes: {}, } satisfies User, ), ), ); const mockClient = createMockClient(); expect(await requireAdminUser(mockClient)).toBe("alice@admin"); }); it("rejects when the user is not an admin", async () => { server.use( http.get( "https://mock.invalid/api/v2/admin/users/getCurrent", () => HttpResponse.json( { id: "user-2", username: "bob@example.com", givenName: "Bob", familyName: "Example", realm: "default", status: "ACTIVE", attributes: {}, } satisfies User, ), ), ); const mockClient = createMockClient(); await expect(requireAdminUser(mockClient)).rejects.toThrow( "User bob@example.com is not an admin", ); }); });
The MSW response body is a JSON literal, so the body can drift from the actual @osdk/foundry.* shape (a renamed field, a new required property, and so on) and only fail at runtime.
To keep the fixture tied to the real type, derive it from the Platform function itself:
Copied!1 2 3import type { somePlatformFn } from "@osdk/foundry.<service>/<Resource>"; type ResponseShape = Awaited<ReturnType<typeof somePlatformFn>>;
Then assert the response body with satisfies ResponseShape:
Copied!1 2 3HttpResponse.json( {/* ...response fields... */} satisfies ResponseShape, );
Awaited<ReturnType<typeof fn>> unwraps the Promise<T> returned by an async function, giving you T directly, which ensures that the mocked response matches what would be returned from real calls exactly.
In the example above, the concrete instance of this pattern is type User = Awaited<ReturnType<typeof getCurrent>>; name the alias after whatever the endpoint returns.
onUnhandledRequest: "error". Spotting a missing handler is simpler than debugging a hung test.setupServer() once at module scope, then call server.use(...) inside each it block for the per-test handler. Use afterEach(server.resetHandlers) to clear them.mockClient handles both kinds of call simultaneously; Ontology calls go through when, whenObjectSet, and whenQuery, while Platform calls go through MSW.