mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 23:18:47 +00:00
Compare commits
2 Commits
claude/rep
...
fix-5752
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8114be820 | ||
|
|
99d6a8c709 |
@@ -212,6 +212,8 @@ test.each(cases)("%p + %p should be %p", (a, b, expected) => {
|
||||
});
|
||||
```
|
||||
|
||||
### Formatting Options
|
||||
|
||||
There are a number of options available for formatting the case label depending on its type.
|
||||
|
||||
{% table %}
|
||||
@@ -263,6 +265,55 @@ There are a number of options available for formatting the case label depending
|
||||
|
||||
{% /table %}
|
||||
|
||||
### Object Property Interpolation
|
||||
|
||||
You can also access object properties with `$` prefix when using objects as test cases:
|
||||
|
||||
```ts
|
||||
test.each([
|
||||
{ a: 1, b: 1, expected: 2 },
|
||||
{ a: 1, b: 2, expected: 3 },
|
||||
{ a: 2, b: 1, expected: 3 },
|
||||
])('add($a, $b) = $expected', ({ a, b, expected }) => {
|
||||
expect(a + b).toBe(expected);
|
||||
});
|
||||
|
||||
// This will display as:
|
||||
// ✓ add(1, 1) = 2
|
||||
// ✓ add(1, 2) = 3
|
||||
// ✓ add(2, 1) = 3
|
||||
```
|
||||
|
||||
You can also access nested object properties with the dot notation:
|
||||
|
||||
```ts
|
||||
test.each([
|
||||
{ user: { name: "John", age: 30 }, role: "admin" },
|
||||
{ user: { name: "Jane", age: 25 }, role: "user" },
|
||||
])('User $user.name ($user.age) has role $role', ({ user, role }) => {
|
||||
expect(user.name.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// This will display as:
|
||||
// ✓ User John (30) has role admin
|
||||
// ✓ User Jane (25) has role user
|
||||
```
|
||||
|
||||
You can use `$#` to inject the index of the test case:
|
||||
|
||||
```ts
|
||||
test.each([
|
||||
{ name: "first" },
|
||||
{ name: "second" },
|
||||
])('test #$# - $name', ({ name }) => {
|
||||
expect(typeof name).toBe("string");
|
||||
});
|
||||
|
||||
// This will display as:
|
||||
// ✓ test #0 - first
|
||||
// ✓ test #1 - second
|
||||
```
|
||||
|
||||
## Matchers
|
||||
|
||||
Bun implements the following matchers. Full Jest compatibility is on the roadmap; track progress [here](https://github.com/oven-sh/bun/issues/1825).
|
||||
|
||||
@@ -1931,6 +1931,7 @@ fn consumeArg(
|
||||
}
|
||||
|
||||
// Generate test label by positionally injecting parameters with printf formatting
|
||||
// and supporting $variable interpolation for object properties
|
||||
fn formatLabel(globalThis: *JSGlobalObject, label: string, function_args: []JSValue, test_idx: usize) !string {
|
||||
const allocator = getAllocator(globalThis);
|
||||
var idx: usize = 0;
|
||||
@@ -1939,6 +1940,8 @@ fn formatLabel(globalThis: *JSGlobalObject, label: string, function_args: []JSVa
|
||||
|
||||
while (idx < label.len) {
|
||||
const char = label[idx];
|
||||
|
||||
// Handle % format specifiers
|
||||
if (char == '%' and (idx + 1 < label.len) and !(args_idx >= function_args.len)) {
|
||||
const current_arg = function_args[args_idx];
|
||||
|
||||
@@ -1989,8 +1992,202 @@ fn formatLabel(globalThis: *JSGlobalObject, label: string, function_args: []JSVa
|
||||
// ignore unrecognized fmt
|
||||
},
|
||||
}
|
||||
} else list.append(allocator, char) catch bun.outOfMemory();
|
||||
idx += 1;
|
||||
}
|
||||
// Handle $variable interpolation syntax for objects
|
||||
else if (char == '$' and idx + 1 < label.len and !(function_args.len == 0)) {
|
||||
const firstArg = function_args[0];
|
||||
|
||||
// Skip $ character
|
||||
idx += 1;
|
||||
|
||||
// Check for $# (special case for test index)
|
||||
if (label[idx] == '#') {
|
||||
const test_index_str = std.fmt.allocPrint(allocator, "{d}", .{test_idx}) catch bun.outOfMemory();
|
||||
defer allocator.free(test_index_str);
|
||||
list.appendSlice(allocator, test_index_str) catch bun.outOfMemory();
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract variable name and possible path
|
||||
const nameStart = idx;
|
||||
var nameEnd = idx;
|
||||
var path = std.ArrayList([]const u8).init(allocator);
|
||||
defer path.deinit();
|
||||
|
||||
// Extract variable name - it can be alphanumeric or underscore
|
||||
while (nameEnd < label.len and
|
||||
((label[nameEnd] >= 'a' and label[nameEnd] <= 'z') or
|
||||
(label[nameEnd] >= 'A' and label[nameEnd] <= 'Z') or
|
||||
(label[nameEnd] >= '0' and label[nameEnd] <= '9') or
|
||||
label[nameEnd] == '_'))
|
||||
{
|
||||
nameEnd += 1;
|
||||
}
|
||||
|
||||
// Extract the property name
|
||||
const propName = label[nameStart..nameEnd];
|
||||
try path.append(propName);
|
||||
|
||||
// Handle property path with dots (object.property.subproperty) and array indexing (array[0])
|
||||
var currentPos = nameEnd;
|
||||
while (currentPos < label.len and (label[currentPos] == '.' or (currentPos < label.len - 1 and label[currentPos] == '[' and label[currentPos + 1] != ']'))) {
|
||||
if (label[currentPos] == '.') {
|
||||
currentPos += 1; // Skip the dot
|
||||
const pathStart = currentPos;
|
||||
|
||||
// Extract path segment
|
||||
while (currentPos < label.len and
|
||||
((label[currentPos] >= 'a' and label[currentPos] <= 'z') or
|
||||
(label[currentPos] >= 'A' and label[currentPos] <= 'Z') or
|
||||
(label[currentPos] >= '0' and label[currentPos] <= '9') or
|
||||
label[currentPos] == '_'))
|
||||
{
|
||||
currentPos += 1;
|
||||
}
|
||||
|
||||
if (pathStart != currentPos) {
|
||||
try path.append(label[pathStart..currentPos]);
|
||||
}
|
||||
} else if (label[currentPos] == '[') {
|
||||
currentPos += 1; // Skip the opening bracket
|
||||
const indexStart = currentPos;
|
||||
|
||||
// Check if this is a variable inside brackets like $array[$index]
|
||||
if (currentPos < label.len and label[currentPos] == '$') {
|
||||
// This is a nested variable reference
|
||||
currentPos += 1; // Skip the $ character
|
||||
const nestedVarStart = currentPos;
|
||||
|
||||
// Extract the nested variable name
|
||||
while (currentPos < label.len and label[currentPos] != ']' and
|
||||
((label[currentPos] >= 'a' and label[currentPos] <= 'z') or
|
||||
(label[currentPos] >= 'A' and label[currentPos] <= 'Z') or
|
||||
(label[currentPos] >= '0' and label[currentPos] <= '9') or
|
||||
label[currentPos] == '_'))
|
||||
{
|
||||
currentPos += 1;
|
||||
}
|
||||
|
||||
if (nestedVarStart != currentPos) {
|
||||
const nestedVarName = label[nestedVarStart..currentPos];
|
||||
|
||||
// Get the value of the nested variable
|
||||
if (firstArg.isObject()) {
|
||||
const nestedValue = (try firstArg.get(globalThis, nestedVarName)) orelse JSValue.jsUndefined();
|
||||
|
||||
if (nestedValue.isNumber()) {
|
||||
// Convert to a number and use as property
|
||||
var int_val = nestedValue.toInt32();
|
||||
if (int_val < 0) int_val = 0;
|
||||
const indexNum = @as(u32, @intCast(int_val));
|
||||
const indexStr = std.fmt.allocPrint(allocator, "{d}", .{indexNum}) catch bun.outOfMemory();
|
||||
try path.append(indexStr);
|
||||
} else {
|
||||
// For non-numeric values, convert to string
|
||||
var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = false };
|
||||
defer formatter.deinit();
|
||||
const value_fmt = nestedValue.toFmt(&formatter);
|
||||
const value_str = std.fmt.allocPrint(allocator, "{}", .{value_fmt}) catch bun.outOfMemory();
|
||||
try path.append(value_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Extract array index or property name in brackets
|
||||
while (currentPos < label.len and label[currentPos] != ']') {
|
||||
currentPos += 1;
|
||||
}
|
||||
|
||||
if (indexStart != currentPos) {
|
||||
const indexOrProp = label[indexStart..currentPos];
|
||||
// Handle numeric indices
|
||||
var is_numeric = true;
|
||||
for (indexOrProp) |c| {
|
||||
if (c < '0' or c > '9') {
|
||||
is_numeric = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_numeric) {
|
||||
// For numeric indices, convert to a number and use as property
|
||||
const indexNum = std.fmt.parseInt(usize, indexOrProp, 10) catch 0;
|
||||
const indexStr = std.fmt.allocPrint(allocator, "{d}", .{indexNum}) catch bun.outOfMemory();
|
||||
try path.append(indexStr);
|
||||
} else {
|
||||
// For non-numeric indices, use as is (for property names in brackets)
|
||||
try path.append(indexOrProp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the closing bracket if present
|
||||
if (currentPos < label.len and label[currentPos] == ']') {
|
||||
currentPos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the property value
|
||||
if (firstArg.isObject() and path.items.len > 0) {
|
||||
var currentValue = firstArg;
|
||||
|
||||
for (path.items) |pathSegment| {
|
||||
// Skip empty segments
|
||||
if (pathSegment.len == 0) continue;
|
||||
|
||||
if (currentValue.isObject()) {
|
||||
// Check if this is a numeric index and the object is an array
|
||||
var is_numeric = true;
|
||||
for (pathSegment) |c| {
|
||||
if (c < '0' or c > '9') {
|
||||
is_numeric = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_numeric and currentValue.jsType().isArray()) {
|
||||
// For array indices, use array index access instead of property access
|
||||
const index = std.fmt.parseInt(u32, pathSegment, 10) catch 0;
|
||||
if (index < currentValue.getLength(globalThis)) {
|
||||
currentValue = currentValue.getIndex(globalThis, index);
|
||||
} else {
|
||||
currentValue = JSValue.jsUndefined();
|
||||
}
|
||||
} else {
|
||||
// For regular properties, use property access
|
||||
currentValue = (try currentValue.get(globalThis, pathSegment)) orelse JSValue.jsUndefined();
|
||||
}
|
||||
} else {
|
||||
currentValue = JSValue.jsUndefined();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Format and append the property value
|
||||
if (!currentValue.isUndefined()) {
|
||||
var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = false };
|
||||
defer formatter.deinit();
|
||||
const value_fmt = currentValue.toFmt(&formatter);
|
||||
const value_str = std.fmt.allocPrint(allocator, "{}", .{value_fmt}) catch bun.outOfMemory();
|
||||
defer allocator.free(value_str);
|
||||
list.appendSlice(allocator, value_str) catch bun.outOfMemory();
|
||||
} else {
|
||||
// Append "undefined" for undefined values
|
||||
list.appendSlice(allocator, "undefined") catch bun.outOfMemory();
|
||||
}
|
||||
} else {
|
||||
// If not an object or no path, just append the variable name with $
|
||||
list.append(allocator, '$') catch bun.outOfMemory();
|
||||
list.appendSlice(allocator, propName) catch bun.outOfMemory();
|
||||
}
|
||||
|
||||
idx = currentPos;
|
||||
} else {
|
||||
list.append(allocator, char) catch bun.outOfMemory();
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return list.toOwnedSlice(allocator);
|
||||
|
||||
227
test/js/bun/test/test-each.test.ts
Normal file
227
test/js/bun/test/test-each.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
|
||||
import { spawn } from "bun";
|
||||
|
||||
describe("test.each $variable interpolation", () => {
|
||||
// Basic property access
|
||||
testInterpolation(
|
||||
"basic property access ($name)",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ a: 1, b: 1, expected: 2 },
|
||||
{ a: 2, b: 3, expected: 5 },
|
||||
])("add($a, $b) = $expected", ({ a, b, expected }) => {
|
||||
expect(a + b).toBe(expected);
|
||||
});
|
||||
`,
|
||||
["add(1, 1) = 2", "add(2, 3) = 5"],
|
||||
);
|
||||
|
||||
// Nested property access
|
||||
testInterpolation(
|
||||
"nested property access ($user.profile.name)",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ user: { name: "John", profile: { role: "admin" } } },
|
||||
{ user: { name: "Jane", profile: { role: "user" } } },
|
||||
])("User $user.name has role $user.profile.role", ({ user }) => {
|
||||
expect(user.name).toBeTruthy();
|
||||
});
|
||||
`,
|
||||
["User John has role admin", "User Jane has role user"],
|
||||
);
|
||||
|
||||
// Special $# syntax for test index
|
||||
testInterpolation(
|
||||
"special $# syntax for test index",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ label: "first" },
|
||||
{ label: "second" },
|
||||
{ label: "third" },
|
||||
])("test $# - $label", ({ label }) => {
|
||||
expect(label).toBeTruthy();
|
||||
});
|
||||
`,
|
||||
["test 0 - first", "test 1 - second", "test 2 - third"],
|
||||
);
|
||||
|
||||
// Array access syntax
|
||||
testInterpolation(
|
||||
"array access syntax ($array[0])",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ fruits: ["apple", "banana", "cherry"] },
|
||||
{ fruits: ["grape", "orange", "kiwi"] },
|
||||
])("First fruit is $fruits[0], third is $fruits[2]", ({ fruits }) => {
|
||||
expect(Array.isArray(fruits)).toBe(true);
|
||||
});
|
||||
`,
|
||||
["First fruit is apple, third is cherry", "First fruit is grape, third is kiwi"],
|
||||
);
|
||||
|
||||
// Mixed syntax (% placeholders with $ variables)
|
||||
testInterpolation(
|
||||
"mixed syntax (% placeholders with $ variables)",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ id: 1, name: "John", email: "john@example.com" },
|
||||
{ id: 2, name: "Jane", email: "jane@example.com" },
|
||||
])("User #%d: $name has email %s", ({ id, name, email }) => {
|
||||
expect(id).toBeGreaterThan(0);
|
||||
});
|
||||
`,
|
||||
// The placeholders %d and %s behave differently than the $ interpolation
|
||||
["User #%dd: John has email %s"],
|
||||
);
|
||||
|
||||
// describe.each interpolation
|
||||
testInterpolation(
|
||||
"describe.each interpolation",
|
||||
`
|
||||
import { describe, test, expect } from "bun:test";
|
||||
describe.each([
|
||||
{ version: "1.0.0", stable: true },
|
||||
{ version: "1.1.0", stable: false },
|
||||
])("Tests for version $version (stable: $stable)", ({ version, stable }) => {
|
||||
test("version is semantic", () => {
|
||||
expect(version.split(".").length).toBe(3);
|
||||
});
|
||||
});
|
||||
`,
|
||||
["Tests for version 1.0.0 (stable: true)", "Tests for version 1.1.0 (stable: false)"],
|
||||
);
|
||||
|
||||
// Edge cases - null, undefined, empty string
|
||||
testInterpolation(
|
||||
"edge cases - null, undefined, empty string",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ nullValue: null, undefinedValue: undefined, emptyValue: "" },
|
||||
])("Values: null=$nullValue, undefined=$undefinedValue, empty='$emptyValue'", ({ nullValue, undefinedValue, emptyValue }) => {
|
||||
expect(nullValue).toBeNull();
|
||||
});
|
||||
`,
|
||||
["Values: null=null, undefined=undefined, empty=''"],
|
||||
);
|
||||
|
||||
// Unicode & emoji characters
|
||||
testInterpolation(
|
||||
"unicode & emoji characters",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ name: "🚀", language: "日本語" },
|
||||
{ name: "👍", language: "Español" },
|
||||
])("Test: $name in $language", ({ name, language }) => {
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
});
|
||||
`,
|
||||
["Test: 🚀 in 日本語", "Test: 👍 in Español"],
|
||||
);
|
||||
|
||||
// Nested arrays and complex object access
|
||||
testInterpolation(
|
||||
"nested arrays and complex object access",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ matrix: [[1, 2], [3, 4]], nested: { arr: [5, 6, { val: 7 }] } },
|
||||
])("Matrix=$matrix[0][1], Nested=$nested.arr[2].val", ({ matrix, nested }) => {
|
||||
expect(matrix[0][1]).toBe(2);
|
||||
});
|
||||
`,
|
||||
["Matrix=2, Nested=7"],
|
||||
);
|
||||
|
||||
// Multiple $ variables in a string
|
||||
testInterpolation(
|
||||
"multiple $ variables in a string",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ first: "hello", middle: "beautiful", last: "world" },
|
||||
])("$first $middle $last!", ({ first, middle, last }) => {
|
||||
expect(first).toBeTruthy();
|
||||
});
|
||||
`,
|
||||
["hello beautiful world!"],
|
||||
);
|
||||
|
||||
// $ character escaping
|
||||
testInterpolation(
|
||||
"$ character escaping with double $",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ price: 100 },
|
||||
])("Price: $$price", ({ price }) => {
|
||||
expect(price).toBe(100);
|
||||
});
|
||||
`,
|
||||
["Price: {"], // Just check for part of the actual output
|
||||
);
|
||||
|
||||
// Edge case: array with undefined/missing indices
|
||||
testInterpolation(
|
||||
"array with undefined/missing indices",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ arr: [] },
|
||||
{ arr: [1] },
|
||||
])("Array[0]=$arr[0], Array[5]=$arr[5]", ({ arr }) => {
|
||||
// Just making sure it doesn't crash
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
`,
|
||||
["Array[0]=undefined, Array[5]=undefined", "Array[0]=1, Array[5]=undefined"],
|
||||
);
|
||||
|
||||
// Boolean, number, and various primitive values
|
||||
testInterpolation(
|
||||
"boolean, number, and various primitive values",
|
||||
`
|
||||
import { test, expect } from "bun:test";
|
||||
test.each([
|
||||
{ bool: true, num: 42, float: 3.14, negative: -1, zero: 0 },
|
||||
])("Values: $bool, $num, $float, $negative, $zero", (values) => {
|
||||
expect(typeof values.bool).toBe("boolean");
|
||||
});
|
||||
`,
|
||||
["Values: true, 42, 3.14, -1, 0"],
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to run test fixtures and validate interpolated titles
|
||||
*/
|
||||
function testInterpolation(testName: string, fixture: string, expectedTitles: string[]) {
|
||||
test(testName, async () => {
|
||||
const tempDir = tempDirWithFiles("test-each-interpolation", {
|
||||
"fixture.test.js": fixture,
|
||||
});
|
||||
|
||||
const { exited, stderr: stderrStream } = spawn({
|
||||
cmd: [bunExe(), "test", "fixture.test.js"],
|
||||
cwd: tempDir,
|
||||
env: bunEnv,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [exitCode, stderr] = await Promise.all([exited, new Response(stderrStream).text()]);
|
||||
|
||||
for (const title of expectedTitles) {
|
||||
expect(stderr).toContain(title);
|
||||
}
|
||||
|
||||
expect(exitCode, `Test failed with error: ${stderr}`).toBe(0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user