Skip to content

Commit 36b323d

Browse files
dacharyccbullinger
andauthored
Node.js Driver Test Suite: Extend output comparison func, update docs (#13535)
* Extend the output comparison func, update docs * Add UTC date handling to fix discrepancies across time series examples * Apply suggestions from review Co-authored-by: cory <[email protected]> * Formatting changes courtesy of Prettier --------- Co-authored-by: cory <[email protected]>
1 parent df42adb commit 36b323d

File tree

4 files changed

+208
-44
lines changed

4 files changed

+208
-44
lines changed

.github/workflows/node-driver-examples-test-in-docker.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ on:
88
jobs:
99
run:
1010
runs-on: ubuntu-latest
11+
env:
12+
TZ: UTC
1113

1214
steps:
1315
- uses: actions/checkout@v4
@@ -31,6 +33,7 @@ jobs:
3133
cd code-example-tests/javascript/driver/
3234
touch .env
3335
echo "CONNECTION_STRING=\"mongodb://localhost:27017/?directConnection=true\"" >> .env
36+
echo "TZ=UTC" >> .env
3437
- name: Run tests
3538
run: |
3639
cd code-example-tests/javascript/driver/

code-example-tests/javascript/driver/README.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,29 @@ in your code example, expect an exact match:
272272
expect(rawExpectedOutput).toStrictEqual(result);
273273
```
274274

275+
If your output contains MongoDB data types that require special handling when
276+
reading from file, you can use the provided helper function to read the output.
277+
278+
Import the helper function at the top of the test file:
279+
280+
```javascript
281+
import outputMatchesExampleOutput from '../../../utils/outputMatchesExampleOutput.js';
282+
```
283+
284+
And then use this function to verify the output, providing the `'ordered'` parameter:
285+
286+
```javascript
287+
const arraysMatch = outputMatchesExampleOutput(
288+
outputFilepath,
289+
result,
290+
'ordered'
291+
);
292+
expect(arraysMatch).toBeTruthy();
293+
```
294+
295+
The function returns `true` if all elements are present and in the correct order, or
296+
`false` if they're not.
297+
275298
##### Verify unordered output
276299

277300
If you expect the output to be in a random order, as when you are not performing
@@ -281,13 +304,17 @@ of the output is present in your output file.
281304
Import the helper function at the top of the test file:
282305

283306
```javascript
284-
import unorderedArrayOutputMatches from '../../../utils/outputMatchesExampleOutput.js';
307+
import outputMatchesExampleOutput from '../../../utils/outputMatchesExampleOutput.js';
285308
```
286309

287-
And then use this function to verify the output:
310+
And then use this function to verify the output, providing the `'unordered'` parameter:
288311

289312
```javascript
290-
const arraysMatch = unorderedArrayOutputMatches(outputFilepath, result);
313+
const arraysMatch = outputMatchesExampleOutput(
314+
outputFilepath,
315+
result,
316+
'unordered'
317+
);
291318
expect(arraysMatch).toBeTruthy();
292319
```
293320

@@ -306,15 +333,20 @@ for how to create a local deployment.
306333
### Create a .env file
307334

308335
Create a file named `.env` at the root of the `/javascript/driver` directory.
309-
Add your connection string as an environment value named `CONNECTION_STRING`:
336+
Add the following environment variables:
310337

311338
```
312339
CONNECTION_STRING="<your-connection-string>"
340+
TZ=UTC
313341
```
314342

315343
Replace the `<your-connection-string>` placeholder with the connection
316344
string from the Atlas cluster or local deployment you created in the prior step.
317345

346+
The `TZ` variable sets the Node.js environment to use the UTC time zone. This
347+
is required to enforce time zone consistency between dates across different
348+
local environments and CI when running the test suite.
349+
318350
### Run All Tests from the command line
319351

320352
From the `/javascript/driver` directory, run:

code-example-tests/javascript/driver/tests/aggregation/pipelines/tutorials.test.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { runUnwindTutorial } from '../../../examples/aggregation/pipelines/unwin
1010
import { runJoinOneToOneTutorial } from '../../../examples/aggregation/pipelines/join-one-to-one/tutorial.js';
1111
import { runJoinMultiFieldTutorial } from '../../../examples/aggregation/pipelines/join-multi-field/tutorial.js';
1212
import { MongoClient } from 'mongodb';
13-
import unorderedArrayOutputMatches from '../../../utils/outputMatchesExampleOutput.js';
13+
import outputMatchesExampleOutput from '../../../utils/outputMatchesExampleOutput.js';
1414

1515
describe('Aggregation pipeline filter tutorial tests', () => {
1616
afterEach(async () => {
@@ -31,23 +31,35 @@ describe('Aggregation pipeline filter tutorial tests', () => {
3131
await loadFilterSampleData();
3232
const result = await runFilterTutorial();
3333
const outputFilepath = 'aggregation/pipelines/filter/tutorial-output.sh';
34-
const arraysMatch = unorderedArrayOutputMatches(outputFilepath, result);
34+
const arraysMatch = outputMatchesExampleOutput(
35+
outputFilepath,
36+
result,
37+
'unordered'
38+
);
3539
expect(arraysMatch).toBe(true);
3640
});
3741

3842
it('Should return grouped output that includes the three specified customer order records', async () => {
3943
await loadGroupSampleData();
4044
const result = await runGroupTutorial();
4145
const outputFilepath = 'aggregation/pipelines/group/tutorial-output.sh';
42-
const arraysMatch = unorderedArrayOutputMatches(outputFilepath, result);
46+
const arraysMatch = outputMatchesExampleOutput(
47+
outputFilepath,
48+
result,
49+
'unordered'
50+
);
4351
expect(arraysMatch).toBe(true);
4452
});
4553

4654
it('Should return unpacked output grouped by product name', async () => {
4755
await loadUnwindSampleData();
4856
const result = await runUnwindTutorial();
4957
const outputFilepath = 'aggregation/pipelines/unwind/tutorial-output.sh';
50-
const arraysMatch = unorderedArrayOutputMatches(outputFilepath, result);
58+
const arraysMatch = outputMatchesExampleOutput(
59+
outputFilepath,
60+
result,
61+
'unordered'
62+
);
5163
expect(arraysMatch).toBe(true);
5264
});
5365

@@ -56,7 +68,11 @@ describe('Aggregation pipeline filter tutorial tests', () => {
5668
const result = await runJoinOneToOneTutorial();
5769
const outputFilepath =
5870
'aggregation/pipelines/join-one-to-one/tutorial-output.sh';
59-
const arraysMatch = unorderedArrayOutputMatches(outputFilepath, result);
71+
const arraysMatch = outputMatchesExampleOutput(
72+
outputFilepath,
73+
result,
74+
'unordered'
75+
);
6076
expect(arraysMatch).toBe(true);
6177
});
6278

@@ -65,7 +81,11 @@ describe('Aggregation pipeline filter tutorial tests', () => {
6581
const result = await runJoinMultiFieldTutorial();
6682
const outputFilepath =
6783
'aggregation/pipelines/join-multi-field/tutorial-output.sh';
68-
const arraysMatch = unorderedArrayOutputMatches(outputFilepath, result);
84+
const arraysMatch = outputMatchesExampleOutput(
85+
outputFilepath,
86+
result,
87+
'unordered'
88+
);
6989
expect(arraysMatch).toBe(true);
7090
});
7191
});

code-example-tests/javascript/driver/utils/outputMatchesExampleOutput.js

Lines changed: 143 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,75 @@ const fs = require('fs');
33
const vm = require('vm');
44
const { Decimal128, ObjectId } = require('mongodb'); // Import MongoDB data types
55

6-
function outputMatchesExampleOutput(filepath, output) {
7-
// Read the content of the expected output
6+
/**
7+
* Compares the actual output from a MongoDB operation against the expected output stored in a file.
8+
* Supports both ordered and unordered array comparisons, and performs deep comparisons of nested objects.
9+
* MongoDB-specific types such as Decimal128, ObjectId, and Date are normalized for consistent comparisons.
10+
*
11+
* @param {string} filepath - The relative path to the expected output file. The file must contain valid JSON.
12+
* @param {Array<Object>} output - The actual output generated by the MongoDB operation, expected to be an array of objects.
13+
* @param {string} [comparisonType='unordered'] - The type of comparison for arrays:
14+
* - 'unordered': Arrays are treated as unordered collections, comparing elements regardless of order.
15+
* - 'ordered': Arrays are compared in a strict order, with elements matched positionally.
16+
*
17+
* @returns {boolean} True if the actual output matches the expected output, false otherwise.
18+
* - Logs detailed mismatch information in the console if the comparison fails.
19+
* - Returns false if one or both arrays are undefined.
20+
*
21+
* @throws {Error} If `comparisonType` is neither 'ordered' nor 'unordered'.
22+
*
23+
* @example
24+
* // Example: Unordered comparison (default)
25+
* const matches = outputMatchesExampleOutput('examples/tutorial-output.json', actualOutput);
26+
* console.log(matches); // true if arrays match regardless of order
27+
*
28+
* @example
29+
* // Example: Ordered comparison
30+
* const matches = outputMatchesExampleOutput(
31+
* 'examples/tutorial-output.json',
32+
* actualOutput,
33+
* 'ordered'
34+
* );
35+
* console.log(matches); // true if arrays match exactly with order preserved
36+
*
37+
* @description
38+
* The function reads the expected output from the specified file, preprocesses for formatting
39+
* issues (e.g., missing arrays, single-quoted strings), and evaluates it safely in a sandbox environment.
40+
* Nested fields, including arrays and objects, are recursively normalized for deep comparisons.
41+
* The function logs mismatched outputs for debugging purposes if the comparison fails.
42+
*/
43+
function outputMatchesExampleOutput(
44+
filepath,
45+
output,
46+
comparisonType = 'unordered'
47+
) {
48+
// Accept 'ordered' or 'unordered'
849
const filepathString = '../examples/' + filepath;
950
const outputFilePath = path.resolve(__dirname, filepathString);
1051
const rawExpectedOutput = fs.readFileSync(outputFilePath, 'utf8');
1152

1253
const preprocessFileContents = (contents) => {
13-
// Detect multiple objects and wrap them into an array
1454
const wrappedContents = contents
1555
.trim()
16-
.replace(/}\n{/g, '},\n{') // Add commas between objects if they are concatenated
17-
.replace(/'(.*?)'/g, '"$1"') // Convert single-quoted values to double quotes
18-
.replace(/^\{/g, '[{') // Wrap first object in an array if no array starts
19-
.replace(/}$/g, '}]'); // Wrap last object in an array if no array ends
56+
.replace(/}\n{/g, '},\n{') // Add commas between concatenated objects
57+
.replace(/'(.*?)'/g, '"$1"') // Convert single quotes to double quotes
58+
.replace(/^\{/g, '[{') // Wrap first object in an array if missing
59+
.replace(/}$/g, '}]'); // Wrap last object in an array if missing
2060

21-
// Ensure keys are quoted properly
2261
const processed = wrappedContents.replace(
23-
/(\b[a-zA-Z_]\w*)\s*:/g, // Match alphanumeric keys (letters, optional underscores) followed by a colon
24-
'"$1":' // Wrap the key in double quotes without touching the colon
62+
/(\b[a-zA-Z_]\w*)\s*:/g,
63+
'"$1":' // Wrap keys in double quotes
2564
);
2665

27-
// Quote any unquoted ISO-like date values
2866
const finalProcessed = processed.replace(
29-
/:\s*([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.?[0-9]*Z?)/g, // Match ISO-like datetime strings
30-
(match, dateValue) => `: "${dateValue.trim()}"` // Wrap the value in double quotes
67+
/:\s*([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.?[0-9]*Z?)/g,
68+
(match, dateValue) => {
69+
const normalizedDate = new Date(dateValue).toISOString(); // Normalize date to UTC
70+
return `: "${normalizedDate}"`;
71+
}
3172
);
3273

33-
return finalProcessed; // Return final sanitized string
74+
return finalProcessed;
3475
};
3576

3677
const processedExpectedOutput = preprocessFileContents(rawExpectedOutput);
@@ -43,51 +84,119 @@ function outputMatchesExampleOutput(filepath, output) {
4384

4485
let expectedOutputArray;
4586
try {
46-
expectedOutputArray = vm.runInNewContext(processedExpectedOutput, context); // Safely parse expected output
87+
expectedOutputArray = vm.runInNewContext(processedExpectedOutput, context);
4788
} catch (error) {
4889
console.error('Failed to parse expected output:', error);
4990
return false;
5091
}
5192

52-
// Directly use the `output` as it is already expected to be a valid array of objects
53-
const actualOutputArray = output;
54-
55-
// Helper function to normalize MongoDB data types for comparison
5693
const normalizeItem = (item) => {
5794
const normalized = {};
5895
for (const key in item) {
59-
if (item[key] instanceof Decimal128 || item[key] instanceof ObjectId) {
60-
normalized[key] = item[key].toString(); // Convert Decimal128 and ObjectId to strings
61-
} else if (item[key] instanceof Date) {
62-
normalized[key] = item[key].toISOString(); // Convert dates to ISO8601 strings for consistent comparison
96+
const value = item[key];
97+
98+
if (value instanceof Decimal128 || value instanceof ObjectId) {
99+
normalized[key] = value.toString();
100+
} else if (value instanceof Date) {
101+
normalized[key] = value.toISOString();
102+
} else if (
103+
typeof value === 'string' &&
104+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.?[0-9]*Z?$/.test(value)
105+
) {
106+
// Parse and re-normalize string-formatted dates to UTC
107+
const parsedDate = new Date(value);
108+
normalized[key] = parsedDate.toISOString();
109+
} else if (Array.isArray(value)) {
110+
normalized[key] = value.map((element) =>
111+
typeof element === 'object' && element !== null
112+
? normalizeItem(element)
113+
: element
114+
);
115+
} else if (typeof value === 'object' && value !== null) {
116+
normalized[key] = normalizeItem(value);
63117
} else {
64-
normalized[key] = item[key]; // Keep other values as-is
118+
normalized[key] = value;
65119
}
66120
}
67121
return normalized;
68122
};
69123

70-
if (actualOutputArray !== undefined && expectedOutputArray !== undefined) {
71-
// Check that both arrays contain the same elements, regardless of order
124+
const areObjectsEqual = (obj1, obj2, comparisonType) => {
125+
if (
126+
typeof obj1 !== 'object' ||
127+
obj1 === null ||
128+
typeof obj2 !== 'object' ||
129+
obj2 === null
130+
) {
131+
return obj1 === obj2; // Direct comparison for non-object values
132+
}
133+
134+
if (Array.isArray(obj1) && Array.isArray(obj2)) {
135+
if (comparisonType === 'unordered') {
136+
const normalizedArray1 = obj1.map(normalizeItem);
137+
const normalizedArray2 = obj2.map(normalizeItem);
138+
139+
return normalizedArray1.every((item1) =>
140+
normalizedArray2.some((item2) =>
141+
areObjectsEqual(item1, item2, comparisonType)
142+
)
143+
);
144+
} else if (comparisonType === 'ordered') {
145+
return obj1.every((element, index) =>
146+
areObjectsEqual(element, obj2[index], comparisonType)
147+
);
148+
} else {
149+
throw new Error(
150+
`Invalid comparisonType: ${comparisonType}. Use "ordered" or "unordered".`
151+
);
152+
}
153+
}
154+
155+
const keys1 = Object.keys(obj1).sort();
156+
const keys2 = Object.keys(obj2).sort();
157+
158+
if (keys1.length !== keys2.length) {
159+
return false; // Different number of keys
160+
}
161+
162+
for (const key of keys1) {
163+
if (!keys2.includes(key)) {
164+
return false; // Mismatched keys
165+
}
166+
const val1 = obj1[key];
167+
const val2 = obj2[key];
168+
169+
if (!areObjectsEqual(val1, val2, comparisonType)) {
170+
return false; // Mismatched values
171+
}
172+
}
173+
174+
return true;
175+
};
176+
177+
if (Array.isArray(output) && Array.isArray(expectedOutputArray)) {
72178
const isEqual =
73-
actualOutputArray.length === expectedOutputArray.length &&
179+
output.length === expectedOutputArray.length &&
74180
expectedOutputArray.every((expectedItem) =>
75-
actualOutputArray.some(
76-
(actualItem) =>
77-
JSON.stringify(normalizeItem(actualItem)) ===
78-
JSON.stringify(normalizeItem(expectedItem))
181+
output.some((actualItem) =>
182+
areObjectsEqual(
183+
normalizeItem(actualItem),
184+
normalizeItem(expectedItem),
185+
comparisonType
186+
)
79187
)
80188
);
189+
81190
if (!isEqual) {
82191
console.log('Mismatch between actual output and expected output:', {
83-
actualOutputArray,
84-
expectedOutputArray,
192+
actualOutputArray: output.map(normalizeItem),
193+
expectedOutputArray: expectedOutputArray.map(normalizeItem),
85194
});
86195
}
87196

88197
return isEqual;
89198
} else {
90-
console.log('One or both arrays is undefined.');
199+
console.error('One or both arrays is undefined.');
91200
return false;
92201
}
93202
}

0 commit comments

Comments
 (0)