puty 0.0.4-rc2 → 0.0.5-rc1

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-rc2",
3
+ "version": "0.0.5-rc1",
4
4
  "description": "A tooling function to test javascript functions and classes.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -41,12 +41,16 @@ const deepEqual = (a, b) => {
41
41
  * @param {Object} globalMocks - Global-level mock definitions
42
42
  * @returns {Object} Resolved mock definitions with hierarchy applied
43
43
  */
44
- export const resolveMocks = (caseMocks = {}, suiteMocks = {}, globalMocks = {}) => {
44
+ export const resolveMocks = (
45
+ caseMocks = {},
46
+ suiteMocks = {},
47
+ globalMocks = {},
48
+ ) => {
45
49
  const resolved = {};
46
-
50
+
47
51
  // Apply hierarchy: global -> suite -> case (case overrides suite, suite overrides global)
48
52
  Object.assign(resolved, globalMocks, suiteMocks, caseMocks);
49
-
53
+
50
54
  return resolved;
51
55
  };
52
56
 
@@ -57,26 +61,26 @@ export const resolveMocks = (caseMocks = {}, suiteMocks = {}, globalMocks = {})
57
61
  * @returns {any} Processed value with $mock: references replaced
58
62
  */
59
63
  export const processMockReferences = (value, mockFunctions) => {
60
- if (typeof value === 'string' && value.startsWith('$mock:')) {
64
+ if (typeof value === "string" && value.startsWith("$mock:")) {
61
65
  const mockName = value.substring(6); // Remove '$mock:' prefix
62
66
  if (!mockFunctions[mockName]) {
63
67
  throw new Error(`Mock '${mockName}' is referenced but not defined`);
64
68
  }
65
69
  return mockFunctions[mockName].mockFunction;
66
70
  }
67
-
71
+
68
72
  if (Array.isArray(value)) {
69
- return value.map(item => processMockReferences(item, mockFunctions));
73
+ return value.map((item) => processMockReferences(item, mockFunctions));
70
74
  }
71
-
72
- if (value && typeof value === 'object') {
75
+
76
+ if (value && typeof value === "object") {
73
77
  const processed = {};
74
78
  for (const [key, val] of Object.entries(value)) {
75
79
  processed[key] = processMockReferences(val, mockFunctions);
76
80
  }
77
81
  return processed;
78
82
  }
79
-
83
+
80
84
  return value;
81
85
  };
82
86
 
@@ -88,40 +92,81 @@ export const processMockReferences = (value, mockFunctions) => {
88
92
  * @returns {Object} Mock function wrapper with validation methods
89
93
  */
90
94
  export const createMockFunction = (mockName, mockDefinition) => {
95
+ // Handle simple mock definition (fn: true)
96
+ if (mockDefinition.fn === true) {
97
+ const mockFn = vi.fn();
98
+ return {
99
+ mockFunction: mockFn,
100
+ expectedCalls: 0, // No specific call expectations
101
+ actualCalls: () => mockFn.mock.calls.length,
102
+ validate: () => {
103
+ // Simple mocks don't have call expectations
104
+ },
105
+ mockName,
106
+ };
107
+ }
108
+
91
109
  const { calls } = mockDefinition;
92
110
  let callIndex = 0;
93
-
111
+
94
112
  const mockFn = vi.fn().mockImplementation((...args) => {
95
113
  if (callIndex >= calls.length) {
96
- throw new Error(`Mock '${mockName}' was called ${callIndex + 1} time(s) but expected exactly ${calls.length} calls`);
114
+ throw new Error(
115
+ `Mock '${mockName}' was called ${callIndex + 1} time(s) but expected exactly ${calls.length} calls`,
116
+ );
97
117
  }
98
-
118
+
99
119
  const expectedCall = calls[callIndex];
100
-
120
+
121
+ // Process __undefined__ in expected inputs recursively
122
+ const processUndefined = (value) => {
123
+ if (value === "__undefined__") return undefined;
124
+ if (Array.isArray(value)) return value.map(processUndefined);
125
+ if (value && typeof value === "object") {
126
+ const processed = {};
127
+ for (const [key, val] of Object.entries(value)) {
128
+ processed[key] = processUndefined(val);
129
+ }
130
+ return processed;
131
+ }
132
+ return value;
133
+ };
134
+
135
+ const processedExpectedIn = processUndefined(expectedCall.in);
136
+
101
137
  // 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)})`);
138
+ if (!deepEqual(args, processedExpectedIn)) {
139
+ throw new Error(
140
+ `Expected ${mockName}(${JSON.stringify(expectedCall.in)}) but got ${mockName}(${JSON.stringify(args)})`,
141
+ );
104
142
  }
105
-
143
+
106
144
  callIndex++;
107
-
145
+
108
146
  if (expectedCall.throws) {
109
147
  throw new Error(expectedCall.throws);
110
148
  }
111
-
149
+
150
+ // Handle special __undefined__ keyword
151
+ if (expectedCall.out === "__undefined__") {
152
+ return undefined;
153
+ }
154
+
112
155
  return expectedCall.out;
113
156
  });
114
-
157
+
115
158
  return {
116
159
  mockFunction: mockFn,
117
160
  expectedCalls: calls.length,
118
161
  actualCalls: () => callIndex,
119
162
  validate: () => {
120
163
  if (callIndex !== calls.length) {
121
- throw new Error(`Mock '${mockName}' was called ${callIndex} time(s) but expected exactly ${calls.length} calls`);
164
+ throw new Error(
165
+ `Mock '${mockName}' was called ${callIndex} time(s) but expected exactly ${calls.length} calls`,
166
+ );
122
167
  }
123
168
  },
124
- mockName
169
+ mockName,
125
170
  };
126
171
  };
127
172
 
@@ -143,10 +188,10 @@ export const validateMockCalls = (mockFunctions) => {
143
188
  */
144
189
  export const createMockFunctions = (resolvedMocks) => {
145
190
  const mockFunctions = {};
146
-
191
+
147
192
  for (const [mockName, mockDef] of Object.entries(resolvedMocks)) {
148
193
  mockFunctions[mockName] = createMockFunction(mockName, mockDef);
149
194
  }
150
-
195
+
151
196
  return mockFunctions;
152
- };
197
+ };
package/src/puty.js CHANGED
@@ -9,7 +9,12 @@ import yaml from "js-yaml";
9
9
  import { expect, test, describe } from "vitest";
10
10
 
11
11
  import { traverseAllFiles, parseWithIncludes } from "./utils.js";
12
- import { resolveMocks, processMockReferences, createMockFunctions, validateMockCalls } from "./mockResolver.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,85 @@ 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
+ // Handle special __undefined__ keyword
258
+ if (testCase.out === "__undefined__") {
259
+ expect(result).toBe(undefined);
260
+ } else {
261
+ expect(result).toEqual(testCase.out);
262
+ }
263
+ }
264
+
265
+ // If executions are present, execute methods on the returned object
266
+ if (executions && executions.length > 0) {
267
+ for (const execution of executions) {
268
+ const {
269
+ method,
270
+ in: execInArg,
271
+ out: execExpectedOut,
272
+ throws: execThrows,
273
+ asserts,
274
+ } = execution;
275
+
276
+ if (execThrows) {
277
+ expect(() =>
278
+ callNestedMethod(result, method, execInArg || []),
279
+ ).toThrow(execThrows);
280
+ } else {
281
+ const methodResult = callNestedMethod(
282
+ result,
283
+ method,
284
+ execInArg || [],
285
+ );
286
+ if (execExpectedOut !== undefined) {
287
+ // Handle special __undefined__ keyword
288
+ if (execExpectedOut === "__undefined__") {
289
+ expect(methodResult).toBe(undefined);
290
+ } else {
291
+ expect(methodResult).toEqual(execExpectedOut);
292
+ }
293
+ }
294
+ }
295
+
296
+ // Run assertions
297
+ if (asserts) {
298
+ for (const assertion of asserts) {
299
+ if (assertion.property) {
300
+ const actualValue = getNestedProperty(
301
+ result,
302
+ assertion.property,
303
+ );
304
+ if (assertion.op === "eq") {
305
+ // Handle special __undefined__ keyword
306
+ if (assertion.value === "__undefined__") {
307
+ expect(actualValue).toBe(undefined);
308
+ } else {
309
+ expect(actualValue).toEqual(assertion.value);
310
+ }
311
+ }
312
+ } else if (assertion.method) {
313
+ const assertResult = callNestedMethod(
314
+ result,
315
+ assertion.method,
316
+ assertion.in || [],
317
+ );
318
+ // Handle special __undefined__ keyword
319
+ if (assertion.out === "__undefined__") {
320
+ expect(assertResult).toBe(undefined);
321
+ } else {
322
+ expect(assertResult).toEqual(assertion.out);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
236
329
  }
237
-
330
+
238
331
  // Validate mock calls after test execution
239
332
  if (mockFunctions && Object.keys(mockFunctions).length > 0) {
240
333
  validateMockCalls(mockFunctions);
@@ -242,7 +335,9 @@ const setupFunctionTests = (suite) => {
242
335
  } finally {
243
336
  // Cleanup mocks after test
244
337
  if (mockFunctions) {
245
- Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
338
+ Object.values(mockFunctions).forEach((mock) =>
339
+ mock.mockFunction.mockClear?.(),
340
+ );
246
341
  }
247
342
  }
248
343
  });
@@ -253,7 +348,7 @@ const setupFunctionTests = (suite) => {
253
348
  * Sets up individual test cases for class-based testing
254
349
  * @param {Object} suite - Test suite configuration for class testing
255
350
  * @param {Object[]} suite.cases - Array of test case objects
256
- * @param {string} suite.cases[].name - Test case name
351
+ * @param {string} suite.cases[].name - Test case name
257
352
  * @param {Object[]} suite.cases[].executions - Array of method executions to perform
258
353
  * @param {Function} suite.ClassUnderTest - The class constructor to test
259
354
  * @param {any[]} suite.constructorArgs - Arguments to pass to class constructor
@@ -281,11 +376,18 @@ const setupClassTests = (suite) => {
281
376
 
282
377
  // Execute the method and check its return value - supports nested methods
283
378
  if (throws) {
284
- expect(() => callNestedMethod(instance, method, inArg || [])).toThrow(throws);
379
+ expect(() =>
380
+ callNestedMethod(instance, method, inArg || []),
381
+ ).toThrow(throws);
285
382
  } else {
286
383
  const result = callNestedMethod(instance, method, inArg || []);
287
384
  if (expectedOut !== undefined) {
288
- expect(result).toEqual(expectedOut);
385
+ // Handle special __undefined__ keyword
386
+ if (expectedOut === "__undefined__") {
387
+ expect(result).toBe(undefined);
388
+ } else {
389
+ expect(result).toEqual(expectedOut);
390
+ }
289
391
  }
290
392
  }
291
393
 
@@ -294,20 +396,37 @@ const setupClassTests = (suite) => {
294
396
  for (const assertion of asserts) {
295
397
  if (assertion.property) {
296
398
  // Property assertion - supports nested properties like "user.profile.name"
297
- const actualValue = getNestedProperty(instance, assertion.property);
399
+ const actualValue = getNestedProperty(
400
+ instance,
401
+ assertion.property,
402
+ );
298
403
  if (assertion.op === "eq") {
299
- expect(actualValue).toEqual(assertion.value);
404
+ // Handle special __undefined__ keyword
405
+ if (assertion.value === "__undefined__") {
406
+ expect(actualValue).toBe(undefined);
407
+ } else {
408
+ expect(actualValue).toEqual(assertion.value);
409
+ }
300
410
  }
301
411
  // Add more operators as needed
302
412
  } else if (assertion.method) {
303
413
  // Method assertion - supports nested methods like "user.api.getData"
304
- const result = callNestedMethod(instance, assertion.method, assertion.in || []);
305
- expect(result).toEqual(assertion.out);
414
+ const result = callNestedMethod(
415
+ instance,
416
+ assertion.method,
417
+ assertion.in || [],
418
+ );
419
+ // Handle special __undefined__ keyword
420
+ if (assertion.out === "__undefined__") {
421
+ expect(result).toBe(undefined);
422
+ } else {
423
+ expect(result).toEqual(assertion.out);
424
+ }
306
425
  }
307
426
  }
308
427
  }
309
428
  }
310
-
429
+
311
430
  // Validate mock calls after test execution
312
431
  if (mockFunctions && Object.keys(mockFunctions).length > 0) {
313
432
  validateMockCalls(mockFunctions);
@@ -315,7 +434,9 @@ const setupClassTests = (suite) => {
315
434
  } finally {
316
435
  // Cleanup mocks after test
317
436
  if (mockFunctions) {
318
- Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
437
+ Object.values(mockFunctions).forEach((mock) =>
438
+ mock.mockFunction.mockClear?.(),
439
+ );
319
440
  }
320
441
  }
321
442
  });
@@ -331,8 +452,8 @@ const setupClassTests = (suite) => {
331
452
  * @example
332
453
  * // Import module and inject functions
333
454
  * const module = await import('./math.js');
334
- * const testConfig = {
335
- * suites: [{ name: 'add', exportName: 'add', cases: [...] }]
455
+ * const testConfig = {
456
+ * suites: [{ name: 'add', exportName: 'add', cases: [...] }]
336
457
  * };
337
458
  * const ready = injectFunctions(module, testConfig);
338
459
  * // ready.suites[0].cases[0].functionUnderTest === module.add
@@ -347,33 +468,45 @@ export const injectFunctions = (module, originalTestConfig) => {
347
468
  testCase.resolvedMocks = resolveMocks(
348
469
  testCase.mocks,
349
470
  suite.mocks,
350
- testConfig.mocks
471
+ testConfig.mocks,
351
472
  );
352
-
473
+
353
474
  // Create mock functions from resolved mock definitions
354
475
  testCase.mockFunctions = createMockFunctions(testCase.resolvedMocks);
355
-
476
+
356
477
  // Process mock references in test inputs and outputs
357
478
  if (testCase.in) {
358
- testCase.in = processMockReferences(testCase.in, testCase.mockFunctions);
479
+ testCase.in = processMockReferences(
480
+ testCase.in,
481
+ testCase.mockFunctions,
482
+ );
359
483
  }
360
484
  if (testCase.out) {
361
- testCase.out = processMockReferences(testCase.out, testCase.mockFunctions);
485
+ testCase.out = processMockReferences(
486
+ testCase.out,
487
+ testCase.mockFunctions,
488
+ );
362
489
  }
363
-
490
+
364
491
  // Process mock references in class test executions
365
492
  if (testCase.executions) {
366
493
  for (const execution of testCase.executions) {
367
494
  if (execution.in) {
368
- execution.in = processMockReferences(execution.in, testCase.mockFunctions);
495
+ execution.in = processMockReferences(
496
+ execution.in,
497
+ testCase.mockFunctions,
498
+ );
369
499
  }
370
500
  if (execution.out) {
371
- execution.out = processMockReferences(execution.out, testCase.mockFunctions);
501
+ execution.out = processMockReferences(
502
+ execution.out,
503
+ testCase.mockFunctions,
504
+ );
372
505
  }
373
506
  }
374
507
  }
375
508
  }
376
-
509
+
377
510
  if (suite.mode === "class") {
378
511
  const exportName = suite.exportName || "default";
379
512
  const exported = module[exportName];
@@ -408,10 +541,10 @@ export const injectFunctions = (module, originalTestConfig) => {
408
541
  * @example
409
542
  * // Set up all test suites from YAML files in the current directory
410
543
  * await setupTestSuiteFromYaml('.');
411
- *
544
+ *
412
545
  * // Set up tests from a specific directory
413
546
  * await setupTestSuiteFromYaml('./tests');
414
- *
547
+ *
415
548
  * // This will find all files matching: *.test.yaml, *.test.yml, *.spec.yaml, *.spec.yml
416
549
  */
417
550
  export const setupTestSuiteFromYaml = async (dirname) => {
@@ -426,7 +559,10 @@ export const setupTestSuiteFromYaml = async (dirname) => {
426
559
 
427
560
  // testConfig.file is relative to the spec file
428
561
  const module = await import(filepathRelativeToSpecFile);
429
- const testConfigWithInjectedFunctions = injectFunctions(module, testConfig);
562
+ const testConfigWithInjectedFunctions = injectFunctions(
563
+ module,
564
+ testConfig,
565
+ );
430
566
  setupTestSuite(testConfigWithInjectedFunctions);
431
567
  } catch (error) {
432
568
  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