Skip to content

Commit 40e8784

Browse files
FEATURE: Added unit testing and fixed response feature
1 parent 67f9fe1 commit 40e8784

11 files changed

Lines changed: 554 additions & 27 deletions

File tree

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,12 @@ This creates:
6969
prompts/
7070
├── defaults.md # Folder-level defaults (provider, model, metadata, system instructions)
7171
├── hello.md # Sample prompt with variables
72-
├── hello.test.yaml # Test sidecar with sample inputs
72+
├── hello.test.yaml # Test sidecar with sample inputs and hardcoded responses
7373
└── shared/
7474
└── tone.md # Shared system instructions (included via composition)
75+
76+
tests/
77+
└── hello.prompt.test.mjs # Executable starter test for the hello prompt
7578
```
7679

7780
### 2. Write a prompt
@@ -540,11 +543,21 @@ Hello {{ name }}!`,
540543
## Testing Helpers
541544

542545
```typescript
543-
import { createMockAsset, createMockResolvedAsset, parseTestPrompt } from 'promptopskit/testing';
546+
import {
547+
createHardcodedPromptResponder,
548+
createMockAsset,
549+
createMockResolvedAsset,
550+
loadPromptTestSidecar,
551+
parseTestPrompt,
552+
} from 'promptopskit/testing';
544553

545554
const asset = createMockAsset({ model: 'gpt-5.4' });
546555
const resolved = createMockResolvedAsset();
547556
const parsed = parseTestPrompt('---\nid: test\nschema_version: 1\n---\n\nHello');
557+
558+
const sidecar = await loadPromptTestSidecar('./prompts/hello.test.yaml');
559+
const respond = createHardcodedPromptResponder(sidecar);
560+
const response = respond('basic-greeting');
548561
```
549562

550563
## API Reference

docs/testing.md

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ npm run test:serial
1919
Import from `promptopskit/testing`:
2020

2121
```typescript
22-
import { createMockAsset, createMockResolvedAsset, parseTestPrompt } from 'promptopskit/testing';
22+
import {
23+
createHardcodedPromptResponder,
24+
createMockAsset,
25+
createMockResolvedAsset,
26+
getHardcodedPromptResponse,
27+
loadPromptTestSidecar,
28+
parseTestPrompt,
29+
renderPromptTestCase,
30+
} from 'promptopskit/testing';
2331
```
2432

2533
### `createMockAsset(overrides?)`
@@ -82,7 +90,7 @@ Hello {{ name }}!
8290

8391
## Test sidecar files
8492

85-
By convention, test data for a prompt lives in a `.test.yaml` file alongside the prompt:
93+
By convention, test data for a prompt lives in a `.test.yaml` file alongside the prompt. `promptopskit init` creates `hello.md`, `hello.test.yaml`, and `tests/hello.prompt.test.mjs` so the starter prompt has executable test coverage immediately.
8694

8795
```
8896
prompts/
@@ -100,9 +108,13 @@ cases:
100108
- name: basic-greeting
101109
variables:
102110
name: "World"
111+
response:
112+
message: "Hello, World! How can I help you today?"
103113
- name: named-greeting
104114
variables:
105115
name: "Alice"
116+
response:
117+
message: "Hello, Alice! How can I help you today?"
106118
```
107119
108120
Each case has:
@@ -111,6 +123,7 @@ Each case has:
111123
|-------|------|-------------|
112124
| `name` | `string` | Test case name |
113125
| `variables` | `Record<string, string>` | Variable values for this case |
126+
| `response` | `unknown` | Optional hardcoded response for deterministic development and CI tests |
114127

115128
### CLI integration
116129

@@ -124,19 +137,38 @@ promptopskit render hello.md
124137
### Using in tests
125138

126139
```typescript
127-
import { readFileSync } from 'node:fs';
128-
import { parse } from 'yaml';
129140
import { createPromptOpsKit } from 'promptopskit';
141+
import { loadPromptTestSidecar, renderPromptTestCase } from 'promptopskit/testing';
130142
131143
const kit = createPromptOpsKit({ sourceDir: './prompts' });
132-
const sidecar = parse(readFileSync('./prompts/hello.test.yaml', 'utf-8'));
144+
const sidecar = await loadPromptTestSidecar('./prompts/hello.test.yaml');
133145
134146
for (const testCase of sidecar.cases) {
135-
const result = await kit.renderPrompt({
147+
const { rendered, response } = await renderPromptTestCase(kit, {
148+
sidecar,
149+
caseName: testCase.name,
136150
path: 'hello',
137151
provider: 'openai',
138-
variables: testCase.variables,
152+
environment: 'dev',
153+
strict: true,
139154
});
140-
// Assert on result.request.body
155+
156+
// Assert on rendered.request.body and, when present, response.
141157
}
142158
```
159+
160+
### Hardcoded responses
161+
162+
PromptOpsKit renders provider request bodies, but your app owns the network call. For unit tests and local development, keep deterministic responses in the sidecar and route your app through a tiny fake model runner:
163+
164+
```typescript
165+
import { createHardcodedPromptResponder, loadPromptTestSidecar } from 'promptopskit/testing';
166+
167+
const sidecar = await loadPromptTestSidecar('./prompts/hello.test.yaml');
168+
const respond = createHardcodedPromptResponder(sidecar);
169+
170+
const result = respond('basic-greeting');
171+
// { message: 'Hello, World! How can I help you today?' }
172+
```
173+
174+
This is intentionally different from GitHub Models: GitHub Models is useful for interactive prompt prototyping, side-by-side model comparison, and evaluations in GitHub. PromptOpsKit sidecars are repo-native fixtures for rendering, unit tests, CI, and deterministic app development without making provider calls.

fixtures/prompts/hello.test.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ cases:
33
variables:
44
name: "World"
55
app_context: "Onboarding flow"
6+
response:
7+
message: "Hello, World! How can I help you today?"
68
- name: named
79
variables:
810
name: "Alice"
911
app_context: "Dashboard"
12+
response:
13+
message: "Hello, Alice! How can I help you today?"

src/cli/commands/init.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { writeFile, mkdir } from 'node:fs/promises';
2-
import { join, dirname } from 'node:path';
2+
import { join, dirname, relative } from 'node:path';
33
import { existsSync, readFileSync } from 'node:fs';
44

55
const HELP = `
@@ -76,10 +76,14 @@ const TEST_SIDECAR = `cases:
7676
variables:
7777
name: "World"
7878
app_context: "Welcome screen"
79+
response:
80+
message: "Hello, World! How can I help you today?"
7981
- name: named-greeting
8082
variables:
8183
name: "Alice"
8284
app_context: "Settings page"
85+
response:
86+
message: "Hello, Alice! How can I help you today?"
8387
`;
8488

8589
const EXAMPLE_USAGE = `// Example: render the hello prompt and send it to OpenAI
@@ -154,13 +158,17 @@ export async function init(args: string[]): Promise<void> {
154158
}
155159

156160
const dir = args.find((a) => !a.startsWith('--')) ?? './prompts';
161+
const testFilePath = join(dirname(dir), 'tests', 'hello.prompt.test.mjs');
162+
const promptsDirFromTest = relative(dirname(testFilePath), dir) || '.';
163+
const helloPromptTest = createHelloPromptTest(promptsDirFromTest);
157164

158165
const files: Array<{ path: string; content: string }> = [
159166
{ path: join(dir, 'defaults.md'), content: DEFAULTS },
160167
{ path: join(dir, 'hello.md'), content: HELLO_PROMPT },
161168
{ path: join(dir, 'hello.test.yaml'), content: TEST_SIDECAR },
162169
{ path: join(dir, 'shared', 'tone.md'), content: TONE_INCLUDE },
163170
{ path: join(dir, 'example-usage.ts'), content: EXAMPLE_USAGE },
171+
{ path: testFilePath, content: helloPromptTest },
164172
];
165173

166174
let created = 0;
@@ -190,8 +198,56 @@ export async function init(args: string[]): Promise<void> {
190198
console.log(`Tip: Add to your package.json scripts:`);
191199
console.log(` "build:prompts": "promptopskit compile ${dir}"`);
192200
}
201+
if (!pkg.scripts?.test) {
202+
console.log();
203+
console.log(`Tip: Add a test script to run the generated prompt test:`);
204+
console.log(` "test": "node --test tests/*.test.mjs"`);
205+
}
193206
} catch {
194207
// Ignore parse errors
195208
}
196209
}
197210
}
211+
212+
function createHelloPromptTest(promptsDirFromTest: string): string {
213+
return `import assert from 'node:assert/strict';
214+
import { dirname, resolve } from 'node:path';
215+
import test from 'node:test';
216+
import { fileURLToPath } from 'node:url';
217+
import { createPromptOpsKit } from 'promptopskit';
218+
import {
219+
getHardcodedPromptResponse,
220+
loadPromptTestSidecar,
221+
renderPromptTestCase,
222+
} from 'promptopskit/testing';
223+
224+
const testDir = dirname(fileURLToPath(import.meta.url));
225+
const promptsDir = resolve(testDir, ${JSON.stringify(promptsDirFromTest)});
226+
227+
test('hello prompt renders every sidecar case', async () => {
228+
const kit = createPromptOpsKit({ sourceDir: promptsDir, cache: false });
229+
const sidecar = await loadPromptTestSidecar(resolve(promptsDir, 'hello.test.yaml'));
230+
231+
for (const testCase of sidecar.cases) {
232+
const { rendered } = await renderPromptTestCase(kit, {
233+
sidecar,
234+
caseName: testCase.name,
235+
path: 'hello',
236+
provider: 'openai',
237+
environment: 'dev',
238+
strict: true,
239+
});
240+
241+
assert.ok(rendered.request?.body?.messages);
242+
}
243+
});
244+
245+
test('hello prompt can return a deterministic response without calling a model', async () => {
246+
const sidecar = await loadPromptTestSidecar(resolve(promptsDir, 'hello.test.yaml'));
247+
248+
assert.deepEqual(getHardcodedPromptResponse(sidecar, 'basic-greeting'), {
249+
message: 'Hello, World! How can I help you today?',
250+
});
251+
});
252+
`;
253+
}

src/testing.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
1+
import { readFile } from 'node:fs/promises';
2+
3+
import matter from 'gray-matter';
4+
15
import { parsePrompt } from './parser/index.js';
26
import type { PromptAsset, ResolvedPromptAsset } from './schema/index.js';
37

8+
export interface PromptTestCase<TResponse = unknown> {
9+
name: string;
10+
variables?: Record<string, string>;
11+
response?: TResponse;
12+
expected_response?: TResponse;
13+
}
14+
15+
export interface PromptTestSidecar<TResponse = unknown> {
16+
cases: Array<PromptTestCase<TResponse>>;
17+
}
18+
19+
export interface PromptTestRenderer {
20+
renderPrompt(options: {
21+
path?: string;
22+
source?: string;
23+
provider: string;
24+
environment?: string;
25+
tier?: string;
26+
variables?: Record<string, string>;
27+
strict?: boolean;
28+
}): Promise<unknown>;
29+
}
30+
431
/**
532
* Create a mock PromptAsset for testing.
633
*/
@@ -47,3 +74,119 @@ export function parseTestPrompt(source: string): PromptAsset {
4774
const { asset } = parsePrompt(source);
4875
return asset;
4976
}
77+
78+
/**
79+
* Parse a .test.yaml sidecar file.
80+
*/
81+
export function parsePromptTestSidecar<TResponse = unknown>(source: string): PromptTestSidecar<TResponse> {
82+
const parsed = matter(`---\n${source.trim()}\n---\n`);
83+
const data = parsed.data as Partial<PromptTestSidecar<TResponse>>;
84+
85+
if (!Array.isArray(data.cases)) {
86+
throw new Error('Prompt test sidecar must include a "cases" array.');
87+
}
88+
89+
return {
90+
cases: data.cases.map((testCase, index) => {
91+
if (!testCase || typeof testCase !== 'object') {
92+
throw new Error(`Prompt test case at index ${index} must be an object.`);
93+
}
94+
95+
if (typeof testCase.name !== 'string' || testCase.name.length === 0) {
96+
throw new Error(`Prompt test case at index ${index} must include a non-empty "name".`);
97+
}
98+
99+
return testCase;
100+
}),
101+
};
102+
}
103+
104+
/**
105+
* Load a .test.yaml sidecar file from disk.
106+
*/
107+
export async function loadPromptTestSidecar<TResponse = unknown>(
108+
filePath: string | URL,
109+
): Promise<PromptTestSidecar<TResponse>> {
110+
return parsePromptTestSidecar<TResponse>(await readFile(filePath, 'utf-8'));
111+
}
112+
113+
/**
114+
* Find a named test case in a sidecar.
115+
*/
116+
export function getPromptTestCase<TResponse = unknown>(
117+
sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>,
118+
name: string,
119+
): PromptTestCase<TResponse> {
120+
const cases = Array.isArray(sidecar) ? sidecar : sidecar.cases;
121+
const testCase = cases.find((candidate) => candidate.name === name);
122+
123+
if (!testCase) {
124+
throw new Error(`Prompt test case "${name}" was not found.`);
125+
}
126+
127+
return testCase;
128+
}
129+
130+
/**
131+
* Read the canned response for a named case.
132+
*/
133+
export function getHardcodedPromptResponse<TResponse = unknown>(
134+
sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>,
135+
name: string,
136+
): TResponse {
137+
const testCase = getPromptTestCase(sidecar, name);
138+
const response = testCase.response ?? testCase.expected_response;
139+
140+
if (response === undefined) {
141+
throw new Error(`Prompt test case "${name}" does not define a "response".`);
142+
}
143+
144+
return response;
145+
}
146+
147+
/**
148+
* Create a small responder for unit tests and local development flows.
149+
*/
150+
export function createHardcodedPromptResponder<TResponse = unknown>(
151+
sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>,
152+
): (name: string) => TResponse {
153+
return (name) => getHardcodedPromptResponse(sidecar, name);
154+
}
155+
156+
/**
157+
* Render a prompt using variables from a named sidecar case.
158+
*/
159+
export async function renderPromptTestCase<TResponse = unknown>(
160+
kit: PromptTestRenderer,
161+
options: {
162+
sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>;
163+
caseName: string;
164+
path?: string;
165+
source?: string;
166+
provider: string;
167+
environment?: string;
168+
tier?: string;
169+
strict?: boolean;
170+
},
171+
): Promise<{
172+
testCase: PromptTestCase<TResponse>;
173+
rendered: unknown;
174+
response?: TResponse;
175+
}> {
176+
const testCase = getPromptTestCase(options.sidecar, options.caseName);
177+
const rendered = await kit.renderPrompt({
178+
path: options.path,
179+
source: options.source,
180+
provider: options.provider,
181+
environment: options.environment,
182+
tier: options.tier,
183+
variables: testCase.variables,
184+
strict: options.strict,
185+
});
186+
187+
return {
188+
testCase,
189+
rendered,
190+
response: testCase.response ?? testCase.expected_response,
191+
};
192+
}

0 commit comments

Comments
 (0)