跳转至

Unit testing TypeScript OSDK code(单元测试 TypeScript OSDK 代码)

:::callout{theme="warning" title="Experimental"} 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

Install the package as a development dependency:

npm 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/functions

The package uses vitest internally for example tests; you can use any test runner in your own code.

Import

All exports are available from the /experimental subpath:

import {
  createMockAttachment,
  createMockClient,
  createMockObjectSet,
  createMockOsdkObject,
} from "@osdk/unit-testing/experimental";

import type {
  AggregateStubBuilder,
  FetchOneStubBuilder,
  FetchPageStubBuilder,
  QueryStubBuilder,
  StubBuilderFor,
} from "@osdk/unit-testing/experimental";

Write your first test

Consider a Foundry function that reads the first Employee from a page:

import 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:

import {
  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:

  1. createMockClient() returns a MockClient that satisfies the Client interface; pass it anywhere your code expects a real client.
  2. createMockOsdkObject(Employee, { ... }) builds a real-shaped Osdk.Instance.
  3. 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.

Arguments

createMockOsdkObject takes three arguments:

createMockOsdkObject(objectType, properties, options);
  1. objectType: The generated object type constant from your SDK (for example, Employee). The mock reads its apiName and primaryKeyApiName from this value.
  2. 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.
  3. options: Three optional fields:
  4. links: Mock data for the object's $link accessor. See Links.
  5. titlePropertyApiName: The API name of the property that should back $title. See Set the title property.
  6. $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.

Basic usage

import { 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.

Set the title property

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:

const 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).
const 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 }:

const 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:

const 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:

const 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.

A 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:

const employee = createMockOsdkObject(
  Employee,
  { employeeId: 1 },
  { links: { peeps: peepsSet } },
);

See Mock object sets for how to build peepsSet and stub calls on it.

Mock object sets

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.

import {
  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:

const 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.

Mock attachments

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:

import { createMockAttachment } from "@osdk/unit-testing/experimental";

const blob = new Blob(["hello world!"], { type: "text/plain" });
const attachment = createMockAttachment("ri.attachments.main.attachment.abc", blob);

Cloning and updates

$clone is supported and returns a fresh frozen mock with merged properties. Updating the primary key to a different value throws an error.

const updated = employee.$clone({ fullName: "Jane Doe" });
updated.$primaryKey; // unchanged
updated.fullName; // "Jane Doe"

Stub client calls

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.

Stub calls rooted at the client

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 thenReturnObjects

const 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 thenReturnObject

mockClient
  .when((stub) => stub(Employee).fetchOne(1))
  .thenReturnObject(emp);

aggregate with thenReturnAggregation

mockClient
  .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:

mockClient
  .when((stub) =>
    stub(Employee).aggregate({
      $select: { "employeeId:max": "unordered" },
      $groupBy: { employeeId: "exact" },
    })
  )
  .thenReturnAggregation([
    { $group: { employeeId: 5 }, employeeId: { max: 5 } },
  ]);

Multiple stubs on the same client

You can register as many stubs as needed. Stubs are matched against the calls your code makes; registration order does not affect matching.

Stub calls on a mock object set

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:

import { 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.

Stub Foundry queries

Queries (functions on the Ontology) can be stubbed as well:

import { 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:

mockClient.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:

mockClient
  .whenQuery(queryTypeReturnsArray, { people: ["Alice", "Bob"] })
  .thenReturn(["Alice - processed", "Bob - processed"]);

Reset stubs between tests

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.

Test Foundry Platform APIs with MSW

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 ↗.

How it works

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.

Set up MSW

Install MSW as a development dependency:

npm 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:

import { 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.

Stub a Platform call

A function that loads the current user and gates on a username suffix:

import 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:

import 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",
    );
  });
});

:::callout{theme="neutral" title="Type your fixtures against the real Platform type"} 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:

import type { somePlatformFn } from "@osdk/foundry.<service>/<Resource>";

type ResponseShape = Awaited<ReturnType<typeof somePlatformFn>>;

Then assert the response body with satisfies ResponseShape:

HttpResponse.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. :::

Tips

  • Use onUnhandledRequest: "error". Spotting a missing handler is simpler than debugging a hung test.
  • Reuse one server across handlers. Call 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.
  • Combine Ontology stubs with Platform stubs. A single mockClient handles both kinds of call simultaneously; Ontology calls go through when, whenObjectSet, and whenQuery, while Platform calls go through MSW.

中文翻译

单元测试 TypeScript OSDK 代码

:::callout{theme="warning" title="实验性"} @osdk/unit-testing 包目前处于实验阶段。该包以 @osdk/unit-testing 发布,仅通过 /experimental 子路径导出。API 表面在升级为稳定版本之前可能会发生变化。 :::

@osdk/unit-testing 包允许您对使用 OSDK Client 的代码进行单元测试,包括 Foundry 函数,而无需向 Foundry 发起请求。该包提供:

  • createMockClient 一个 Client,您可以使用流畅的 .when.whenObjectSet.whenQuery 匹配器进行桩接。
  • createMockOsdkObject 构建完整形状的 Osdk.Instance 值,包括 $primaryKey$title$rid$link$clone
  • createMockObjectSet 一个独立的 ObjectSet,您可以将其传递给任何需要真实对象集的地方,或用作聚合的多链接目标。
  • createMockAttachment 附件值的占位符。

安装

将包安装为开发依赖:

npm install --save-dev @osdk/unit-testing

该包有以下对等依赖,这些依赖应该已经安装在您的项目中:

  • @osdk/api
  • @osdk/client
  • @osdk/functions

该包内部使用 vitest 进行示例测试;您可以在自己的代码中使用任何测试运行器。

导入

所有导出均可从 /experimental 子路径获取:

import {
  createMockAttachment,
  createMockClient,
  createMockObjectSet,
  createMockOsdkObject,
} from "@osdk/unit-testing/experimental";

import type {
  AggregateStubBuilder,
  FetchOneStubBuilder,
  FetchPageStubBuilder,
  QueryStubBuilder,
  StubBuilderFor,
} from "@osdk/unit-testing/experimental";

编写您的第一个测试

考虑一个从页面读取第一个 Employee 的 Foundry 函数:

import 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;
}

使用模拟客户端的单元测试如下所示:

import {
  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);
  });
});

该测试做了三件事:

  1. createMockClient() 返回一个满足 Client 接口的 MockClient;将其传递给任何期望真实客户端的代码。
  2. createMockOsdkObject(Employee, { ... }) 构建一个真实形状的 Osdk.Instance
  3. mockClient.when(stub => stub(Employee).fetchPage()).thenReturnObjects([...]) 记录一个桩。stub 参数是一个类似 Client 的工厂;重建被测代码将要执行的相同调用链。

模拟对象和链接

createMockOsdkObject 构建一个完整形状的 Osdk.Instance<T>,您可以将其传递给被测代码或放置在 .thenReturnObjects([...]) 桩中。以下部分涵盖对象形状、links 选项(单链接、多链接、错误和模拟对象集)以及 createMockAttachment

参数

createMockOsdkObject 接受三个参数:

createMockOsdkObject(objectType, properties, options);
  1. objectType 来自 SDK 的生成对象类型常量(例如 Employee)。模拟对象从此值读取其 apiNameprimaryKeyApiName
  2. properties 您希望在模拟对象上设置的属性值。必须包含主键属性。其他属性是可选的,仅当您的代码读取它们时才相关。
  3. options 三个可选字段:
  4. links 对象 $link 访问器的模拟数据。请参阅 链接
  5. titlePropertyApiName 应支持 $title 的属性的 API 名称。请参阅 设置标题属性
  6. $rid 覆盖自动生成的 $rid。默认值为 "ri.mock.main.object.<apiName>.<primaryKey>"

返回的模拟对象与真实的 OSDK 实例具有相同的形状:

字段 描述
$apiName$objectType 对象类型的 API 名称。
$primaryKey properties 中主键属性的值。
$title titlePropertyApiName 属性的值;如果未设置则为 undefined
$rid 如果提供了 options.$rid,则为该值,否则为自动生成的模拟 RID。
$objectSpecifier "<apiName>:<primaryKey>"
$link options.links 支持的代理。
$clone(updates?) 返回一个具有合并属性值的新模拟对象。

模拟对象不模拟 $as$__EXPERIMENTAL__NOT_SUPPORTED_YET__* 访问器;访问它们会抛出错误。

基本用法

import { 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"

您必须包含主键属性。createMockOsdkObject 读取 objectType.primaryKeyApiName,如果 properties 中不存在该键,则会抛出错误。

设置标题属性

在测试环境中,OSDK 不知道给定对象类型上的哪个属性是其标题。如果您的被测代码读取 obj.$title,您必须通过传递 titlePropertyApiName 告诉模拟对象在那里显示哪个属性:

const emp = createMockOsdkObject(
  Employee,
  { employeeId: 1, fullName: "John Doe" },
  { titlePropertyApiName: "fullName" },
);

emp.$title; // "John Doe"

titlePropertyApiName 必须命名您实际包含在 properties 中的属性;如果缺少该属性,createMockOsdkObject 会抛出错误。如果您完全省略 titlePropertyApiName,则 $titleundefined

链接

links 选项镜像对象类型的链接 API 名称。每个值可以是以下之一:

链接多重性 允许的值
单链接 模拟对象,或 Error 实例。
多链接 模拟对象数组,或 MockObjectSet(请参阅 模拟对象集)。

成功的链接获取

const 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

失败的链接获取

传递一个 Error 实例来测试调用代码中的失败分支。fetchOne() 会拒绝并返回该错误,而 fetchOneWithErrors() 会解析为 { error }

const 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

缺少链接

如果您的代码访问 $link.someLink 但您没有配置它,访问器仍然存在。fetchOne 方法会拒绝,而 fetchOneWithErrors 会解析为 { error },其中包含链接名称、对象类型和主键:

const employee = createMockOsdkObject(Employee, { employeeId: 1 });

await employee.$link.officeLink.fetchOne();
// rejects

使用数组的多链接

传递一个数组;$link 访问器暴露与真实多链接相同的调用形状:

const 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
}

当没有数组元素具有匹配的 $primaryKey 时,fetchOne(primaryKey) 会抛出错误。数组形式不支持 aggregate() 方法;请改用 MockObjectSet

使用 MockObjectSet 的多链接

多链接也可以由 MockObjectSet 而不是数组支持。当您的代码在链接上调用 aggregate()where() 或其他对象集方法时,请使用此方法:

const employee = createMockOsdkObject(
  Employee,
  { employeeId: 1 },
  { links: { peeps: peepsSet } },
);

请参阅 模拟对象集 了解如何构建 peepsSet 并在其上注册桩调用。

模拟对象集

createMockObjectSet(objectType) 返回一个 ObjectSet<T>,您可以将其传递给任何需要真实对象集的地方:直接传递给被测函数,或作为 createMockOsdkObject 中多链接的值。模拟对象集本身不包含数据;您通过在 MockClient 上注册桩来配置其行为。

import {
  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 });

现在 peepsSet.aggregate(...) 解析为 { $count: 7 }。您可以将同一个集合作为多链接附加到父模拟对象:

const employee = createMockOsdkObject(
  Employee,
  { employeeId: 1 },
  { links: { peeps: peepsSet } },
);

const result = await employee.$link.peeps.aggregate({
  $select: { $count: "unordered" },
});
result.$count; // 7

您可以在同一个集合上注册 fetchPagewhere 和其他调用形状;请参阅 在模拟对象集上注册桩调用 获取完整的构建器参考。

模拟附件

对于 Attachment 类型的函数输入,createMockAttachment 返回一个占位符值,其表面是您的代码可以调用的。使用方式与 createMockOsdkObject 相同:

import { 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,返回一个具有合并属性的新的冻结模拟对象。将主键更新为不同的值会抛出错误。

const updated = employee.$clone({ fullName: "Jane Doe" });
updated.$primaryKey; // unchanged
updated.fullName; // "Jane Doe"

桩接客户端调用

createMockClient() 返回一个满足 OSDK Client 接口的值,外加四个用于设置桩的额外方法:

  • client.when(callback) 桩接以客户端为根的调用。传递一个回调,重建被测代码将要执行的链(例如 stub(Employee).where(...).fetchPage())。返回一个构建器,其 .thenReturn* 匹配器取决于调用形状。
  • client.whenObjectSet(set, callback) 在特定的 MockObjectSet(使用 createMockObjectSet 创建)上桩接调用。当您的代码直接接收对象集,或用于由模拟对象集支持的多链接时,请使用此方法。
  • client.whenQuery(query, params?) 桩接查询调用(本体上的生成函数)。返回一个具有 .thenReturn(value).thenThrow(error) 的构建器。
  • client.clearStubs() 移除在此客户端上注册的所有桩。

以下部分将介绍每个注册器。

桩接以客户端为根的调用

使用 client.when(callback) 重建被测代码将要执行的调用链。参数是一个类似 Client 的工厂;像您的代码一样链式调用 whereaggregatefetchPagefetchOne 等。

使用 thenReturnObjectsfetchPage

const 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 也会设置 asyncIter。如果您的代码进行迭代而不是分页,同一个桩可以服务于两种调用形状。

使用 thenReturnObjectfetchOne

mockClient
  .when((stub) => stub(Employee).fetchOne(1))
  .thenReturnObject(emp);

使用 thenReturnAggregationaggregate

mockClient
  .when((stub) =>
    stub(Employee)
      .where({ employeeId: { $eq: 5 } })
      .aggregate({ $select: { "employeeLocation:exactDistinct": "asc" } })
  )
  .thenReturnAggregation({ employeeLocation: { exactDistinct: 3 } });

以相同方式桩接 $groupBy 聚合;返回一个分组行数组:

mockClient
  .when((stub) =>
    stub(Employee).aggregate({
      $select: { "employeeId:max": "unordered" },
      $groupBy: { employeeId: "exact" },
    })
  )
  .thenReturnAggregation([
    { $group: { employeeId: 5 }, employeeId: { max: 5 } },
  ]);

在同一个客户端上的多个桩

您可以根据需要注册任意数量的桩。桩会根据您的代码进行的调用进行匹配;注册顺序不影响匹配。

在模拟对象集上桩接调用

当代码直接接收 ObjectSet(不是从 client(Type) 构建的),或者当您要为由 MockObjectSet 支持的多链接桩接聚合或获取行为时,请针对集合本身注册桩:

import { 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 });

然后可以将同一个 empSet 传递给您的代码期望 ObjectSet<Employee> 的任何地方:传递给被测函数,或作为父模拟对象上的多链接目标。

桩接 Foundry 查询

查询(本体上的函数)也可以被桩接:

import { addOne } from "your-app-sdk";

mockClient.whenQuery(addOne, { n: 5 }).thenReturn(6);
mockClient.whenQuery(addOne, { n: 99 }).thenThrow(new Error("boom"));

thenReturn(value) 将查询 promise 解析为 value,而 thenThrow(error) 拒绝它。不同的参数对象可以独立桩接:

mockClient.whenQuery(addOne, { n: 10 }).thenReturn(11);
mockClient.whenQuery(addOne, { n: 20 }).thenReturn(21);

具有数组参数的查询遵循相同的模式;匹配您的代码传递的参数:

mockClient
  .whenQuery(queryTypeReturnsArray, { people: ["Alice", "Bob"] })
  .thenReturn(["Alice - processed", "Bob - processed"]);

在测试之间重置桩

mockClient.clearStubs() 移除在客户端上注册的所有桩。如果您在多个 it 块之间重用客户端,这很有用;否则,为每个测试构造一个新的 createMockClient() 以实现隔离。

使用 MSW 测试 Foundry 平台 API

createMockClient 仅桩接本体调用(对象类型、查询和对象集)。它不会拦截 @osdk/foundry.*@osdk/internal.foundry.* 中的 Foundry 平台 API;这些调用通过常规的 fetch 路径进行。要在测试中阻止它们访问网络,请使用 MSW ↗ 拦截它们。

工作原理

您使用网络请求桩接库来桩接平台 SDK 请求。它使用一个占位符基础 URL:

https://mock.invalid/

您的代码进行的每个平台调用都将针对该源进行解析。为您的函数调用的特定路径提供 MSW 处理程序。

设置 MSW

将 MSW 安装为开发依赖:

npm install --save-dev msw

在您的测试文件中设置一个 Node 服务器。标准的 MSW 生命周期钩子在测试之间重置处理程序,以便每个 it 块是隔离的:

import { 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());

设置 onUnhandledRequest: "error";它会在没有处理程序的情况下暴露意外的平台调用,而不是让它们静默通过。

桩接平台调用

一个加载当前用户并根据用户名后缀进行门控的函数:

import 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;
}

测试使用 MSW 桩接平台端点,并使用 createMockClient() 作为 Client

import 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",
    );
  });
});

:::callout{theme="neutral" title="针对真实平台类型输入您的测试数据"} MSW 响应体是一个 JSON 字面量,因此响应体可能会偏离实际的 @osdk/foundry.* 形状(重命名的字段、新的必需属性等),并且只在运行时失败。

为了将测试数据与真实类型绑定,从 Platform 函数本身派生它:

import type { somePlatformFn } from "@osdk/foundry.<service>/<Resource>";

type ResponseShape = Awaited<ReturnType<typeof somePlatformFn>>;

然后使用 satisfies ResponseShape 断言响应体:

HttpResponse.json(
  {/* ...response fields... */} satisfies ResponseShape,
);

Awaited<ReturnType<typeof fn>> 展开由 async 函数返回的 Promise<T>,直接为您提供 T,这确保模拟的响应与真实调用返回的内容完全匹配。

在上面的示例中,此模式的具体实例是 type User = Awaited<ReturnType<typeof getCurrent>>;根据端点返回的内容命名别名。 :::

提示

  • 使用 onUnhandledRequest: "error" 发现缺少的处理程序比调试挂起的测试更简单。
  • 在处理程序之间重用同一个服务器。 在模块作用域调用一次 setupServer(),然后在每个 it 块内部调用 server.use(...) 以设置每个测试的处理程序。使用 afterEach(server.resetHandlers) 清除它们。
  • 将本体桩与平台桩结合使用。 单个 mockClient 同时处理两种调用;本体调用通过 whenwhenObjectSetwhenQuery 进行,而平台调用通过 MSW 进行。