puty 0.0.3 โ 0.0.4-rc2
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 +128 -10
- package/package.json +3 -1
- package/src/mockResolver.js +152 -0
- package/src/puty.js +176 -60
- package/src/utils.js +5 -0
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
|
|
@@ -186,12 +188,12 @@ executions:
|
|
|
186
188
|
- `mode: 'class'` - Indicates this suite tests a class
|
|
187
189
|
- `constructorArgs` - Arguments passed to the class constructor
|
|
188
190
|
- `executions` - Array of method calls to execute in sequence
|
|
189
|
-
- `method` - Name of the method to call
|
|
191
|
+
- `method` - Name of the method to call (supports nested: `user.api.getData`)
|
|
190
192
|
- `in` - Arguments to pass to the method
|
|
191
193
|
- `out` - Expected return value (optional)
|
|
192
194
|
- `asserts` - Assertions to run after the method call
|
|
193
|
-
- Property assertions: Check instance properties
|
|
194
|
-
- Method assertions: Call methods and check their return values
|
|
195
|
+
- Property assertions: Check instance properties (supports nested: `user.profile.name`)
|
|
196
|
+
- Method assertions: Call methods and check their return values (supports nested: `settings.getTheme`)
|
|
195
197
|
|
|
196
198
|
### Error Testing
|
|
197
199
|
|
|
@@ -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,25 +372,58 @@ 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:
|
|
296
387
|
```yaml
|
|
297
388
|
case: 'test description'
|
|
298
389
|
executions:
|
|
299
|
-
- method: 'methodName'
|
|
390
|
+
- method: 'methodName' # Supports nested: 'user.api.getData'
|
|
300
391
|
in: [arg1]
|
|
301
|
-
out: expectedValue
|
|
302
|
-
throws: 'Error msg'
|
|
392
|
+
out: expectedValue # Optional
|
|
393
|
+
throws: 'Error msg' # Optional
|
|
303
394
|
asserts:
|
|
304
|
-
- property: 'prop'
|
|
305
|
-
op: 'eq'
|
|
395
|
+
- property: 'prop' # Supports nested: 'user.profile.name'
|
|
396
|
+
op: 'eq' # Currently only 'eq' is supported
|
|
306
397
|
value: expected
|
|
307
|
-
- method: 'getter'
|
|
398
|
+
- method: 'getter' # Supports nested: 'settings.ui.getTheme'
|
|
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
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
#### Nested Properties and Methods
|
|
409
|
+
|
|
410
|
+
Puty supports accessing nested properties and calling nested methods using dot notation:
|
|
411
|
+
|
|
412
|
+
```yaml
|
|
413
|
+
case: 'test nested access'
|
|
414
|
+
executions:
|
|
415
|
+
- method: 'settings.ui.setTheme' # Call nested method
|
|
416
|
+
in: ['dark']
|
|
417
|
+
out: 'dark'
|
|
418
|
+
asserts:
|
|
419
|
+
- property: 'user.profile.name' # Access nested property
|
|
420
|
+
op: eq
|
|
421
|
+
value: 'John Doe'
|
|
422
|
+
- property: 'user.account.balance' # Deep nested property
|
|
423
|
+
op: eq
|
|
424
|
+
value: 100.50
|
|
425
|
+
- method: 'api.client.get' # Call nested method
|
|
426
|
+
in: ['/users/123']
|
|
427
|
+
out: 'GET /users/123'
|
|
310
428
|
```
|
|
311
429
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "puty",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4-rc2",
|
|
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,71 @@ 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";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a nested property path on an object (e.g., "user.profile.name")
|
|
16
|
+
* @param {Object} obj - The object to traverse
|
|
17
|
+
* @param {string} path - The property path (e.g., "user.profile.name")
|
|
18
|
+
* @returns {any} The value at the path
|
|
19
|
+
* @throws {Error} If any part of the path doesn't exist
|
|
20
|
+
*/
|
|
21
|
+
const getNestedProperty = (obj, path) => {
|
|
22
|
+
const parts = path.split('.');
|
|
23
|
+
let current = obj;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < parts.length; i++) {
|
|
26
|
+
if (current == null) {
|
|
27
|
+
throw new Error(`Cannot access property '${parts[i]}' of ${current} in path '${path}'`);
|
|
28
|
+
}
|
|
29
|
+
if (!(parts[i] in current)) {
|
|
30
|
+
throw new Error(`Property '${parts[i]}' not found in path '${path}'`);
|
|
31
|
+
}
|
|
32
|
+
current = current[parts[i]];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return current;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves a nested method path and calls it (e.g., "user.api.getData")
|
|
40
|
+
* @param {Object} obj - The object to traverse
|
|
41
|
+
* @param {string} path - The method path (e.g., "user.api.getData")
|
|
42
|
+
* @param {Array} args - Arguments to pass to the method
|
|
43
|
+
* @returns {any} The result of the method call
|
|
44
|
+
* @throws {Error} If any part of the path doesn't exist or final part is not a function
|
|
45
|
+
*/
|
|
46
|
+
const callNestedMethod = (obj, path, args = []) => {
|
|
47
|
+
const parts = path.split('.');
|
|
48
|
+
const methodName = parts.pop();
|
|
49
|
+
|
|
50
|
+
let current = obj;
|
|
51
|
+
const parentPath = parts.join('.');
|
|
52
|
+
|
|
53
|
+
// Navigate to the parent object
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
if (current == null) {
|
|
56
|
+
throw new Error(`Cannot access property '${part}' of ${current} in path '${path}'`);
|
|
57
|
+
}
|
|
58
|
+
if (!(part in current)) {
|
|
59
|
+
throw new Error(`Property '${part}' not found in path '${path}'`);
|
|
60
|
+
}
|
|
61
|
+
current = current[part];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if the method exists and is a function
|
|
65
|
+
if (current == null) {
|
|
66
|
+
throw new Error(`Cannot access method '${methodName}' of ${current} in path '${path}'`);
|
|
67
|
+
}
|
|
68
|
+
if (!(methodName in current)) {
|
|
69
|
+
throw new Error(`Method '${methodName}' not found in path '${path}'`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof current[methodName] !== 'function') {
|
|
72
|
+
throw new Error(`'${methodName}' is not a function in path '${path}'`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return current[methodName](...args);
|
|
76
|
+
};
|
|
12
77
|
|
|
13
78
|
/**
|
|
14
79
|
* File extensions that are recognized as YAML test files
|
|
@@ -43,6 +108,7 @@ export const parseYamlDocuments = (yamlContent) => {
|
|
|
43
108
|
const config = {
|
|
44
109
|
file: null,
|
|
45
110
|
group: null,
|
|
111
|
+
mocks: {},
|
|
46
112
|
suites: [],
|
|
47
113
|
};
|
|
48
114
|
|
|
@@ -52,6 +118,7 @@ export const parseYamlDocuments = (yamlContent) => {
|
|
|
52
118
|
if (doc.file) {
|
|
53
119
|
config.file = doc.file;
|
|
54
120
|
config.group = doc.group || doc.name;
|
|
121
|
+
config.mocks = doc.mocks || {};
|
|
55
122
|
if (doc.suites) {
|
|
56
123
|
config.suiteNames = doc.suites;
|
|
57
124
|
}
|
|
@@ -62,6 +129,7 @@ export const parseYamlDocuments = (yamlContent) => {
|
|
|
62
129
|
currentSuite = {
|
|
63
130
|
name: doc.suite,
|
|
64
131
|
exportName: doc.exportName || doc.suite,
|
|
132
|
+
mocks: doc.mocks || {},
|
|
65
133
|
cases: [],
|
|
66
134
|
};
|
|
67
135
|
// Only add mode and constructorArgs if mode is explicitly 'class'
|
|
@@ -72,6 +140,8 @@ export const parseYamlDocuments = (yamlContent) => {
|
|
|
72
140
|
} else if (doc.case && currentSuite) {
|
|
73
141
|
const testCase = {
|
|
74
142
|
name: doc.case,
|
|
143
|
+
mocks: doc.mocks || {},
|
|
144
|
+
resolvedMocks: null,
|
|
75
145
|
};
|
|
76
146
|
|
|
77
147
|
if (currentSuite.mode === "class") {
|
|
@@ -149,18 +219,31 @@ const setupFunctionTests = (suite) => {
|
|
|
149
219
|
out: expectedOut,
|
|
150
220
|
functionUnderTest,
|
|
151
221
|
throws,
|
|
222
|
+
mockFunctions,
|
|
152
223
|
} = testCase;
|
|
153
224
|
test(name, () => {
|
|
154
225
|
if (!functionUnderTest) {
|
|
155
226
|
throw new Error(`Function not found for test case: ${name}`);
|
|
156
227
|
}
|
|
157
228
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
229
|
+
try {
|
|
230
|
+
if (throws) {
|
|
231
|
+
// Test expects an error to be thrown
|
|
232
|
+
expect(() => functionUnderTest(...(inArg || []))).toThrow(throws);
|
|
233
|
+
} else {
|
|
234
|
+
const out = functionUnderTest(...(inArg || []));
|
|
235
|
+
expect(out).toEqual(expectedOut);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Validate mock calls after test execution
|
|
239
|
+
if (mockFunctions && Object.keys(mockFunctions).length > 0) {
|
|
240
|
+
validateMockCalls(mockFunctions);
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
// Cleanup mocks after test
|
|
244
|
+
if (mockFunctions) {
|
|
245
|
+
Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
|
|
246
|
+
}
|
|
164
247
|
}
|
|
165
248
|
});
|
|
166
249
|
}
|
|
@@ -178,7 +261,7 @@ const setupFunctionTests = (suite) => {
|
|
|
178
261
|
const setupClassTests = (suite) => {
|
|
179
262
|
const { cases, ClassUnderTest, constructorArgs } = suite;
|
|
180
263
|
for (const testCase of cases) {
|
|
181
|
-
const { name, executions } = testCase;
|
|
264
|
+
const { name, executions, mockFunctions } = testCase;
|
|
182
265
|
test(name, () => {
|
|
183
266
|
if (!ClassUnderTest) {
|
|
184
267
|
throw new Error(`Class not found for test suite: ${suite.name}`);
|
|
@@ -186,57 +269,54 @@ const setupClassTests = (suite) => {
|
|
|
186
269
|
|
|
187
270
|
const instance = new ClassUnderTest(...constructorArgs);
|
|
188
271
|
|
|
189
|
-
|
|
190
|
-
const {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// Validate method exists
|
|
199
|
-
if (!instance[method] || typeof instance[method] !== "function") {
|
|
200
|
-
throw new Error(`Method '${method}' not found on class instance`);
|
|
201
|
-
}
|
|
272
|
+
try {
|
|
273
|
+
for (const execution of executions) {
|
|
274
|
+
const {
|
|
275
|
+
method,
|
|
276
|
+
in: inArg,
|
|
277
|
+
out: expectedOut,
|
|
278
|
+
throws,
|
|
279
|
+
asserts,
|
|
280
|
+
} = execution;
|
|
202
281
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
282
|
+
// Execute the method and check its return value - supports nested methods
|
|
283
|
+
if (throws) {
|
|
284
|
+
expect(() => callNestedMethod(instance, method, inArg || [])).toThrow(throws);
|
|
285
|
+
} else {
|
|
286
|
+
const result = callNestedMethod(instance, method, inArg || []);
|
|
287
|
+
if (expectedOut !== undefined) {
|
|
288
|
+
expect(result).toEqual(expectedOut);
|
|
289
|
+
}
|
|
210
290
|
}
|
|
211
|
-
}
|
|
212
291
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
typeof instance[assertion.method] !== "function"
|
|
228
|
-
) {
|
|
229
|
-
throw new Error(
|
|
230
|
-
`Method '${assertion.method}' not found on class instance for assertion`,
|
|
231
|
-
);
|
|
292
|
+
// Run assertions
|
|
293
|
+
if (asserts) {
|
|
294
|
+
for (const assertion of asserts) {
|
|
295
|
+
if (assertion.property) {
|
|
296
|
+
// Property assertion - supports nested properties like "user.profile.name"
|
|
297
|
+
const actualValue = getNestedProperty(instance, assertion.property);
|
|
298
|
+
if (assertion.op === "eq") {
|
|
299
|
+
expect(actualValue).toEqual(assertion.value);
|
|
300
|
+
}
|
|
301
|
+
// Add more operators as needed
|
|
302
|
+
} else if (assertion.method) {
|
|
303
|
+
// Method assertion - supports nested methods like "user.api.getData"
|
|
304
|
+
const result = callNestedMethod(instance, assertion.method, assertion.in || []);
|
|
305
|
+
expect(result).toEqual(assertion.out);
|
|
232
306
|
}
|
|
233
|
-
const result = instance[assertion.method](
|
|
234
|
-
...(assertion.in || []),
|
|
235
|
-
);
|
|
236
|
-
expect(result).toEqual(assertion.out);
|
|
237
307
|
}
|
|
238
308
|
}
|
|
239
309
|
}
|
|
310
|
+
|
|
311
|
+
// Validate mock calls after test execution
|
|
312
|
+
if (mockFunctions && Object.keys(mockFunctions).length > 0) {
|
|
313
|
+
validateMockCalls(mockFunctions);
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
// Cleanup mocks after test
|
|
317
|
+
if (mockFunctions) {
|
|
318
|
+
Object.values(mockFunctions).forEach(mock => mock.mockFunction.mockClear?.());
|
|
319
|
+
}
|
|
240
320
|
}
|
|
241
321
|
});
|
|
242
322
|
}
|
|
@@ -262,6 +342,38 @@ export const injectFunctions = (module, originalTestConfig) => {
|
|
|
262
342
|
let functionUnderTest = module[testConfig.exportName || "default"];
|
|
263
343
|
|
|
264
344
|
for (const suite of testConfig.suites) {
|
|
345
|
+
for (const testCase of suite.cases) {
|
|
346
|
+
// Resolve mocks for this test case using hierarchy
|
|
347
|
+
testCase.resolvedMocks = resolveMocks(
|
|
348
|
+
testCase.mocks,
|
|
349
|
+
suite.mocks,
|
|
350
|
+
testConfig.mocks
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Create mock functions from resolved mock definitions
|
|
354
|
+
testCase.mockFunctions = createMockFunctions(testCase.resolvedMocks);
|
|
355
|
+
|
|
356
|
+
// Process mock references in test inputs and outputs
|
|
357
|
+
if (testCase.in) {
|
|
358
|
+
testCase.in = processMockReferences(testCase.in, testCase.mockFunctions);
|
|
359
|
+
}
|
|
360
|
+
if (testCase.out) {
|
|
361
|
+
testCase.out = processMockReferences(testCase.out, testCase.mockFunctions);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Process mock references in class test executions
|
|
365
|
+
if (testCase.executions) {
|
|
366
|
+
for (const execution of testCase.executions) {
|
|
367
|
+
if (execution.in) {
|
|
368
|
+
execution.in = processMockReferences(execution.in, testCase.mockFunctions);
|
|
369
|
+
}
|
|
370
|
+
if (execution.out) {
|
|
371
|
+
execution.out = processMockReferences(execution.out, testCase.mockFunctions);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
265
377
|
if (suite.mode === "class") {
|
|
266
378
|
const exportName = suite.exportName || "default";
|
|
267
379
|
const exported = module[exportName];
|
|
@@ -305,15 +417,19 @@ export const injectFunctions = (module, originalTestConfig) => {
|
|
|
305
417
|
export const setupTestSuiteFromYaml = async (dirname) => {
|
|
306
418
|
const testYamlFiles = traverseAllFiles(dirname, extensions);
|
|
307
419
|
for (const file of testYamlFiles) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
path.
|
|
311
|
-
|
|
312
|
-
|
|
420
|
+
try {
|
|
421
|
+
const testConfig = parseWithIncludes(file);
|
|
422
|
+
const filepathRelativeToSpecFile = path.join(
|
|
423
|
+
path.dirname(file),
|
|
424
|
+
testConfig.file,
|
|
425
|
+
);
|
|
313
426
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
427
|
+
// testConfig.file is relative to the spec file
|
|
428
|
+
const module = await import(filepathRelativeToSpecFile);
|
|
429
|
+
const testConfigWithInjectedFunctions = injectFunctions(module, testConfig);
|
|
430
|
+
setupTestSuite(testConfigWithInjectedFunctions);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
318
434
|
}
|
|
319
435
|
};
|
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") {
|