TypeScript OSDK 코드 단위 테스트
@osdk/unit-testing은 OSDK Client를 받는 코드를 Foundry 요청 없이 테스트하기 위한 도구입니다. Foundry 함수도 같은 클라이언트 경계에서 테스트할 수 있습니다. 패키지는 Experimental이며 모든 내보내기는 /experimental 하위 경로에서 제공합니다.
설치와 범위
npm install --save-dev @osdk/unit-testing
프로젝트에는 호환되는 @osdk/api, @osdk/client와 @osdk/functions peer dependency가 필요합니다. 아래 테스트는 Vitest를 사용하지만 다른 테스트 실행 도구도 사용할 수 있습니다.
| 도구 | 용도 |
|---|---|
| createMockClient | Client 계약을 만족하며 when, whenObjectSet과 whenQuery로 호출 결과를 지정 |
| createMockOsdkObject | 객체 속성과 내장 메타데이터를 가진 테스트 인스턴스 |
| createMockObjectSet | 입력 집합 또는 다중 링크에 사용할 테스트 집합 |
| createMockAttachment | 파일 내용을 가진 테스트 첨부 값 |
테스트 대체 응답은 실제 서비스의 결과가 아닙니다. 앱이 해당 응답을 받았을 때 어떻게 처리하는지를 검증합니다. 플랫폼의 인증과 실제 데이터 권한, 질의 엔진의 결과는 별도 통합 범위입니다.
첫 페이지의 직원 읽기
예제 생성 패키지 your-app-sdk에는 Integer 기본 키 employeeId와 String fullName을 가진 Employee가 있다고 가정합니다. basicFetchPage.ts는 첫 객체가 없으면 오류를 반환합니다.
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 page = await client(Employee).fetchPage();
const employee = page.data[0];
if (employee === undefined) throw new Error("No objects returned");
return employee;
}
basicFetchPage.test.ts에서 같은 호출 체인을 when에 등록합니다.
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 client = createMockClient();
const employee = createMockOsdkObject(Employee, { employeeId: 1, fullName: "John" });
client.when(stub => stub(Employee).fetchPage()).thenReturnObjects([employee]);
await expect(basicFetchPage(client)).resolves.toEqual(employee);
});
it("rejects an empty page", async () => {
const client = createMockClient();
client.when(stub => stub(Employee).fetchPage()).thenReturnObjects([]);
await expect(basicFetchPage(client)).rejects.toThrow("No objects returned");
});
});
비동기 resolves와 rejects 검증은 await하여 결과를 기다립니다.
테스트 객체의 구성
createMockOsdkObject(objectType, properties, options)에 생성 유형 정의와 속성을 전달합니다. properties에는 생성 정의의 primaryKeyApiName에 해당하는 기본 키가 반드시 있어야 하며 빠지면 오류가 발생합니다. 나머지 속성은 코드가 읽을 값을 준비합니다.
options에는 links, titlePropertyApiName과 $rid를 지정할 수 있습니다.
| 필드 | 테스트 인스턴스 값 |
|---|---|
| $apiName, $objectType | 유형의 API 이름 |
| $primaryKey | 입력 속성의 기본 키 값 |
| $title | titlePropertyApiName으로 지정한 속성 값 |
| $rid | 명시 값 또는 ri.mock.main.object.<apiName>.<primaryKey> |
| $objectSpecifier | 유형과 키를 포함한 문자열 |
| $link | links 설정을 읽는 접근자 |
| $clone(updates) | 속성을 병합한 새 고정 객체 |
$title이 필요하면 titlePropertyApiName을 지정해야 합니다. 해당 속성을 properties에 넣지 않으면 오류가 나며 옵션 자체를 생략하면 $title은 undefined입니다.
import { createMockOsdkObject } from "@osdk/unit-testing/experimental";
import { Employee } from "your-app-sdk";
const employee = createMockOsdkObject(
Employee,
{ employeeId: 1, fullName: "John Doe" },
{ titlePropertyApiName: "fullName" },
);
const renamed = employee.$clone({ fullName: "Jane Doe" });
clone은 기본 키를 유지하며 다른 키로 바꾸면 오류가 발생합니다. $as와 $__EXPERIMENTAL__NOT_SUPPORTED_YET__* 접근자는 모델링하지 않으며 접근하면 오류가 발생합니다.
단일 링크
links의 키는 실제 링크 API 이름입니다. 단일 링크는 객체 또는 Error를 받습니다. 가상의 Employee.officeLink가 Office로 연결된다고 가정합니다.
import { createMockOsdkObject } from "@osdk/unit-testing/experimental";
import { expect, it } from "vitest";
import { Employee, Office } from "your-app-sdk";
it("returns a linked office", async () => {
const office = createMockOsdkObject(Office, { officeId: "nyc", name: "New York Office" });
const employee = createMockOsdkObject(Employee, { employeeId: 1 }, { links: { officeLink: office } });
await expect(employee.$link.officeLink.fetchOne()).resolves.toEqual(office);
const result = await employee.$link.officeLink.fetchOneWithErrors();
expect(result.value).toEqual(office);
});
it("returns a link failure", async () => {
const error = new Error("link unavailable");
const employee = createMockOsdkObject(Employee, { employeeId: 1 }, { links: { officeLink: error } });
await expect(employee.$link.officeLink.fetchOne()).rejects.toThrow("link unavailable");
const result = await employee.$link.officeLink.fetchOneWithErrors();
expect(result.error).toBe(error);
});
설정하지 않은 링크에도 접근자는 있지만 fetchOne은 거절하고 fetchOneWithErrors는 오류를 담아 반환합니다. 진단에는 링크 이름, 유형과 기본 키가 포함됩니다.
다중 링크와 테스트 집합
다중 링크는 객체 배열 또는 MockObjectSet을 받습니다. 배열이면 fetchPage().data, 기본 키로 선택하는 fetchOne과 asyncIter를 사용할 수 있습니다. 배열에 해당 키가 없으면 fetchOne이 실패합니다. 배열 방식은 aggregate를 지원하지 않습니다.
where나 aggregate가 필요하면 createMockObjectSet으로 집합을 만들고 whenObjectSet에 동작을 등록합니다. 새 집합 자체에는 데이터가 없습니다.
import { createMockClient, createMockObjectSet, createMockOsdkObject } from "@osdk/unit-testing/experimental";
import { expect, it } from "vitest";
import { Employee } from "your-app-sdk";
it("aggregates a many-link object set", async () => {
const client = createMockClient();
const peeps = createMockObjectSet(Employee);
client.whenObjectSet(peeps, set => set.aggregate({ $select: { $count: "unordered" } }))
.thenReturnAggregation({ $count: 7 });
const employee = createMockOsdkObject(Employee, { employeeId: 1 }, { links: { peeps } });
const result = await employee.$link.peeps.aggregate({ $select: { $count: "unordered" } });
expect(result.$count).toBe(7);
});
Employee.peeps는 이 예제의 다중 자기 링크입니다. 7은 테스트 응답으로 지정한 값입니다.
호출별 반환 빌더
| 등록 | 대응 반환 |
|---|---|
| when(stub => stub(Type).fetchPage(...)) | thenReturnObjects(객체 배열). asyncIter에도 같은 응답을 제공 |
| when(stub => stub(Type).fetchOne(key)) | thenReturnObject(객체) |
| when(stub => stub(Type).where(...).aggregate(...)) | thenReturnAggregation(집계 결과) |
| whenObjectSet(set, callback) | 해당 MockObjectSet의 fetch/where/aggregate 체인에 맞는 빌더 |
| whenQuery(query, params) | thenReturn(value) 또는 thenThrow(error) |
| clearStubs() | 해당 클라이언트에 등록한 모든 응답 제거 |
타입을 명시해 빌더를 전달해야 하면 AggregateStubBuilder, FetchOneStubBuilder, FetchPageStubBuilder, QueryStubBuilder와 StubBuilderFor도 같은 /experimental 경로에서 내보냅니다.
그룹 집계의 대체 응답은 평평한 그룹 행 배열입니다.
import { createMockClient } from "@osdk/unit-testing/experimental";
import { Employee } from "your-app-sdk";
const client = createMockClient();
client.when(stub => stub(Employee).aggregate({
$select: { "employeeId:max": "unordered" },
$groupBy: { employeeId: "exact" },
})).thenReturnAggregation([
{ $group: { employeeId: 5 }, employeeId: { max: 5 } },
]);
같은 클라이언트에 여러 응답을 등록할 수 있으며 등록 순서는 매칭에 영향을 주지 않습니다. 테스트마다 새 클라이언트를 만들거나 clearStubs로 상태를 초기화합니다.
생성 쿼리
whenQuery는 같은 쿼리라도 매개변수 객체별로 응답을 구분합니다. addOne과 queryTypeReturnsArray는 앱에 실제 가져온 생성 쿼리 정의라는 가정입니다.
import { createMockClient } from "@osdk/unit-testing/experimental";
import { addOne, queryTypeReturnsArray } from "your-app-sdk";
const client = createMockClient();
client.whenQuery(addOne, { n: 5 }).thenReturn(6);
client.whenQuery(addOne, { n: 99 }).thenThrow(new Error("boom"));
client.whenQuery(queryTypeReturnsArray, { people: ["Alice", "Bob"] })
.thenReturn(["Alice - processed", "Bob - processed"]);
thenReturn은 쿼리 Promise를 정상값으로, thenThrow는 거절된 Promise로 만듭니다.
첨부 파일 입력
createMockAttachment는 RID와 Blob으로 첨부 파일 테스트 값을 만듭니다.
import { createMockAttachment } from "@osdk/unit-testing/experimental";
const attachment = createMockAttachment(
"ri.attachments.main.attachment.abc",
new Blob(["hello world!"], { type: "text/plain" }),
);
RID는 테스트용 가상 값입니다. 원본 파일의 실제 업로드를 수행하지 않습니다.
Platform API는 별도로 가로채기
createMockClient는 온톨로지 객체, 집합과 쿼리를 대체합니다. @osdk/foundry.* 또는 @osdk/internal.foundry.*의 Platform API는 일반 fetch 경로로 요청하므로 MSW로 가로채야 합니다. 테스트 기본 주소는 https://mock.invalid/입니다.
아래 예제는 MSW 3.0.3 API를 사용합니다. 이 버전에는 Node.js 22.12.0 이상이 필요하며 TypeScript를 사용하는 경우 peer 조건은 5.9 이상입니다.
npm install --save-dev msw@3.0.3 vitest
MSW 3의 http와 HttpResponse는 msw/http에서 가져오고, 처리하지 않은 요청 및 WebSocket 연결은 onUnhandledFrame으로 제어합니다. 이전 MSW 2의 msw 루트 import 및 onUnhandledRequest와 다릅니다.
테스트 대상 Platform 호출
requireDemoSuffix.ts는 현재 사용자의 이름이 @admin으로 끝나는지 확인하는 가상 문자열 규칙입니다. 이 접미사는 실제 Foundry 관리자 역할이나 권한을 증명하지 않습니다.
import type { Client } from "@osdk/client";
import { Users } from "@osdk/foundry.admin";
export async function requireDemoSuffix(client: Client): Promise<string> {
const user = await Users.getCurrent(client);
if (!user.username.endsWith("@admin")) throw new Error("Username does not match the demo suffix");
return user.username;
}
MSW 요청 대체와 정리
requireDemoSuffix.test.ts는 서버를 한 번 만들고 테스트마다 handler를 초기화합니다. 처리하지 않은 요청은 error로 막아 실제 네트워크로 빠져나가지 않게 합니다.
import type { getCurrent } from "@osdk/foundry.admin/User";
import { createMockClient } from "@osdk/unit-testing/experimental";
import { http, HttpResponse } from "msw/http";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { requireDemoSuffix } from "./requireDemoSuffix.js";
type User = Awaited<ReturnType<typeof getCurrent>>;
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("requireDemoSuffix", () => {
it("returns a matching username", 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)));
await expect(requireDemoSuffix(createMockClient())).resolves.toBe("alice@admin");
});
it("rejects a nonmatching username", 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)));
await expect(requireDemoSuffix(createMockClient())).rejects.toThrow("Username does not match the demo suffix");
});
});
Awaited<ReturnType<typeof getCurrent>>는 실제 Platform 메서드의 Promise 안쪽 반환 타입을 얻습니다. satisfies는 테스트 JSON이 그 타입과 맞는지 검사하므로 필드 이름이나 필수 속성 변경을 타입 단계에서 발견할 수 있습니다. 런타임의 실제 API 응답을 검증한 결과와는 다릅니다.
온톨로지와 Platform 호출을 한 테스트에서 함께 사용하면 같은 mockClient의 when 계열은 온톨로지 호출을, MSW는 Platform 호출을 처리합니다.