Skip to content

Testing schematics

Run your factory through runFactoryForTest, then assert on the resulting tree — no CLI, no engine process, and no generated files on disk.

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:

package.json
{
"private": true,
"type": "module"
}
Terminal window
npm install --save-dev --save-exact @pbuilder/[email protected] [email protected]

Use a JavaScript ESM factory to keep the example independent of TypeScript transforms:

factory.js
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:

tests/vitest.test.js
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');
});
vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'node', include: ['tests/vitest.test.js'] },
});
Terminal window
node node_modules/vitest/vitest.mjs run

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

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.

Terminal window
npm install --save-dev --save-exact @pbuilder/[email protected] [email protected]

In tests/jest.test.js, replace the Vitest import with:

import { expect, test } from '@jest/globals';
jest.config.js
export default {
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/jest.test.js'],
transform: {},
};
Terminal window
node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand

Expected result: one passing test. This uses Jest’s native ESM mode with transforms disabled; Node emits an experimental VM Modules warning.

Terminal window
npm install --save-dev --save-exact @pbuilder/[email protected] @rstest/[email protected]

In tests/rstest.test.js, replace the Vitest import with:

import { expect, test } from '@rstest/core';
rstest.config.js
import { defineConfig } from '@rstest/core';
export default defineConfig({
testEnvironment: 'node',
include: ['tests/rstest.test.js'],
});
Terminal window
node node_modules/@rstest/core/bin/rstest.js run

Expected result: one passing test, using the same factory and assertions.

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.

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.

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.

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.

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’s exports map, which is the only route to @pbuilder/sdk’s subpaths (./testing, ./commons, and friends). Without this, your editor reports Cannot find module '@pbuilder/sdk/commons' even though the import is correct.
  • allowImportingTsExtensions: true — factories import the generated types with an explicit .ts extension (./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 resolve bun:test from @types/bun.