puty 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ Puty is ideal for testing pure functions - functions that always return the same
12
12
  - [Usage](#usage)
13
13
  - [Testing Functions](#testing-functions)
14
14
  - [Testing Classes](#testing-classes)
15
+ - [Testing Factory Functions](#testing-factory-functions)
15
16
  - [Error Testing](#error-testing)
16
17
  - [Using Mocks](#using-mocks)
17
18
  - [Using !include Directive](#using-include-directive)
@@ -33,43 +34,110 @@ npm install puty
33
34
 
34
35
  ## Quick Start
35
36
 
37
+ Get up and running with Puty in just a few minutes!
36
38
 
37
- 1. Create a test runner file `puty.test.js` in your project:
39
+ ### Prerequisites
38
40
 
39
- ```js
40
- import { setupTestSuiteFromYaml } from "puty";
41
+ - Node.js with ES modules support
42
+ - Vitest installed in your project
41
43
 
42
- // Search for test files in the current directory
43
- await setupTestSuiteFromYaml();
44
+ ### Step 1: Install Puty
44
45
 
45
- // Or specify a different directory
46
- // await setupTestSuiteFromYaml("./tests");
46
+ ```bash
47
+ npm install puty
47
48
  ```
48
49
 
49
- **Note:** Puty uses ES module imports and requires your project to support ES modules. If you're using Node.js, make sure to add `"type": "module"` to your `package.json` or use `.mjs` file extensions.
50
+ ### Step 2: Setup Your Project
51
+
52
+ Ensure your `package.json` has ES modules enabled:
50
53
 
54
+ ```json
55
+ {
56
+ "type": "module"
57
+ }
58
+ ```
59
+
60
+ ### Step 3: Create a Function to Test
51
61
 
52
- 2. Create your first test file `math.test.yaml`:
62
+ Create `utils/validator.js`:
63
+
64
+ ```js
65
+ export function isValidEmail(email) {
66
+ const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
67
+ return regex.test(email);
68
+ }
69
+
70
+ export function capitalize(str) {
71
+ return str.charAt(0).toUpperCase() + str.slice(1);
72
+ }
73
+ ```
74
+
75
+ ### Step 4: Create Your Test File
76
+
77
+ Create `validator.test.yaml`:
53
78
 
54
79
  ```yaml
55
- file: './math.js'
56
- group: math
57
- suites: [add]
80
+ file: './utils/validator.js'
81
+ group: validator
82
+ suites: [isValidEmail, capitalize]
58
83
  ---
59
- suite: add
60
- exportName: add
84
+ suite: isValidEmail
85
+ exportName: isValidEmail
86
+ ---
87
+ case: valid email should return true
88
+ in: ['user@example.com']
89
+ out: true
90
+ ---
91
+ case: invalid email should return false
92
+ in: ['invalid-email']
93
+ out: false
94
+ ---
95
+ case: empty string should return false
96
+ in: ['']
97
+ out: false
61
98
  ---
62
- case: add two numbers
63
- in: [2, 3]
64
- out: 5
99
+ suite: capitalize
100
+ exportName: capitalize
101
+ ---
102
+ case: capitalize first letter
103
+ in: ['hello']
104
+ out: 'Hello'
105
+ ---
106
+ case: single letter
107
+ in: ['a']
108
+ out: 'A'
109
+ ```
110
+
111
+ ### Step 5: Create Test Runner
112
+
113
+ Create `puty.test.js`:
114
+
115
+ ```js
116
+ import path from 'path'
117
+ import { setupTestSuiteFromYaml } from 'puty'
118
+
119
+ const __dirname = path.dirname(new URL(import.meta.url).pathname)
120
+
121
+ await setupTestSuiteFromYaml(__dirname);
65
122
  ```
66
123
 
67
- 3. Run your tests:
124
+ ### Step 6: Run Your Tests
68
125
 
69
126
  ```bash
70
127
  npx vitest
71
128
  ```
72
129
 
130
+ You should see output like:
131
+ ```
132
+ ✓ validator > isValidEmail > valid email should return true
133
+ ✓ validator > isValidEmail > invalid email should return false
134
+ ✓ validator > isValidEmail > empty string should return false
135
+ ✓ validator > capitalize > capitalize first letter
136
+ ✓ validator > capitalize > single letter
137
+ ```
138
+
139
+ 🎉 **That's it!** You've just created declarative tests using YAML instead of JavaScript.
140
+
73
141
  ### Recommended Vitest Configuration
74
142
 
75
143
  To enable automatic test reruns when YAML test files change, create a `vitest.config.js` file in your project root:
@@ -195,6 +263,92 @@ executions:
195
263
  - Property assertions: Check instance properties (supports nested: `user.profile.name`)
196
264
  - Method assertions: Call methods and check their return values (supports nested: `settings.getTheme`)
197
265
 
266
+ ### Testing Factory Functions
267
+
268
+ Puty supports testing factory functions that return objects with methods. When using `executions` in a function test, you can omit the `out` field to skip asserting the factory's return value:
269
+
270
+ ```yaml
271
+ file: './store.js'
272
+ group: store
273
+ ---
274
+ suite: createStore
275
+ exportName: createStore
276
+ ---
277
+ case: test store methods
278
+ in:
279
+ - { count: 0 }
280
+ # No 'out' field - skip return value assertion
281
+ executions:
282
+ - method: getCount
283
+ in: []
284
+ out: 0
285
+ - method: dispatch
286
+ in: [{ type: 'INCREMENT' }]
287
+ out: 1
288
+ - method: getCount
289
+ in: []
290
+ out: 1
291
+ ```
292
+
293
+ This pattern is useful for:
294
+ - Factory functions that return objects with methods
295
+ - Builder patterns
296
+ - Module patterns that return APIs
297
+ - Any function that returns an object you want to test methods on
298
+
299
+ Key behaviors:
300
+ - When `out` field is omitted: The function is called but its return value is not asserted
301
+ - When `out:` is present (even empty): The return value is asserted (empty value in YAML equals `null`)
302
+ - This works for any function test, with or without `executions`
303
+
304
+ Examples:
305
+ ```yaml
306
+ # No assertion on return value
307
+ case: test without return assertion
308
+ in: [1, 2]
309
+
310
+ # Assert return value is null
311
+ case: test null return
312
+ in: [1, 2]
313
+ out:
314
+
315
+ # Assert return value is 42
316
+ case: test specific return
317
+ in: [1, 2]
318
+ out: 42
319
+ ```
320
+
321
+ #### Testing Undefined Values
322
+
323
+ To assert that a function returns `undefined`, use the special keyword `__undefined__`:
324
+
325
+ ```yaml
326
+ # Assert function returns undefined
327
+ case: test undefined return
328
+ in: []
329
+ out: __undefined__
330
+
331
+ # Also works in executions
332
+ executions:
333
+ - method: doSomething
334
+ in: []
335
+ out: __undefined__
336
+
337
+ # And in mock definitions
338
+ mocks:
339
+ callback:
340
+ calls:
341
+ - in: ['data']
342
+ out: __undefined__
343
+ ```
344
+
345
+ The `__undefined__` keyword works in:
346
+ - Function return value assertions (`out: __undefined__`)
347
+ - Method return value assertions in executions
348
+ - Mock return values
349
+ - Mock input expectations
350
+ - Property assertions (`value: __undefined__`)
351
+
198
352
  ### Error Testing
199
353
 
200
354
  You can test that functions or methods throw expected errors:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puty",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "A tooling function to test javascript functions and classes.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -25,4 +25,4 @@
25
25
  "devDependencies": {
26
26
  "vitest": "^3.2.1"
27
27
  }
28
- }
28
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { vi } from "vitest";
8
+ import { processUndefined } from "./utils.js";
8
9
 
9
10
  /**
10
11
  * Deep equality check for mock argument validation
@@ -41,12 +42,16 @@ const deepEqual = (a, b) => {
41
42
  * @param {Object} globalMocks - Global-level mock definitions
42
43
  * @returns {Object} Resolved mock definitions with hierarchy applied
43
44
  */
44
- export const resolveMocks = (caseMocks = {}, suiteMocks = {}, globalMocks = {}) => {
45
+ export const resolveMocks = (
46
+ caseMocks = {},
47
+ suiteMocks = {},
48
+ globalMocks = {},
49
+ ) => {
45
50
  const resolved = {};
46
-
51
+
47
52
  // Apply hierarchy: global -> suite -> case (case overrides suite, suite overrides global)
48
53
  Object.assign(resolved, globalMocks, suiteMocks, caseMocks);
49
-
54
+
50
55
  return resolved;
51
56
  };
52
57
 
@@ -57,26 +62,26 @@ export const resolveMocks = (caseMocks = {}, suiteMocks = {}, globalMocks = {})
57
62
  * @returns {any} Processed value with $mock: references replaced
58
63
  */
59
64
  export const processMockReferences = (value, mockFunctions) => {
60
- if (typeof value === 'string' && value.startsWith('$mock:')) {
65
+ if (typeof value === "string" && value.startsWith("$mock:")) {
61
66
  const mockName = value.substring(6); // Remove '$mock:' prefix
62
67
  if (!mockFunctions[mockName]) {
63
68
  throw new Error(`Mock '${mockName}' is referenced but not defined`);
64
69
  }
65
70
  return mockFunctions[mockName].mockFunction;
66
71
  }
67
-
72
+
68
73
  if (Array.isArray(value)) {
69
- return value.map(item => processMockReferences(item, mockFunctions));
74
+ return value.map((item) => processMockReferences(item, mockFunctions));
70
75
  }
71
-
72
- if (value && typeof value === 'object') {
76
+
77
+ if (value && typeof value === "object") {
73
78
  const processed = {};
74
79
  for (const [key, val] of Object.entries(value)) {
75
80
  processed[key] = processMockReferences(val, mockFunctions);
76
81
  }
77
82
  return processed;
78
83
  }
79
-
84
+
80
85
  return value;
81
86
  };
82
87
 
@@ -88,40 +93,62 @@ export const processMockReferences = (value, mockFunctions) => {
88
93
  * @returns {Object} Mock function wrapper with validation methods
89
94
  */
90
95
  export const createMockFunction = (mockName, mockDefinition) => {
96
+ // Handle simple mock definition (fn: true)
97
+ if (mockDefinition.fn === true) {
98
+ const mockFn = vi.fn();
99
+ return {
100
+ mockFunction: mockFn,
101
+ expectedCalls: 0, // No specific call expectations
102
+ actualCalls: () => mockFn.mock.calls.length,
103
+ validate: () => {
104
+ // Simple mocks don't have call expectations
105
+ },
106
+ mockName,
107
+ };
108
+ }
109
+
91
110
  const { calls } = mockDefinition;
92
111
  let callIndex = 0;
93
-
112
+
94
113
  const mockFn = vi.fn().mockImplementation((...args) => {
95
114
  if (callIndex >= calls.length) {
96
- throw new Error(`Mock '${mockName}' was called ${callIndex + 1} time(s) but expected exactly ${calls.length} calls`);
115
+ throw new Error(
116
+ `Mock '${mockName}' was called ${callIndex + 1} time(s) but expected exactly ${calls.length} calls`,
117
+ );
97
118
  }
98
-
119
+
99
120
  const expectedCall = calls[callIndex];
100
-
121
+
122
+ const processedExpectedIn = processUndefined(expectedCall.in);
123
+
101
124
  // Validate input arguments
102
- if (!deepEqual(args, expectedCall.in)) {
103
- throw new Error(`Expected ${mockName}(${JSON.stringify(expectedCall.in)}) but got ${mockName}(${JSON.stringify(args)})`);
125
+ if (!deepEqual(args, processedExpectedIn)) {
126
+ throw new Error(
127
+ `Expected ${mockName}(${JSON.stringify(expectedCall.in)}) but got ${mockName}(${JSON.stringify(args)})`,
128
+ );
104
129
  }
105
-
130
+
106
131
  callIndex++;
107
-
132
+
108
133
  if (expectedCall.throws) {
109
134
  throw new Error(expectedCall.throws);
110
135
  }
111
-
112
- return expectedCall.out;
136
+
137
+ return processUndefined(expectedCall.out);
113
138
  });
114
-
139
+
115
140
  return {
116
141
  mockFunction: mockFn,
117
142
  expectedCalls: calls.length,
118
143
  actualCalls: () => callIndex,
119
144
  validate: () => {
120
145
  if (callIndex !== calls.length) {
121
- throw new Error(`Mock '${mockName}' was called ${callIndex} time(s) but expected exactly ${calls.length} calls`);
146
+ throw new Error(
147
+ `Mock '${mockName}' was called ${callIndex} time(s) but expected exactly ${calls.length} calls`,
148
+ );
122
149
  }
123
150
  },
124
- mockName
151
+ mockName,
125
152
  };
126
153
  };
127
154
 
@@ -143,10 +170,10 @@ export const validateMockCalls = (mockFunctions) => {
143
170
  */
144
171
  export const createMockFunctions = (resolvedMocks) => {
145
172
  const mockFunctions = {};
146
-
173
+
147
174
  for (const [mockName, mockDef] of Object.entries(resolvedMocks)) {
148
175
  mockFunctions[mockName] = createMockFunction(mockName, mockDef);
149
176
  }
150
-
177
+
151
178
  return mockFunctions;
152
- };
179
+ };
package/src/puty.js CHANGED
@@ -8,8 +8,13 @@ import path from "node:path";
8
8
  import yaml from "js-yaml";
9
9
  import { expect, test, describe } from "vitest";
10
10
 
11
- import { traverseAllFiles, parseWithIncludes } from "./utils.js";
12
- import { resolveMocks, processMockReferences, createMockFunctions, validateMockCalls } from "./mockResolver.js";
11
+ import { traverseAllFiles, parseWithIncludes, processUndefined } from "./utils.js";
12
+ import {
13
+ resolveMocks,
14
+ processMockReferences,
15
+ createMockFunctions,
16
+ validateMockCalls,
17
+ } from "./mockResolver.js";
13
18
 
14
19
  /**
15
20
  * Resolves a nested property path on an object (e.g., "user.profile.name")
@@ -19,19 +24,21 @@ import { resolveMocks, processMockReferences, createMockFunctions, validateMockC
19
24
  * @throws {Error} If any part of the path doesn't exist
20
25
  */
21
26
  const getNestedProperty = (obj, path) => {
22
- const parts = path.split('.');
27
+ const parts = path.split(".");
23
28
  let current = obj;
24
-
29
+
25
30
  for (let i = 0; i < parts.length; i++) {
26
31
  if (current == null) {
27
- throw new Error(`Cannot access property '${parts[i]}' of ${current} in path '${path}'`);
32
+ throw new Error(
33
+ `Cannot access property '${parts[i]}' of ${current} in path '${path}'`,
34
+ );
28
35
  }
29
36
  if (!(parts[i] in current)) {
30
37
  throw new Error(`Property '${parts[i]}' not found in path '${path}'`);
31
38
  }
32
39
  current = current[parts[i]];
33
40
  }
34
-
41
+
35
42
  return current;
36
43
  };
37
44
 
@@ -44,34 +51,38 @@ const getNestedProperty = (obj, path) => {
44
51
  * @throws {Error} If any part of the path doesn't exist or final part is not a function
45
52
  */
46
53
  const callNestedMethod = (obj, path, args = []) => {
47
- const parts = path.split('.');
54
+ const parts = path.split(".");
48
55
  const methodName = parts.pop();
49
-
56
+
50
57
  let current = obj;
51
- const parentPath = parts.join('.');
52
-
58
+ const parentPath = parts.join(".");
59
+
53
60
  // Navigate to the parent object
54
61
  for (const part of parts) {
55
62
  if (current == null) {
56
- throw new Error(`Cannot access property '${part}' of ${current} in path '${path}'`);
63
+ throw new Error(
64
+ `Cannot access property '${part}' of ${current} in path '${path}'`,
65
+ );
57
66
  }
58
67
  if (!(part in current)) {
59
68
  throw new Error(`Property '${part}' not found in path '${path}'`);
60
69
  }
61
70
  current = current[part];
62
71
  }
63
-
72
+
64
73
  // Check if the method exists and is a function
65
74
  if (current == null) {
66
- throw new Error(`Cannot access method '${methodName}' of ${current} in path '${path}'`);
75
+ throw new Error(
76
+ `Cannot access method '${methodName}' of ${current} in path '${path}'`,
77
+ );
67
78
  }
68
79
  if (!(methodName in current)) {
69
80
  throw new Error(`Method '${methodName}' not found in path '${path}'`);
70
81
  }
71
- if (typeof current[methodName] !== 'function') {
82
+ if (typeof current[methodName] !== "function") {
72
83
  throw new Error(`'${methodName}' is not a function in path '${path}'`);
73
84
  }
74
-
85
+
75
86
  return current[methodName](...args);
76
87
  };
77
88
 
@@ -86,7 +97,7 @@ const extensions = [".test.yaml", ".test.yml", ".spec.yaml", ".spec.yml"];
86
97
  * @param {string} yamlContent - Raw YAML content string to parse
87
98
  * @returns {Object} Structured test configuration object
88
99
  * @returns {string|null} returns.file - Path to the JavaScript file being tested
89
- * @returns {string|null} returns.group - Test group name
100
+ * @returns {string|null} returns.group - Test group name
90
101
  * @returns {string[]} [returns.suiteNames] - Array of suite names defined in config
91
102
  * @returns {Object[]} returns.suites - Array of test suite objects
92
103
  * @example
@@ -148,10 +159,17 @@ export const parseYamlDocuments = (yamlContent) => {
148
159
  testCase.executions = doc.executions || [];
149
160
  } else {
150
161
  testCase.in = doc.in || [];
151
- testCase.out = doc.out;
162
+ // Always preserve 'out' field if present (including when it's undefined)
163
+ if ("out" in doc) {
164
+ testCase.out = doc.out;
165
+ }
152
166
  if (doc.throws) {
153
167
  testCase.throws = doc.throws;
154
168
  }
169
+ // Allow executions for function tests (factory pattern)
170
+ if (doc.executions) {
171
+ testCase.executions = doc.executions;
172
+ }
155
173
  }
156
174
 
157
175
  currentSuite.cases.push(testCase);
@@ -216,10 +234,10 @@ const setupFunctionTests = (suite) => {
216
234
  const {
217
235
  name,
218
236
  in: inArg,
219
- out: expectedOut,
220
237
  functionUnderTest,
221
238
  throws,
222
239
  mockFunctions,
240
+ executions,
223
241
  } = testCase;
224
242
  test(name, () => {
225
243
  if (!functionUnderTest) {
@@ -231,10 +249,69 @@ const setupFunctionTests = (suite) => {
231
249
  // Test expects an error to be thrown
232
250
  expect(() => functionUnderTest(...(inArg || []))).toThrow(throws);
233
251
  } else {
234
- const out = functionUnderTest(...(inArg || []));
235
- expect(out).toEqual(expectedOut);
252
+ // Call the function
253
+ const result = functionUnderTest(...(inArg || []));
254
+
255
+ // Assert return value if 'out' field is present in the test case
256
+ if ("out" in testCase) {
257
+ const expectedOut = processUndefined(testCase.out);
258
+ expect(result).toEqual(expectedOut);
259
+ }
260
+
261
+ // If executions are present, execute methods on the returned object
262
+ if (executions && executions.length > 0) {
263
+ for (const execution of executions) {
264
+ const {
265
+ method,
266
+ in: execInArg,
267
+ out: execExpectedOut,
268
+ throws: execThrows,
269
+ asserts,
270
+ } = execution;
271
+
272
+ if (execThrows) {
273
+ expect(() =>
274
+ callNestedMethod(result, method, execInArg || []),
275
+ ).toThrow(execThrows);
276
+ } else {
277
+ const methodResult = callNestedMethod(
278
+ result,
279
+ method,
280
+ execInArg || [],
281
+ );
282
+ if (execExpectedOut !== undefined) {
283
+ const processedExpectedOut = processUndefined(execExpectedOut);
284
+ expect(methodResult).toEqual(processedExpectedOut);
285
+ }
286
+ }
287
+
288
+ // Run assertions
289
+ if (asserts) {
290
+ for (const assertion of asserts) {
291
+ if (assertion.property) {
292
+ const actualValue = getNestedProperty(
293
+ result,
294
+ assertion.property,
295
+ );
296
+ if (assertion.op === "eq") {
297
+ const processedValue = processUndefined(assertion.value);
298
+ expect(actualValue).toEqual(processedValue);
299
+ }
300
+ } else if (assertion.method) {
301
+ const assertResult = callNestedMethod(
302
+ result,
303
+ assertion.method,
304
+ assertion.in || [],
305
+ );
306
+ const processedOut = processUndefined(assertion.out);
307
+ expect(assertResult).toEqual(processedOut);
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
236
313
  }
237
-
314
+
238
315
  // Validate mock calls after test execution
239
316
  if (mockFunctions && Object.keys(mockFunctions).length > 0) {
240
317
  validateMockCalls(mockFunctions);
@@ -242,7 +319,9 @@ const setupFunctionTests = (suite) => {
242
319
  } finally {
243
320
  // Cleanup mocks after test
244
321
  if (mockFunctions) {
245
- Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
322
+ Object.values(mockFunctions).forEach((mock) =>
323
+ mock.mockFunction.mockClear?.(),
324
+ );
246
325
  }
247
326
  }
248
327
  });
@@ -253,7 +332,7 @@ const setupFunctionTests = (suite) => {
253
332
  * Sets up individual test cases for class-based testing
254
333
  * @param {Object} suite - Test suite configuration for class testing
255
334
  * @param {Object[]} suite.cases - Array of test case objects
256
- * @param {string} suite.cases[].name - Test case name
335
+ * @param {string} suite.cases[].name - Test case name
257
336
  * @param {Object[]} suite.cases[].executions - Array of method executions to perform
258
337
  * @param {Function} suite.ClassUnderTest - The class constructor to test
259
338
  * @param {any[]} suite.constructorArgs - Arguments to pass to class constructor
@@ -281,11 +360,14 @@ const setupClassTests = (suite) => {
281
360
 
282
361
  // Execute the method and check its return value - supports nested methods
283
362
  if (throws) {
284
- expect(() => callNestedMethod(instance, method, inArg || [])).toThrow(throws);
363
+ expect(() =>
364
+ callNestedMethod(instance, method, inArg || []),
365
+ ).toThrow(throws);
285
366
  } else {
286
367
  const result = callNestedMethod(instance, method, inArg || []);
287
368
  if (expectedOut !== undefined) {
288
- expect(result).toEqual(expectedOut);
369
+ const processedExpectedOut = processUndefined(expectedOut);
370
+ expect(result).toEqual(processedExpectedOut);
289
371
  }
290
372
  }
291
373
 
@@ -294,20 +376,29 @@ const setupClassTests = (suite) => {
294
376
  for (const assertion of asserts) {
295
377
  if (assertion.property) {
296
378
  // Property assertion - supports nested properties like "user.profile.name"
297
- const actualValue = getNestedProperty(instance, assertion.property);
379
+ const actualValue = getNestedProperty(
380
+ instance,
381
+ assertion.property,
382
+ );
298
383
  if (assertion.op === "eq") {
299
- expect(actualValue).toEqual(assertion.value);
384
+ const processedValue = processUndefined(assertion.value);
385
+ expect(actualValue).toEqual(processedValue);
300
386
  }
301
387
  // Add more operators as needed
302
388
  } else if (assertion.method) {
303
389
  // Method assertion - supports nested methods like "user.api.getData"
304
- const result = callNestedMethod(instance, assertion.method, assertion.in || []);
305
- expect(result).toEqual(assertion.out);
390
+ const result = callNestedMethod(
391
+ instance,
392
+ assertion.method,
393
+ assertion.in || [],
394
+ );
395
+ const processedOut = processUndefined(assertion.out);
396
+ expect(result).toEqual(processedOut);
306
397
  }
307
398
  }
308
399
  }
309
400
  }
310
-
401
+
311
402
  // Validate mock calls after test execution
312
403
  if (mockFunctions && Object.keys(mockFunctions).length > 0) {
313
404
  validateMockCalls(mockFunctions);
@@ -315,7 +406,9 @@ const setupClassTests = (suite) => {
315
406
  } finally {
316
407
  // Cleanup mocks after test
317
408
  if (mockFunctions) {
318
- Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
409
+ Object.values(mockFunctions).forEach((mock) =>
410
+ mock.mockFunction.mockClear?.(),
411
+ );
319
412
  }
320
413
  }
321
414
  });
@@ -331,8 +424,8 @@ const setupClassTests = (suite) => {
331
424
  * @example
332
425
  * // Import module and inject functions
333
426
  * const module = await import('./math.js');
334
- * const testConfig = {
335
- * suites: [{ name: 'add', exportName: 'add', cases: [...] }]
427
+ * const testConfig = {
428
+ * suites: [{ name: 'add', exportName: 'add', cases: [...] }]
336
429
  * };
337
430
  * const ready = injectFunctions(module, testConfig);
338
431
  * // ready.suites[0].cases[0].functionUnderTest === module.add
@@ -347,33 +440,45 @@ export const injectFunctions = (module, originalTestConfig) => {
347
440
  testCase.resolvedMocks = resolveMocks(
348
441
  testCase.mocks,
349
442
  suite.mocks,
350
- testConfig.mocks
443
+ testConfig.mocks,
351
444
  );
352
-
445
+
353
446
  // Create mock functions from resolved mock definitions
354
447
  testCase.mockFunctions = createMockFunctions(testCase.resolvedMocks);
355
-
448
+
356
449
  // Process mock references in test inputs and outputs
357
450
  if (testCase.in) {
358
- testCase.in = processMockReferences(testCase.in, testCase.mockFunctions);
451
+ testCase.in = processMockReferences(
452
+ testCase.in,
453
+ testCase.mockFunctions,
454
+ );
359
455
  }
360
456
  if (testCase.out) {
361
- testCase.out = processMockReferences(testCase.out, testCase.mockFunctions);
457
+ testCase.out = processMockReferences(
458
+ testCase.out,
459
+ testCase.mockFunctions,
460
+ );
362
461
  }
363
-
462
+
364
463
  // Process mock references in class test executions
365
464
  if (testCase.executions) {
366
465
  for (const execution of testCase.executions) {
367
466
  if (execution.in) {
368
- execution.in = processMockReferences(execution.in, testCase.mockFunctions);
467
+ execution.in = processMockReferences(
468
+ execution.in,
469
+ testCase.mockFunctions,
470
+ );
369
471
  }
370
472
  if (execution.out) {
371
- execution.out = processMockReferences(execution.out, testCase.mockFunctions);
473
+ execution.out = processMockReferences(
474
+ execution.out,
475
+ testCase.mockFunctions,
476
+ );
372
477
  }
373
478
  }
374
479
  }
375
480
  }
376
-
481
+
377
482
  if (suite.mode === "class") {
378
483
  const exportName = suite.exportName || "default";
379
484
  const exported = module[exportName];
@@ -408,10 +513,10 @@ export const injectFunctions = (module, originalTestConfig) => {
408
513
  * @example
409
514
  * // Set up all test suites from YAML files in the current directory
410
515
  * await setupTestSuiteFromYaml('.');
411
- *
516
+ *
412
517
  * // Set up tests from a specific directory
413
518
  * await setupTestSuiteFromYaml('./tests');
414
- *
519
+ *
415
520
  * // This will find all files matching: *.test.yaml, *.test.yml, *.spec.yaml, *.spec.yml
416
521
  */
417
522
  export const setupTestSuiteFromYaml = async (dirname) => {
@@ -426,7 +531,10 @@ export const setupTestSuiteFromYaml = async (dirname) => {
426
531
 
427
532
  // testConfig.file is relative to the spec file
428
533
  const module = await import(filepathRelativeToSpecFile);
429
- const testConfigWithInjectedFunctions = injectFunctions(module, testConfig);
534
+ const testConfigWithInjectedFunctions = injectFunctions(
535
+ module,
536
+ testConfig,
537
+ );
430
538
  setupTestSuite(testConfigWithInjectedFunctions);
431
539
  } catch (error) {
432
540
  throw error;
package/src/utils.js CHANGED
@@ -18,7 +18,7 @@ import yaml from "js-yaml";
18
18
  * @example
19
19
  * // Load a simple YAML file
20
20
  * const config = loadYamlWithPath('./config.yaml');
21
- *
21
+ *
22
22
  * // Load YAML with includes
23
23
  * const data = loadYamlWithPath('./main.yaml'); // main.yaml contains: data: !include ./data.yaml
24
24
  */
@@ -80,7 +80,7 @@ export const loadYamlWithPath = (filePath, visitedFiles = new Set()) => {
80
80
  * @example
81
81
  * // Find all YAML test files
82
82
  * const testFiles = traverseAllFiles('./tests', ['.test.yaml', '.spec.yml']);
83
- *
83
+ *
84
84
  * // Find all JavaScript files
85
85
  * const jsFiles = traverseAllFiles('./src', ['.js', '.ts']);
86
86
  */
@@ -189,10 +189,17 @@ const processDocuments = (docs) => {
189
189
  testCase.executions = doc.executions || [];
190
190
  } else {
191
191
  testCase.in = doc.in || [];
192
- testCase.out = doc.out;
192
+ // Only add 'out' if it's present in the doc
193
+ if ("out" in doc) {
194
+ testCase.out = doc.out;
195
+ }
193
196
  if (doc.throws) {
194
197
  testCase.throws = doc.throws;
195
198
  }
199
+ // Allow executions for function tests (factory pattern)
200
+ if (doc.executions) {
201
+ testCase.executions = doc.executions;
202
+ }
196
203
  }
197
204
 
198
205
  currentSuite.cases.push(testCase);
@@ -215,9 +222,9 @@ const processDocuments = (docs) => {
215
222
  * // Parse a test configuration file with includes
216
223
  * const config = parseWithIncludes('./tests/math.spec.yaml');
217
224
  * // Returns: { file: './math.js', group: 'math', suites: [...] }
218
- *
225
+ *
219
226
  * // Works with files containing !include directives
220
- * // main.yaml:
227
+ * // main.yaml:
221
228
  * // file: './lib.js'
222
229
  * // ---
223
230
  * // !include ./test-cases.yaml
@@ -231,3 +238,25 @@ export const parseWithIncludes = (filePath) => {
231
238
 
232
239
  return processDocuments(flattenedDocs);
233
240
  };
241
+
242
+ /**
243
+ * Recursively processes a value to convert "__undefined__" strings to actual undefined values
244
+ * @param {any} value - The value to process (string, array, object, or primitive)
245
+ * @returns {any} The processed value with "__undefined__" converted to undefined
246
+ * @example
247
+ * processUndefined("__undefined__") // returns undefined
248
+ * processUndefined({ a: "__undefined__", b: [1, "__undefined__"] })
249
+ * // returns { a: undefined, b: [1, undefined] }
250
+ */
251
+ export const processUndefined = (value) => {
252
+ if (value === "__undefined__") return undefined;
253
+ if (Array.isArray(value)) return value.map(processUndefined);
254
+ if (value && typeof value === "object") {
255
+ const processed = {};
256
+ for (const [key, val] of Object.entries(value)) {
257
+ processed[key] = processUndefined(val);
258
+ }
259
+ return processed;
260
+ }
261
+ return value;
262
+ };