Testing schematics
@pbuilder/sdk/testing provides utilities for testing schematics with your test runner.
Bun is not required for the in-memory factory example below: it runs on Node with
Vitest, Jest, or Rstest.
Run your factory through runFactoryForTest, then assert on the resulting tree — no
CLI, no engine process, and no generated files on disk.
A first test
Section titled “A first test”Start in a separate directory so this example does not change your application’s test
configuration. Use Node 26.8.1 and npm 11.19.0, the versions used to verify it.
Create this package.json, then install the SDK and Vitest:
{ "private": true, "type": "module"}Use a JavaScript ESM factory to keep the example independent of TypeScript transforms:
import { create } from '@pbuilder/sdk/commons';
export function greetingFactory(input) { create('src/greeting.txt', { template: `Hello, ${input.name}!\n`, options: {}, });}Create a tests directory and add:
import process from 'node:process';import { expect, test } from 'vitest';import { runFactoryForTest } from '@pbuilder/sdk/testing';import { greetingFactory } from '../factory.js';
test('commits a greeting through the SDK harness on Node without Bun', async () => { expect(process.versions.node).toBeDefined(); expect(process.versions.bun).toBeUndefined(); const result = await runFactoryForTest(greetingFactory, { name: 'Ada' }); expect(result.error).toBeUndefined(); expect(result.tree.get('src/greeting.txt')).toBe('Hello, Ada!\n');});import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'node', include: ['tests/vitest.test.js'] },});node node_modules/vitest/vitest.mjs runExpected result: one passing test. The greeting exists in the committed in-memory
tree, not as src/greeting.txt on disk. JavaScript interpolation produces the text;
this test does not exercise SDK template rendering.
Use Jest or Rstest instead
Section titled “Use Jest or Rstest instead”Keep the same package.json and factory.js. Copy the test above to the path for your
chosen runner and replace only its expect, test import; keep the other imports and
assertions unchanged. Install only the runner you choose. Each configuration selects
its own test file, so the three examples can also coexist.
In tests/jest.test.js, replace the Vitest import with:
import { expect, test } from '@jest/globals';export default { testEnvironment: 'node', testMatch: ['<rootDir>/tests/jest.test.js'], transform: {},};node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBandExpected result: one passing test. This uses Jest’s native ESM mode with transforms disabled; Node emits an experimental VM Modules warning.
Rstest
Section titled “Rstest”In tests/rstest.test.js, replace the Vitest import with:
import { expect, test } from '@rstest/core';import { defineConfig } from '@rstest/core';
export default defineConfig({ testEnvironment: 'node', include: ['tests/rstest.test.js'],});node node_modules/@rstest/core/bin/rstest.js runExpected result: one passing test, using the same factory and assertions.
Scope of the verified example
Section titled “Scope of the verified example”These versions passed the inline create case on Node 26.8.1 on macOS ARM64. The SDK
declares Node >=25.9.0 and Bun 1.3.14 in its engines; this result does not change those
requirements or establish official Node-only support for every SDK feature. Schema
validation, filesystem scaffolding, template rendering, and TypeScript type checking
are not covered by this example.
Runner references: Vitest configuration, Jest ESM, and Rstest configuration.
Read the result tree
Section titled “Read the result tree”result.tree is typed as ReadonlyMap<string, string> and is a Map at runtime,
not a plain object. Use .get(path) to read content (undefined when absent),
.has(path) to check membership, [...result.tree.keys()] to list paths, and
.size to count entries. Do not use result.tree[path] to read files.
Seed files and idempotence
Section titled “Seed files and idempotence”The seed option is a plain object (Record<string, string>); the returned
result.tree is a Map containing committed writes only, not a full workspace snapshot.
Seed files are readable by the factory, but untouched seed paths are absent from the
returned Map. A write to a seeded path appears when committed:
import { test, expect } from "vitest";import { runFactoryForTest } from "@pbuilder/sdk/testing";import { find, replaceContent } from "@pbuilder/sdk/commons";
test("a seeded file is readable; only the write is committed", async () => { const run = async (input: { name: string }) => { const existing = await find("services.txt").read(); replaceContent("services.txt", `${existing}\n${input.name}`); };
const seed = { "services.txt": "payments", "untouched.txt": "keep" }; const result = await runFactoryForTest(run, { name: "orders" }, { seed });
expect(result.error).toBeUndefined(); expect(result.tree.get("services.txt")).toEqual("payments\norders"); expect(result.tree.has("untouched.txt")).toBe(false);});packageDir — anchoring package-local verbs
Section titled “packageDir — anchoring package-local verbs”The options bag’s other field, packageDir (the schematic package’s absolute directory), anchors the
package-local verbs (scaffold, copyIn, create({ templateFile })) and opts the run
into schema-derived input validation against the adjacent schema.json. Without it those
verbs have nothing to resolve against. (When the CLI runs your schematic, it passes the
package location automatically — this is only a testing concern. See
Scaffolding for the verbs themselves.)
What the harness does — and doesn’t — fake
Section titled “What the harness does — and doesn’t — fake”Templates are stored verbatim in the test tree — rendering happens in the engine at
builder execute time, and the harness doesn’t fake it. So asserting on a templated
create means asserting on the raw {= .name =} text, not rendered output.
@pbuilder/sdk/testing ships 0.x, semver-exempt, until real use validates the result
shape.
Bun as an alternative
Section titled “Bun as an alternative”You can also use Bun’s test runner with the harness. Import test and expect from
bun:test in your Bun tests and run bun test with their path. The Node runtime
assertions above are specific to the Node smoke test; omit them when running on Bun.
Editor errors on bun:test?
Section titled “Editor errors on bun:test?”bun test strips types rather than checking them, so it runs fine either way — but your
editor needs a few tsconfig settings to resolve the imports. Add typescript and
@types/bun as dev dependencies, then make sure your tsconfig.json has:
moduleResolution: "NodeNext"(or"bundler") — TypeScript’s default legacy resolution cannot read a package’sexportsmap, which is the only route to@pbuilder/sdk’s subpaths (./testing,./commons, and friends). Without this, your editor reportsCannot find module '@pbuilder/sdk/commons'even though the import is correct.allowImportingTsExtensions: true— factories import the generated types with an explicit.tsextension (./schema.generated.ts), which TypeScript rejects by default.- If you restrict ambient types with
types, include"bun"alongside any existing entries so the editor can resolvebun:testfrom@types/bun.