puty 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -18
- package/package.json +2 -2
- package/src/mockResolver.js +52 -25
- package/src/puty.js +153 -45
- package/src/utils.js +34 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "puty",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "A tooling function to test javascript functions and classes.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -25,4 +25,4 @@
|
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"vitest": "^3.2.1"
|
|
27
27
|
}
|
|
28
|
-
}
|
|
28
|
+
}
|
package/src/mockResolver.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { vi } from "vitest";
|
|
8
|
+
import { processUndefined } from "./utils.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Deep equality check for mock argument validation
|
|
@@ -41,12 +42,16 @@ const deepEqual = (a, b) => {
|
|
|
41
42
|
* @param {Object} globalMocks - Global-level mock definitions
|
|
42
43
|
* @returns {Object} Resolved mock definitions with hierarchy applied
|
|
43
44
|
*/
|
|
44
|
-
export const resolveMocks = (
|
|
45
|
+
export const resolveMocks = (
|
|
46
|
+
caseMocks = {},
|
|
47
|
+
suiteMocks = {},
|
|
48
|
+
globalMocks = {},
|
|
49
|
+
) => {
|
|
45
50
|
const resolved = {};
|
|
46
|
-
|
|
51
|
+
|
|
47
52
|
// Apply hierarchy: global -> suite -> case (case overrides suite, suite overrides global)
|
|
48
53
|
Object.assign(resolved, globalMocks, suiteMocks, caseMocks);
|
|
49
|
-
|
|
54
|
+
|
|
50
55
|
return resolved;
|
|
51
56
|
};
|
|
52
57
|
|
|
@@ -57,26 +62,26 @@ export const resolveMocks = (caseMocks = {}, suiteMocks = {}, globalMocks = {})
|
|
|
57
62
|
* @returns {any} Processed value with $mock: references replaced
|
|
58
63
|
*/
|
|
59
64
|
export const processMockReferences = (value, mockFunctions) => {
|
|
60
|
-
if (typeof value ===
|
|
65
|
+
if (typeof value === "string" && value.startsWith("$mock:")) {
|
|
61
66
|
const mockName = value.substring(6); // Remove '$mock:' prefix
|
|
62
67
|
if (!mockFunctions[mockName]) {
|
|
63
68
|
throw new Error(`Mock '${mockName}' is referenced but not defined`);
|
|
64
69
|
}
|
|
65
70
|
return mockFunctions[mockName].mockFunction;
|
|
66
71
|
}
|
|
67
|
-
|
|
72
|
+
|
|
68
73
|
if (Array.isArray(value)) {
|
|
69
|
-
return value.map(item => processMockReferences(item, mockFunctions));
|
|
74
|
+
return value.map((item) => processMockReferences(item, mockFunctions));
|
|
70
75
|
}
|
|
71
|
-
|
|
72
|
-
if (value && typeof value ===
|
|
76
|
+
|
|
77
|
+
if (value && typeof value === "object") {
|
|
73
78
|
const processed = {};
|
|
74
79
|
for (const [key, val] of Object.entries(value)) {
|
|
75
80
|
processed[key] = processMockReferences(val, mockFunctions);
|
|
76
81
|
}
|
|
77
82
|
return processed;
|
|
78
83
|
}
|
|
79
|
-
|
|
84
|
+
|
|
80
85
|
return value;
|
|
81
86
|
};
|
|
82
87
|
|
|
@@ -88,40 +93,62 @@ export const processMockReferences = (value, mockFunctions) => {
|
|
|
88
93
|
* @returns {Object} Mock function wrapper with validation methods
|
|
89
94
|
*/
|
|
90
95
|
export const createMockFunction = (mockName, mockDefinition) => {
|
|
96
|
+
// Handle simple mock definition (fn: true)
|
|
97
|
+
if (mockDefinition.fn === true) {
|
|
98
|
+
const mockFn = vi.fn();
|
|
99
|
+
return {
|
|
100
|
+
mockFunction: mockFn,
|
|
101
|
+
expectedCalls: 0, // No specific call expectations
|
|
102
|
+
actualCalls: () => mockFn.mock.calls.length,
|
|
103
|
+
validate: () => {
|
|
104
|
+
// Simple mocks don't have call expectations
|
|
105
|
+
},
|
|
106
|
+
mockName,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
const { calls } = mockDefinition;
|
|
92
111
|
let callIndex = 0;
|
|
93
|
-
|
|
112
|
+
|
|
94
113
|
const mockFn = vi.fn().mockImplementation((...args) => {
|
|
95
114
|
if (callIndex >= calls.length) {
|
|
96
|
-
throw new Error(
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Mock '${mockName}' was called ${callIndex + 1} time(s) but expected exactly ${calls.length} calls`,
|
|
117
|
+
);
|
|
97
118
|
}
|
|
98
|
-
|
|
119
|
+
|
|
99
120
|
const expectedCall = calls[callIndex];
|
|
100
|
-
|
|
121
|
+
|
|
122
|
+
const processedExpectedIn = processUndefined(expectedCall.in);
|
|
123
|
+
|
|
101
124
|
// Validate input arguments
|
|
102
|
-
if (!deepEqual(args,
|
|
103
|
-
throw new Error(
|
|
125
|
+
if (!deepEqual(args, processedExpectedIn)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Expected ${mockName}(${JSON.stringify(expectedCall.in)}) but got ${mockName}(${JSON.stringify(args)})`,
|
|
128
|
+
);
|
|
104
129
|
}
|
|
105
|
-
|
|
130
|
+
|
|
106
131
|
callIndex++;
|
|
107
|
-
|
|
132
|
+
|
|
108
133
|
if (expectedCall.throws) {
|
|
109
134
|
throw new Error(expectedCall.throws);
|
|
110
135
|
}
|
|
111
|
-
|
|
112
|
-
return expectedCall.out;
|
|
136
|
+
|
|
137
|
+
return processUndefined(expectedCall.out);
|
|
113
138
|
});
|
|
114
|
-
|
|
139
|
+
|
|
115
140
|
return {
|
|
116
141
|
mockFunction: mockFn,
|
|
117
142
|
expectedCalls: calls.length,
|
|
118
143
|
actualCalls: () => callIndex,
|
|
119
144
|
validate: () => {
|
|
120
145
|
if (callIndex !== calls.length) {
|
|
121
|
-
throw new Error(
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Mock '${mockName}' was called ${callIndex} time(s) but expected exactly ${calls.length} calls`,
|
|
148
|
+
);
|
|
122
149
|
}
|
|
123
150
|
},
|
|
124
|
-
mockName
|
|
151
|
+
mockName,
|
|
125
152
|
};
|
|
126
153
|
};
|
|
127
154
|
|
|
@@ -143,10 +170,10 @@ export const validateMockCalls = (mockFunctions) => {
|
|
|
143
170
|
*/
|
|
144
171
|
export const createMockFunctions = (resolvedMocks) => {
|
|
145
172
|
const mockFunctions = {};
|
|
146
|
-
|
|
173
|
+
|
|
147
174
|
for (const [mockName, mockDef] of Object.entries(resolvedMocks)) {
|
|
148
175
|
mockFunctions[mockName] = createMockFunction(mockName, mockDef);
|
|
149
176
|
}
|
|
150
|
-
|
|
177
|
+
|
|
151
178
|
return mockFunctions;
|
|
152
|
-
};
|
|
179
|
+
};
|
package/src/puty.js
CHANGED
|
@@ -8,8 +8,13 @@ import path from "node:path";
|
|
|
8
8
|
import yaml from "js-yaml";
|
|
9
9
|
import { expect, test, describe } from "vitest";
|
|
10
10
|
|
|
11
|
-
import { traverseAllFiles, parseWithIncludes } from "./utils.js";
|
|
12
|
-
import {
|
|
11
|
+
import { traverseAllFiles, parseWithIncludes, processUndefined } from "./utils.js";
|
|
12
|
+
import {
|
|
13
|
+
resolveMocks,
|
|
14
|
+
processMockReferences,
|
|
15
|
+
createMockFunctions,
|
|
16
|
+
validateMockCalls,
|
|
17
|
+
} from "./mockResolver.js";
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* Resolves a nested property path on an object (e.g., "user.profile.name")
|
|
@@ -19,19 +24,21 @@ import { resolveMocks, processMockReferences, createMockFunctions, validateMockC
|
|
|
19
24
|
* @throws {Error} If any part of the path doesn't exist
|
|
20
25
|
*/
|
|
21
26
|
const getNestedProperty = (obj, path) => {
|
|
22
|
-
const parts = path.split(
|
|
27
|
+
const parts = path.split(".");
|
|
23
28
|
let current = obj;
|
|
24
|
-
|
|
29
|
+
|
|
25
30
|
for (let i = 0; i < parts.length; i++) {
|
|
26
31
|
if (current == null) {
|
|
27
|
-
throw new Error(
|
|
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,69 @@ 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
|
+
const expectedOut = processUndefined(testCase.out);
|
|
258
|
+
expect(result).toEqual(expectedOut);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// If executions are present, execute methods on the returned object
|
|
262
|
+
if (executions && executions.length > 0) {
|
|
263
|
+
for (const execution of executions) {
|
|
264
|
+
const {
|
|
265
|
+
method,
|
|
266
|
+
in: execInArg,
|
|
267
|
+
out: execExpectedOut,
|
|
268
|
+
throws: execThrows,
|
|
269
|
+
asserts,
|
|
270
|
+
} = execution;
|
|
271
|
+
|
|
272
|
+
if (execThrows) {
|
|
273
|
+
expect(() =>
|
|
274
|
+
callNestedMethod(result, method, execInArg || []),
|
|
275
|
+
).toThrow(execThrows);
|
|
276
|
+
} else {
|
|
277
|
+
const methodResult = callNestedMethod(
|
|
278
|
+
result,
|
|
279
|
+
method,
|
|
280
|
+
execInArg || [],
|
|
281
|
+
);
|
|
282
|
+
if (execExpectedOut !== undefined) {
|
|
283
|
+
const processedExpectedOut = processUndefined(execExpectedOut);
|
|
284
|
+
expect(methodResult).toEqual(processedExpectedOut);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Run assertions
|
|
289
|
+
if (asserts) {
|
|
290
|
+
for (const assertion of asserts) {
|
|
291
|
+
if (assertion.property) {
|
|
292
|
+
const actualValue = getNestedProperty(
|
|
293
|
+
result,
|
|
294
|
+
assertion.property,
|
|
295
|
+
);
|
|
296
|
+
if (assertion.op === "eq") {
|
|
297
|
+
const processedValue = processUndefined(assertion.value);
|
|
298
|
+
expect(actualValue).toEqual(processedValue);
|
|
299
|
+
}
|
|
300
|
+
} else if (assertion.method) {
|
|
301
|
+
const assertResult = callNestedMethod(
|
|
302
|
+
result,
|
|
303
|
+
assertion.method,
|
|
304
|
+
assertion.in || [],
|
|
305
|
+
);
|
|
306
|
+
const processedOut = processUndefined(assertion.out);
|
|
307
|
+
expect(assertResult).toEqual(processedOut);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
236
313
|
}
|
|
237
|
-
|
|
314
|
+
|
|
238
315
|
// Validate mock calls after test execution
|
|
239
316
|
if (mockFunctions && Object.keys(mockFunctions).length > 0) {
|
|
240
317
|
validateMockCalls(mockFunctions);
|
|
@@ -242,7 +319,9 @@ const setupFunctionTests = (suite) => {
|
|
|
242
319
|
} finally {
|
|
243
320
|
// Cleanup mocks after test
|
|
244
321
|
if (mockFunctions) {
|
|
245
|
-
Object.values(mockFunctions).forEach(mock =>
|
|
322
|
+
Object.values(mockFunctions).forEach((mock) =>
|
|
323
|
+
mock.mockFunction.mockClear?.(),
|
|
324
|
+
);
|
|
246
325
|
}
|
|
247
326
|
}
|
|
248
327
|
});
|
|
@@ -253,7 +332,7 @@ const setupFunctionTests = (suite) => {
|
|
|
253
332
|
* Sets up individual test cases for class-based testing
|
|
254
333
|
* @param {Object} suite - Test suite configuration for class testing
|
|
255
334
|
* @param {Object[]} suite.cases - Array of test case objects
|
|
256
|
-
* @param {string} suite.cases[].name - Test case name
|
|
335
|
+
* @param {string} suite.cases[].name - Test case name
|
|
257
336
|
* @param {Object[]} suite.cases[].executions - Array of method executions to perform
|
|
258
337
|
* @param {Function} suite.ClassUnderTest - The class constructor to test
|
|
259
338
|
* @param {any[]} suite.constructorArgs - Arguments to pass to class constructor
|
|
@@ -281,11 +360,14 @@ const setupClassTests = (suite) => {
|
|
|
281
360
|
|
|
282
361
|
// Execute the method and check its return value - supports nested methods
|
|
283
362
|
if (throws) {
|
|
284
|
-
expect(() =>
|
|
363
|
+
expect(() =>
|
|
364
|
+
callNestedMethod(instance, method, inArg || []),
|
|
365
|
+
).toThrow(throws);
|
|
285
366
|
} else {
|
|
286
367
|
const result = callNestedMethod(instance, method, inArg || []);
|
|
287
368
|
if (expectedOut !== undefined) {
|
|
288
|
-
|
|
369
|
+
const processedExpectedOut = processUndefined(expectedOut);
|
|
370
|
+
expect(result).toEqual(processedExpectedOut);
|
|
289
371
|
}
|
|
290
372
|
}
|
|
291
373
|
|
|
@@ -294,20 +376,29 @@ const setupClassTests = (suite) => {
|
|
|
294
376
|
for (const assertion of asserts) {
|
|
295
377
|
if (assertion.property) {
|
|
296
378
|
// Property assertion - supports nested properties like "user.profile.name"
|
|
297
|
-
const actualValue = getNestedProperty(
|
|
379
|
+
const actualValue = getNestedProperty(
|
|
380
|
+
instance,
|
|
381
|
+
assertion.property,
|
|
382
|
+
);
|
|
298
383
|
if (assertion.op === "eq") {
|
|
299
|
-
|
|
384
|
+
const processedValue = processUndefined(assertion.value);
|
|
385
|
+
expect(actualValue).toEqual(processedValue);
|
|
300
386
|
}
|
|
301
387
|
// Add more operators as needed
|
|
302
388
|
} else if (assertion.method) {
|
|
303
389
|
// Method assertion - supports nested methods like "user.api.getData"
|
|
304
|
-
const result = callNestedMethod(
|
|
305
|
-
|
|
390
|
+
const result = callNestedMethod(
|
|
391
|
+
instance,
|
|
392
|
+
assertion.method,
|
|
393
|
+
assertion.in || [],
|
|
394
|
+
);
|
|
395
|
+
const processedOut = processUndefined(assertion.out);
|
|
396
|
+
expect(result).toEqual(processedOut);
|
|
306
397
|
}
|
|
307
398
|
}
|
|
308
399
|
}
|
|
309
400
|
}
|
|
310
|
-
|
|
401
|
+
|
|
311
402
|
// Validate mock calls after test execution
|
|
312
403
|
if (mockFunctions && Object.keys(mockFunctions).length > 0) {
|
|
313
404
|
validateMockCalls(mockFunctions);
|
|
@@ -315,7 +406,9 @@ const setupClassTests = (suite) => {
|
|
|
315
406
|
} finally {
|
|
316
407
|
// Cleanup mocks after test
|
|
317
408
|
if (mockFunctions) {
|
|
318
|
-
Object.values(mockFunctions).forEach(mock =>
|
|
409
|
+
Object.values(mockFunctions).forEach((mock) =>
|
|
410
|
+
mock.mockFunction.mockClear?.(),
|
|
411
|
+
);
|
|
319
412
|
}
|
|
320
413
|
}
|
|
321
414
|
});
|
|
@@ -331,8 +424,8 @@ const setupClassTests = (suite) => {
|
|
|
331
424
|
* @example
|
|
332
425
|
* // Import module and inject functions
|
|
333
426
|
* const module = await import('./math.js');
|
|
334
|
-
* const testConfig = {
|
|
335
|
-
* suites: [{ name: 'add', exportName: 'add', cases: [...] }]
|
|
427
|
+
* const testConfig = {
|
|
428
|
+
* suites: [{ name: 'add', exportName: 'add', cases: [...] }]
|
|
336
429
|
* };
|
|
337
430
|
* const ready = injectFunctions(module, testConfig);
|
|
338
431
|
* // ready.suites[0].cases[0].functionUnderTest === module.add
|
|
@@ -347,33 +440,45 @@ export const injectFunctions = (module, originalTestConfig) => {
|
|
|
347
440
|
testCase.resolvedMocks = resolveMocks(
|
|
348
441
|
testCase.mocks,
|
|
349
442
|
suite.mocks,
|
|
350
|
-
testConfig.mocks
|
|
443
|
+
testConfig.mocks,
|
|
351
444
|
);
|
|
352
|
-
|
|
445
|
+
|
|
353
446
|
// Create mock functions from resolved mock definitions
|
|
354
447
|
testCase.mockFunctions = createMockFunctions(testCase.resolvedMocks);
|
|
355
|
-
|
|
448
|
+
|
|
356
449
|
// Process mock references in test inputs and outputs
|
|
357
450
|
if (testCase.in) {
|
|
358
|
-
testCase.in = processMockReferences(
|
|
451
|
+
testCase.in = processMockReferences(
|
|
452
|
+
testCase.in,
|
|
453
|
+
testCase.mockFunctions,
|
|
454
|
+
);
|
|
359
455
|
}
|
|
360
456
|
if (testCase.out) {
|
|
361
|
-
testCase.out = processMockReferences(
|
|
457
|
+
testCase.out = processMockReferences(
|
|
458
|
+
testCase.out,
|
|
459
|
+
testCase.mockFunctions,
|
|
460
|
+
);
|
|
362
461
|
}
|
|
363
|
-
|
|
462
|
+
|
|
364
463
|
// Process mock references in class test executions
|
|
365
464
|
if (testCase.executions) {
|
|
366
465
|
for (const execution of testCase.executions) {
|
|
367
466
|
if (execution.in) {
|
|
368
|
-
execution.in = processMockReferences(
|
|
467
|
+
execution.in = processMockReferences(
|
|
468
|
+
execution.in,
|
|
469
|
+
testCase.mockFunctions,
|
|
470
|
+
);
|
|
369
471
|
}
|
|
370
472
|
if (execution.out) {
|
|
371
|
-
execution.out = processMockReferences(
|
|
473
|
+
execution.out = processMockReferences(
|
|
474
|
+
execution.out,
|
|
475
|
+
testCase.mockFunctions,
|
|
476
|
+
);
|
|
372
477
|
}
|
|
373
478
|
}
|
|
374
479
|
}
|
|
375
480
|
}
|
|
376
|
-
|
|
481
|
+
|
|
377
482
|
if (suite.mode === "class") {
|
|
378
483
|
const exportName = suite.exportName || "default";
|
|
379
484
|
const exported = module[exportName];
|
|
@@ -408,10 +513,10 @@ export const injectFunctions = (module, originalTestConfig) => {
|
|
|
408
513
|
* @example
|
|
409
514
|
* // Set up all test suites from YAML files in the current directory
|
|
410
515
|
* await setupTestSuiteFromYaml('.');
|
|
411
|
-
*
|
|
516
|
+
*
|
|
412
517
|
* // Set up tests from a specific directory
|
|
413
518
|
* await setupTestSuiteFromYaml('./tests');
|
|
414
|
-
*
|
|
519
|
+
*
|
|
415
520
|
* // This will find all files matching: *.test.yaml, *.test.yml, *.spec.yaml, *.spec.yml
|
|
416
521
|
*/
|
|
417
522
|
export const setupTestSuiteFromYaml = async (dirname) => {
|
|
@@ -426,7 +531,10 @@ export const setupTestSuiteFromYaml = async (dirname) => {
|
|
|
426
531
|
|
|
427
532
|
// testConfig.file is relative to the spec file
|
|
428
533
|
const module = await import(filepathRelativeToSpecFile);
|
|
429
|
-
const testConfigWithInjectedFunctions = injectFunctions(
|
|
534
|
+
const testConfigWithInjectedFunctions = injectFunctions(
|
|
535
|
+
module,
|
|
536
|
+
testConfig,
|
|
537
|
+
);
|
|
430
538
|
setupTestSuite(testConfigWithInjectedFunctions);
|
|
431
539
|
} catch (error) {
|
|
432
540
|
throw error;
|
package/src/utils.js
CHANGED
|
@@ -18,7 +18,7 @@ import yaml from "js-yaml";
|
|
|
18
18
|
* @example
|
|
19
19
|
* // Load a simple YAML file
|
|
20
20
|
* const config = loadYamlWithPath('./config.yaml');
|
|
21
|
-
*
|
|
21
|
+
*
|
|
22
22
|
* // Load YAML with includes
|
|
23
23
|
* const data = loadYamlWithPath('./main.yaml'); // main.yaml contains: data: !include ./data.yaml
|
|
24
24
|
*/
|
|
@@ -80,7 +80,7 @@ export const loadYamlWithPath = (filePath, visitedFiles = new Set()) => {
|
|
|
80
80
|
* @example
|
|
81
81
|
* // Find all YAML test files
|
|
82
82
|
* const testFiles = traverseAllFiles('./tests', ['.test.yaml', '.spec.yml']);
|
|
83
|
-
*
|
|
83
|
+
*
|
|
84
84
|
* // Find all JavaScript files
|
|
85
85
|
* const jsFiles = traverseAllFiles('./src', ['.js', '.ts']);
|
|
86
86
|
*/
|
|
@@ -189,10 +189,17 @@ const processDocuments = (docs) => {
|
|
|
189
189
|
testCase.executions = doc.executions || [];
|
|
190
190
|
} else {
|
|
191
191
|
testCase.in = doc.in || [];
|
|
192
|
-
|
|
192
|
+
// Only add 'out' if it's present in the doc
|
|
193
|
+
if ("out" in doc) {
|
|
194
|
+
testCase.out = doc.out;
|
|
195
|
+
}
|
|
193
196
|
if (doc.throws) {
|
|
194
197
|
testCase.throws = doc.throws;
|
|
195
198
|
}
|
|
199
|
+
// Allow executions for function tests (factory pattern)
|
|
200
|
+
if (doc.executions) {
|
|
201
|
+
testCase.executions = doc.executions;
|
|
202
|
+
}
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
currentSuite.cases.push(testCase);
|
|
@@ -215,9 +222,9 @@ const processDocuments = (docs) => {
|
|
|
215
222
|
* // Parse a test configuration file with includes
|
|
216
223
|
* const config = parseWithIncludes('./tests/math.spec.yaml');
|
|
217
224
|
* // Returns: { file: './math.js', group: 'math', suites: [...] }
|
|
218
|
-
*
|
|
225
|
+
*
|
|
219
226
|
* // Works with files containing !include directives
|
|
220
|
-
* // main.yaml:
|
|
227
|
+
* // main.yaml:
|
|
221
228
|
* // file: './lib.js'
|
|
222
229
|
* // ---
|
|
223
230
|
* // !include ./test-cases.yaml
|
|
@@ -231,3 +238,25 @@ export const parseWithIncludes = (filePath) => {
|
|
|
231
238
|
|
|
232
239
|
return processDocuments(flattenedDocs);
|
|
233
240
|
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Recursively processes a value to convert "__undefined__" strings to actual undefined values
|
|
244
|
+
* @param {any} value - The value to process (string, array, object, or primitive)
|
|
245
|
+
* @returns {any} The processed value with "__undefined__" converted to undefined
|
|
246
|
+
* @example
|
|
247
|
+
* processUndefined("__undefined__") // returns undefined
|
|
248
|
+
* processUndefined({ a: "__undefined__", b: [1, "__undefined__"] })
|
|
249
|
+
* // returns { a: undefined, b: [1, undefined] }
|
|
250
|
+
*/
|
|
251
|
+
export const processUndefined = (value) => {
|
|
252
|
+
if (value === "__undefined__") return undefined;
|
|
253
|
+
if (Array.isArray(value)) return value.map(processUndefined);
|
|
254
|
+
if (value && typeof value === "object") {
|
|
255
|
+
const processed = {};
|
|
256
|
+
for (const [key, val] of Object.entries(value)) {
|
|
257
|
+
processed[key] = processUndefined(val);
|
|
258
|
+
}
|
|
259
|
+
return processed;
|
|
260
|
+
}
|
|
261
|
+
return value;
|
|
262
|
+
};
|