puty 0.0.3 โ†’ 0.0.4-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
@@ -13,6 +13,7 @@ Puty is ideal for testing pure functions - functions that always return the same
13
13
  - [Testing Functions](#testing-functions)
14
14
  - [Testing Classes](#testing-classes)
15
15
  - [Error Testing](#error-testing)
16
+ - [Using Mocks](#using-mocks)
16
17
  - [Using !include Directive](#using-include-directive)
17
18
  - [YAML Structure](#yaml-structure)
18
19
 
@@ -21,6 +22,7 @@ Puty is ideal for testing pure functions - functions that always return the same
21
22
  - ๐Ÿ“ Write tests in simple YAML format
22
23
  - ๐Ÿ“ฆ Modular test organization with `!include` directive
23
24
  - ๐ŸŽฏ Clear separation of test data and test logic
25
+ - ๐Ÿงช Mock support for testing functions with dependencies
24
26
  - โšก Powered by Vitest for fast test execution
25
27
 
26
28
  ## Installation
@@ -203,6 +205,79 @@ in: [10, 0]
203
205
  throws: "Division by zero"
204
206
  ```
205
207
 
208
+ ### Using Mocks
209
+
210
+ Puty supports mocking dependencies using the `$mock:` syntax. This is useful for testing functions that have external dependencies like loggers, API clients, or callbacks.
211
+
212
+ #### Basic Mock Example
213
+
214
+ ```yaml
215
+ file: './calculator.js'
216
+ group: calculator
217
+ ---
218
+ suite: calculate
219
+ exportName: calculateWithLogger
220
+ ---
221
+ case: test with mock logger
222
+ in:
223
+ - 10
224
+ - 5
225
+ - $mock:logger
226
+ out: 15
227
+ mocks:
228
+ logger:
229
+ calls:
230
+ - in: ['Calculating 10 + 5']
231
+ - in: ['Result: 15']
232
+ ```
233
+
234
+ #### Mock Hierarchy
235
+
236
+ Mocks can be defined at three levels (case overrides suite, suite overrides global):
237
+
238
+ ```yaml
239
+ file: './service.js'
240
+ group: service
241
+ mocks:
242
+ globalApi: # Global mock - available to all suites
243
+ calls:
244
+ - in: ['/default']
245
+ out: { status: 200 }
246
+ ---
247
+ suite: userService
248
+ mocks:
249
+ api: # Suite mock - available to all cases in this suite
250
+ calls:
251
+ - in: ['/users']
252
+ out: { users: [] }
253
+ ---
254
+ case: get user with mock
255
+ in: [123, $mock:api]
256
+ out: { id: 123, name: 'John' }
257
+ mocks:
258
+ api: # Case mock - overrides suite mock
259
+ calls:
260
+ - in: ['/users/123']
261
+ out: { id: 123, name: 'John' }
262
+ ```
263
+
264
+ #### Testing Callbacks
265
+
266
+ Mocks are perfect for testing event-driven code:
267
+
268
+ ```yaml
269
+ case: test event emitter
270
+ executions:
271
+ - method: on
272
+ in: ['data', $mock:callback]
273
+ - method: emit
274
+ in: ['data', 'hello']
275
+ mocks:
276
+ callback:
277
+ calls:
278
+ - in: ['hello']
279
+ ```
280
+
206
281
  ### Using !include Directive
207
282
 
208
283
  Puty supports the `!include` directive to modularize and reuse YAML test files. This is useful for:
@@ -271,6 +346,11 @@ Puty test files use multi-document YAML format with three types of documents:
271
346
  file: './module.js' # Required: Path to JS file (relative to YAML file)
272
347
  group: 'test-group' # Required: Test group name (or use 'name')
273
348
  suites: ['suite1', 'suite2'] # Optional: List of suites to define
349
+ mocks: # Optional: Global mocks available to all suites
350
+ mockName:
351
+ calls:
352
+ - in: [args]
353
+ out: result
274
354
  ```
275
355
 
276
356
  ### 2. Suite Definition Documents
@@ -280,6 +360,11 @@ suite: 'suiteName' # Required: Suite name
280
360
  exportName: 'functionName' # Optional: Export to test (defaults to suite name or 'default')
281
361
  mode: 'class' # Optional: Set to 'class' for class testing
282
362
  constructorArgs: [arg1] # Optional: Arguments for class constructor (class mode only)
363
+ mocks: # Optional: Suite-level mocks for all cases in this suite
364
+ mockName:
365
+ calls:
366
+ - in: [args]
367
+ out: result
283
368
  ```
284
369
 
285
370
  ### 3. Test Case Documents
@@ -287,9 +372,15 @@ constructorArgs: [arg1] # Optional: Arguments for class constructor (class mo
287
372
  For function tests:
288
373
  ```yaml
289
374
  case: 'test description' # Required: Test case name
290
- in: [arg1, arg2] # Required: Input arguments
375
+ in: [arg1, arg2] # Required: Input arguments (use $mock:name for mocks)
291
376
  out: expectedValue # Optional: Expected output (omit if testing for errors)
292
377
  throws: 'Error message' # Optional: Expected error message
378
+ mocks: # Optional: Case-specific mocks
379
+ mockName:
380
+ calls: # Array of expected calls
381
+ - in: [args] # Expected arguments
382
+ out: result # Optional: Return value
383
+ throws: 'error' # Optional: Throw error instead
293
384
  ```
294
385
 
295
386
  For class tests:
@@ -307,5 +398,10 @@ executions:
307
398
  - method: 'getter'
308
399
  in: []
309
400
  out: expected
401
+ mocks: # Optional: Mocks for the entire test case
402
+ mockName:
403
+ calls:
404
+ - in: [args]
405
+ out: result
310
406
  ```
311
407
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puty",
3
- "version": "0.0.3",
3
+ "version": "0.0.4-rc1",
4
4
  "description": "A tooling function to test javascript functions and classes.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -16,6 +16,8 @@
16
16
  "js-yaml": "~4.1.0"
17
17
  },
18
18
  "scripts": {
19
+ "test": "vitest run",
20
+ "test:watch": "vitest run",
19
21
  "lint": "bunx prettier src tests -c",
20
22
  "lint:fix": "bunx prettier src tests -w",
21
23
  "build": "bun run esbuild.js"
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @fileoverview Mock resolution and processing utilities
3
+ * This module provides functions for resolving mock references, creating mock functions,
4
+ * and validating mock calls in YAML-driven tests.
5
+ */
6
+
7
+ import { vi } from "vitest";
8
+
9
+ /**
10
+ * Deep equality check for mock argument validation
11
+ * @param {any} a - First value to compare
12
+ * @param {any} b - Second value to compare
13
+ * @returns {boolean} True if values are deeply equal
14
+ */
15
+ const deepEqual = (a, b) => {
16
+ if (a === b) return true;
17
+ if (a == null || b == null) return false;
18
+ if (Array.isArray(a) && Array.isArray(b)) {
19
+ if (a.length !== b.length) return false;
20
+ for (let i = 0; i < a.length; i++) {
21
+ if (!deepEqual(a[i], b[i])) return false;
22
+ }
23
+ return true;
24
+ }
25
+ if (typeof a === "object" && typeof b === "object") {
26
+ const keysA = Object.keys(a);
27
+ const keysB = Object.keys(b);
28
+ if (keysA.length !== keysB.length) return false;
29
+ for (const key of keysA) {
30
+ if (!keysB.includes(key) || !deepEqual(a[key], b[key])) return false;
31
+ }
32
+ return true;
33
+ }
34
+ return false;
35
+ };
36
+
37
+ /**
38
+ * Resolves mock references following hierarchy: case -> suite -> global
39
+ * @param {Object} caseMocks - Case-level mock definitions
40
+ * @param {Object} suiteMocks - Suite-level mock definitions
41
+ * @param {Object} globalMocks - Global-level mock definitions
42
+ * @returns {Object} Resolved mock definitions with hierarchy applied
43
+ */
44
+ export const resolveMocks = (caseMocks = {}, suiteMocks = {}, globalMocks = {}) => {
45
+ const resolved = {};
46
+
47
+ // Apply hierarchy: global -> suite -> case (case overrides suite, suite overrides global)
48
+ Object.assign(resolved, globalMocks, suiteMocks, caseMocks);
49
+
50
+ return resolved;
51
+ };
52
+
53
+ /**
54
+ * Recursively processes values, replacing $mock: references with mock functions
55
+ * @param {any} value - Value to process (can be string, array, object, or primitive)
56
+ * @param {Object} mockFunctions - Map of mock name to mock function wrapper
57
+ * @returns {any} Processed value with $mock: references replaced
58
+ */
59
+ export const processMockReferences = (value, mockFunctions) => {
60
+ if (typeof value === 'string' && value.startsWith('$mock:')) {
61
+ const mockName = value.substring(6); // Remove '$mock:' prefix
62
+ if (!mockFunctions[mockName]) {
63
+ throw new Error(`Mock '${mockName}' is referenced but not defined`);
64
+ }
65
+ return mockFunctions[mockName].mockFunction;
66
+ }
67
+
68
+ if (Array.isArray(value)) {
69
+ return value.map(item => processMockReferences(item, mockFunctions));
70
+ }
71
+
72
+ if (value && typeof value === 'object') {
73
+ const processed = {};
74
+ for (const [key, val] of Object.entries(value)) {
75
+ processed[key] = processMockReferences(val, mockFunctions);
76
+ }
77
+ return processed;
78
+ }
79
+
80
+ return value;
81
+ };
82
+
83
+ /**
84
+ * Creates a mock function with call tracking and validation
85
+ * @param {string} mockName - Name of the mock for error reporting
86
+ * @param {Object} mockDefinition - Mock definition with calls array
87
+ * @param {Array} mockDefinition.calls - Array of expected calls with in/out/throws
88
+ * @returns {Object} Mock function wrapper with validation methods
89
+ */
90
+ export const createMockFunction = (mockName, mockDefinition) => {
91
+ const { calls } = mockDefinition;
92
+ let callIndex = 0;
93
+
94
+ const mockFn = vi.fn().mockImplementation((...args) => {
95
+ if (callIndex >= calls.length) {
96
+ throw new Error(`Mock '${mockName}' was called ${callIndex + 1} time(s) but expected exactly ${calls.length} calls`);
97
+ }
98
+
99
+ const expectedCall = calls[callIndex];
100
+
101
+ // 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)})`);
104
+ }
105
+
106
+ callIndex++;
107
+
108
+ if (expectedCall.throws) {
109
+ throw new Error(expectedCall.throws);
110
+ }
111
+
112
+ return expectedCall.out;
113
+ });
114
+
115
+ return {
116
+ mockFunction: mockFn,
117
+ expectedCalls: calls.length,
118
+ actualCalls: () => callIndex,
119
+ validate: () => {
120
+ if (callIndex !== calls.length) {
121
+ throw new Error(`Mock '${mockName}' was called ${callIndex} time(s) but expected exactly ${calls.length} calls`);
122
+ }
123
+ },
124
+ mockName
125
+ };
126
+ };
127
+
128
+ /**
129
+ * Validates all mocks were called as expected
130
+ * @param {Object} mockFunctions - Map of mock name to mock function wrapper
131
+ * @throws {Error} If any mock validation fails
132
+ */
133
+ export const validateMockCalls = (mockFunctions) => {
134
+ for (const mockWrapper of Object.values(mockFunctions)) {
135
+ mockWrapper.validate();
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Creates mock functions from resolved mock definitions
141
+ * @param {Object} resolvedMocks - Resolved mock definitions
142
+ * @returns {Object} Map of mock name to mock function wrapper
143
+ */
144
+ export const createMockFunctions = (resolvedMocks) => {
145
+ const mockFunctions = {};
146
+
147
+ for (const [mockName, mockDef] of Object.entries(resolvedMocks)) {
148
+ mockFunctions[mockName] = createMockFunction(mockName, mockDef);
149
+ }
150
+
151
+ return mockFunctions;
152
+ };
package/src/puty.js CHANGED
@@ -9,6 +9,7 @@ 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
13
 
13
14
  /**
14
15
  * File extensions that are recognized as YAML test files
@@ -43,6 +44,7 @@ export const parseYamlDocuments = (yamlContent) => {
43
44
  const config = {
44
45
  file: null,
45
46
  group: null,
47
+ mocks: {},
46
48
  suites: [],
47
49
  };
48
50
 
@@ -52,6 +54,7 @@ export const parseYamlDocuments = (yamlContent) => {
52
54
  if (doc.file) {
53
55
  config.file = doc.file;
54
56
  config.group = doc.group || doc.name;
57
+ config.mocks = doc.mocks || {};
55
58
  if (doc.suites) {
56
59
  config.suiteNames = doc.suites;
57
60
  }
@@ -62,6 +65,7 @@ export const parseYamlDocuments = (yamlContent) => {
62
65
  currentSuite = {
63
66
  name: doc.suite,
64
67
  exportName: doc.exportName || doc.suite,
68
+ mocks: doc.mocks || {},
65
69
  cases: [],
66
70
  };
67
71
  // Only add mode and constructorArgs if mode is explicitly 'class'
@@ -72,6 +76,8 @@ export const parseYamlDocuments = (yamlContent) => {
72
76
  } else if (doc.case && currentSuite) {
73
77
  const testCase = {
74
78
  name: doc.case,
79
+ mocks: doc.mocks || {},
80
+ resolvedMocks: null,
75
81
  };
76
82
 
77
83
  if (currentSuite.mode === "class") {
@@ -149,18 +155,31 @@ const setupFunctionTests = (suite) => {
149
155
  out: expectedOut,
150
156
  functionUnderTest,
151
157
  throws,
158
+ mockFunctions,
152
159
  } = testCase;
153
160
  test(name, () => {
154
161
  if (!functionUnderTest) {
155
162
  throw new Error(`Function not found for test case: ${name}`);
156
163
  }
157
164
 
158
- if (throws) {
159
- // Test expects an error to be thrown
160
- expect(() => functionUnderTest(...(inArg || []))).toThrow(throws);
161
- } else {
162
- const out = functionUnderTest(...(inArg || []));
163
- expect(out).toEqual(expectedOut);
165
+ try {
166
+ if (throws) {
167
+ // Test expects an error to be thrown
168
+ expect(() => functionUnderTest(...(inArg || []))).toThrow(throws);
169
+ } else {
170
+ const out = functionUnderTest(...(inArg || []));
171
+ expect(out).toEqual(expectedOut);
172
+ }
173
+
174
+ // Validate mock calls after test execution
175
+ if (mockFunctions && Object.keys(mockFunctions).length > 0) {
176
+ validateMockCalls(mockFunctions);
177
+ }
178
+ } finally {
179
+ // Cleanup mocks after test
180
+ if (mockFunctions) {
181
+ Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
182
+ }
164
183
  }
165
184
  });
166
185
  }
@@ -178,7 +197,7 @@ const setupFunctionTests = (suite) => {
178
197
  const setupClassTests = (suite) => {
179
198
  const { cases, ClassUnderTest, constructorArgs } = suite;
180
199
  for (const testCase of cases) {
181
- const { name, executions } = testCase;
200
+ const { name, executions, mockFunctions } = testCase;
182
201
  test(name, () => {
183
202
  if (!ClassUnderTest) {
184
203
  throw new Error(`Class not found for test suite: ${suite.name}`);
@@ -186,57 +205,69 @@ const setupClassTests = (suite) => {
186
205
 
187
206
  const instance = new ClassUnderTest(...constructorArgs);
188
207
 
189
- for (const execution of executions) {
190
- const {
191
- method,
192
- in: inArg,
193
- out: expectedOut,
194
- throws,
195
- asserts,
196
- } = execution;
208
+ try {
209
+ for (const execution of executions) {
210
+ const {
211
+ method,
212
+ in: inArg,
213
+ out: expectedOut,
214
+ throws,
215
+ asserts,
216
+ } = execution;
197
217
 
198
- // Validate method exists
199
- if (!instance[method] || typeof instance[method] !== "function") {
200
- throw new Error(`Method '${method}' not found on class instance`);
201
- }
218
+ // Validate method exists
219
+ if (!instance[method] || typeof instance[method] !== "function") {
220
+ throw new Error(`Method '${method}' not found on class instance`);
221
+ }
202
222
 
203
- // Execute the method and check its return value
204
- if (throws) {
205
- expect(() => instance[method](...(inArg || []))).toThrow(throws);
206
- } else {
207
- const result = instance[method](...(inArg || []));
208
- if (expectedOut !== undefined) {
209
- expect(result).toEqual(expectedOut);
223
+ // Execute the method and check its return value
224
+ if (throws) {
225
+ expect(() => instance[method](...(inArg || []))).toThrow(throws);
226
+ } else {
227
+ const result = instance[method](...(inArg || []));
228
+ if (expectedOut !== undefined) {
229
+ expect(result).toEqual(expectedOut);
230
+ }
210
231
  }
211
- }
212
232
 
213
- // Run assertions
214
- if (asserts) {
215
- for (const assertion of asserts) {
216
- if (assertion.property) {
217
- // Property assertion
218
- const actualValue = instance[assertion.property];
219
- if (assertion.op === "eq") {
220
- expect(actualValue).toEqual(assertion.value);
221
- }
222
- // Add more operators as needed
223
- } else if (assertion.method) {
224
- // Method assertion
225
- if (
226
- !instance[assertion.method] ||
227
- typeof instance[assertion.method] !== "function"
228
- ) {
229
- throw new Error(
230
- `Method '${assertion.method}' not found on class instance for assertion`,
233
+ // Run assertions
234
+ if (asserts) {
235
+ for (const assertion of asserts) {
236
+ if (assertion.property) {
237
+ // Property assertion
238
+ const actualValue = instance[assertion.property];
239
+ if (assertion.op === "eq") {
240
+ expect(actualValue).toEqual(assertion.value);
241
+ }
242
+ // Add more operators as needed
243
+ } else if (assertion.method) {
244
+ // Method assertion
245
+ if (
246
+ !instance[assertion.method] ||
247
+ typeof instance[assertion.method] !== "function"
248
+ ) {
249
+ throw new Error(
250
+ `Method '${assertion.method}' not found on class instance for assertion`,
251
+ );
252
+ }
253
+ const result = instance[assertion.method](
254
+ ...(assertion.in || []),
231
255
  );
256
+ expect(result).toEqual(assertion.out);
232
257
  }
233
- const result = instance[assertion.method](
234
- ...(assertion.in || []),
235
- );
236
- expect(result).toEqual(assertion.out);
237
258
  }
238
259
  }
239
260
  }
261
+
262
+ // Validate mock calls after test execution
263
+ if (mockFunctions && Object.keys(mockFunctions).length > 0) {
264
+ validateMockCalls(mockFunctions);
265
+ }
266
+ } finally {
267
+ // Cleanup mocks after test
268
+ if (mockFunctions) {
269
+ Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
270
+ }
240
271
  }
241
272
  });
242
273
  }
@@ -262,6 +293,38 @@ export const injectFunctions = (module, originalTestConfig) => {
262
293
  let functionUnderTest = module[testConfig.exportName || "default"];
263
294
 
264
295
  for (const suite of testConfig.suites) {
296
+ for (const testCase of suite.cases) {
297
+ // Resolve mocks for this test case using hierarchy
298
+ testCase.resolvedMocks = resolveMocks(
299
+ testCase.mocks,
300
+ suite.mocks,
301
+ testConfig.mocks
302
+ );
303
+
304
+ // Create mock functions from resolved mock definitions
305
+ testCase.mockFunctions = createMockFunctions(testCase.resolvedMocks);
306
+
307
+ // Process mock references in test inputs and outputs
308
+ if (testCase.in) {
309
+ testCase.in = processMockReferences(testCase.in, testCase.mockFunctions);
310
+ }
311
+ if (testCase.out) {
312
+ testCase.out = processMockReferences(testCase.out, testCase.mockFunctions);
313
+ }
314
+
315
+ // Process mock references in class test executions
316
+ if (testCase.executions) {
317
+ for (const execution of testCase.executions) {
318
+ if (execution.in) {
319
+ execution.in = processMockReferences(execution.in, testCase.mockFunctions);
320
+ }
321
+ if (execution.out) {
322
+ execution.out = processMockReferences(execution.out, testCase.mockFunctions);
323
+ }
324
+ }
325
+ }
326
+ }
327
+
265
328
  if (suite.mode === "class") {
266
329
  const exportName = suite.exportName || "default";
267
330
  const exported = module[exportName];
@@ -305,15 +368,19 @@ export const injectFunctions = (module, originalTestConfig) => {
305
368
  export const setupTestSuiteFromYaml = async (dirname) => {
306
369
  const testYamlFiles = traverseAllFiles(dirname, extensions);
307
370
  for (const file of testYamlFiles) {
308
- const testConfig = parseWithIncludes(file);
309
- const filepathRelativeToSpecFile = path.join(
310
- path.dirname(file),
311
- testConfig.file,
312
- );
371
+ try {
372
+ const testConfig = parseWithIncludes(file);
373
+ const filepathRelativeToSpecFile = path.join(
374
+ path.dirname(file),
375
+ testConfig.file,
376
+ );
313
377
 
314
- // testConfig.file is relative to the spec file
315
- const module = await import(filepathRelativeToSpecFile);
316
- const testConfigWithInjectedFunctions = injectFunctions(module, testConfig);
317
- setupTestSuite(testConfigWithInjectedFunctions);
378
+ // testConfig.file is relative to the spec file
379
+ const module = await import(filepathRelativeToSpecFile);
380
+ const testConfigWithInjectedFunctions = injectFunctions(module, testConfig);
381
+ setupTestSuite(testConfigWithInjectedFunctions);
382
+ } catch (error) {
383
+ throw error;
384
+ }
318
385
  }
319
386
  };
package/src/utils.js CHANGED
@@ -149,6 +149,7 @@ const processDocuments = (docs) => {
149
149
  const config = {
150
150
  file: null,
151
151
  group: null,
152
+ mocks: {},
152
153
  suites: [],
153
154
  };
154
155
 
@@ -158,6 +159,7 @@ const processDocuments = (docs) => {
158
159
  if (doc.file) {
159
160
  config.file = doc.file;
160
161
  config.group = doc.group || doc.name;
162
+ config.mocks = doc.mocks || {};
161
163
  if (doc.suites) {
162
164
  config.suiteNames = doc.suites;
163
165
  }
@@ -168,6 +170,7 @@ const processDocuments = (docs) => {
168
170
  currentSuite = {
169
171
  name: doc.suite,
170
172
  exportName: doc.exportName || doc.suite,
173
+ mocks: doc.mocks || {},
171
174
  cases: [],
172
175
  };
173
176
  // Only add mode and constructorArgs if mode is explicitly 'class'
@@ -178,6 +181,8 @@ const processDocuments = (docs) => {
178
181
  } else if (doc.case && currentSuite) {
179
182
  const testCase = {
180
183
  name: doc.case,
184
+ mocks: doc.mocks || {},
185
+ resolvedMocks: null,
181
186
  };
182
187
 
183
188
  if (currentSuite.mode === "class") {