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 +172 -18
- package/package.json +1 -1
- package/src/mockResolver.js +69 -24
- package/src/puty.js +180 -44
- package/src/utils.js +12 -5
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
|
-
|
|
39
|
+
### Prerequisites
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
- Node.js with ES modules support
|
|
42
|
+
- Vitest installed in your project
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
await setupTestSuiteFromYaml();
|
|
44
|
+
### Step 1: Install Puty
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
```bash
|
|
47
|
+
npm install puty
|
|
47
48
|
```
|
|
48
49
|
|
|
49
|
-
|
|
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
|
-
|
|
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: './
|
|
56
|
-
group:
|
|
57
|
-
suites: [
|
|
80
|
+
file: './utils/validator.js'
|
|
81
|
+
group: validator
|
|
82
|
+
suites: [isValidEmail, capitalize]
|
|
58
83
|
---
|
|
59
|
-
suite:
|
|
60
|
-
exportName:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
package/src/mockResolver.js
CHANGED
|
@@ -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 = (
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
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,
|
|
103
|
-
throw new Error(
|
|
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(
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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] !==
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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 =>
|
|
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(() =>
|
|
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
|
-
|
|
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(
|
|
399
|
+
const actualValue = getNestedProperty(
|
|
400
|
+
instance,
|
|
401
|
+
assertion.property,
|
|
402
|
+
);
|
|
298
403
|
if (assertion.op === "eq") {
|
|
299
|
-
|
|
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(
|
|
305
|
-
|
|
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 =>
|
|
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(
|
|
479
|
+
testCase.in = processMockReferences(
|
|
480
|
+
testCase.in,
|
|
481
|
+
testCase.mockFunctions,
|
|
482
|
+
);
|
|
359
483
|
}
|
|
360
484
|
if (testCase.out) {
|
|
361
|
-
testCase.out = processMockReferences(
|
|
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(
|
|
495
|
+
execution.in = processMockReferences(
|
|
496
|
+
execution.in,
|
|
497
|
+
testCase.mockFunctions,
|
|
498
|
+
);
|
|
369
499
|
}
|
|
370
500
|
if (execution.out) {
|
|
371
|
-
execution.out = processMockReferences(
|
|
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(
|
|
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
|
-
|
|
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
|