@zyplux/tests-fixtures
v0.5.1
Published
Story-test fixtures for zyplux workspaces: Node exec fake, console capture, fetch routing, temp dirs, domain matchers, and vitest test.extend bases per app type
Maintainers
Readme
@zyplux/tests-fixtures
Story-test fixtures for Node CLIs and libraries. Fakes swap in at the lowest boundary (node:child_process, node:readline/promises, node:timers/promises, fetch, console, process.env) so tests exercise only public interfaces.
Install
pnpm add -D @zyplux/tests-fixturesEach fake needs the Node module it stands in for mocked in that project's own vitest setup file — only the modules whose fakes you use:
import { vi } from 'vitest';
vi.mock('node:child_process', async importOriginal => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: vi.fn(actual.spawn) }; // shell fake
});
// node:readline/promises -> createInterface (prompt fake); node:timers/promises -> setTimeout (instant sleeps)Each factory wraps the original, so the module stays fully live until a fake installs an implementation. A fake whose module is unmocked says which one to add.
Use
Pick a base per app type, extend it with suite fixtures, and keep the binding named test:
import { cliTest } from '@zyplux/tests-fixtures/story';
export const test = cliTest;
export { describe, expect } from 'vitest';import { describe, expect, test } from '#fixtures';
describe('1.1 pushing a branch', () => {
test('1.1.1 pushes and reports the PR url', async ({ logs, shell }) => {
shell.on('git rev-parse --abbrev-ref HEAD', 'feat-x');
shell.on('git push', '');
await runPushBranch({ command: 'push-branch', hold: false, ready: false });
expect(shell).toHaveRun('git push --set-upstream origin feat-x');
expect(logs).toHaveLogged('PR (draft): https://github.com/acme/repo/pull/1');
});
});Entry points
There is no barrel — import each fixture from its own subpath: /story (libraryTest, cliTest, makeFixture), /shell, /console, /fetch, /fs, /prompt, /cli, /matchers.
Bases
libraryTest— lazy fixtures:shell(fakenode:child_processspawn, installed only when destructured),tempDir(auto-removed scratch directory withpath,write,exists).cliTest— extendslibraryTest; auto-silences and capturesconsole(logs), makesnode:timers/promisessleeps instant; adds lazynetwork(fakefetch),prompt(fakenode:readline/promisesinterface that accepts and records every question), andenv(set(name, value)stubs an env var for the test).
Fakes
createShellFake()— routes commands (on(pattern, ...replies), later routes win, the last reply repeats;otherwise(reply)sets a fallback, unrouted commands throw) and recordscalls({ argv, cwd?, env?, program, stdin? }),commands(rendered strings),commandsMatching(pattern).createConsoleCapture()— recordslogLines/warnLines/errorLines.createFetchFake()— routes urls (on(prefixOrRegExp, reply),otherwise(reply)) and recordsrequests;okResponse()/notFoundResponse()build replies.createPromptFake()— accepts everyquestion()call and recordsmessages.createTempDir()—path,write(relativePath, content),exists(relativePath),remove().
Matchers
Importing a base registers domain matchers via expect.extend:
expect(shell).toHaveRun(command)— the exact rendered command ran.expect(shell).toHaveRunMatching(pattern)— some command matches (string = command prefix at a word boundary, same ason; RegExp = test); negate with.notfor "never ran".expect(logs).toHaveLogged(line?)/toHaveWarned(line?)/toHaveErrored(line?)— a captured line equals the string (or matches the RegExp); with no argument, that the channel captured anything, so.not.toHaveWarned()asserts silence.
