Compare commits

...

2 Commits

Author SHA1 Message Date
Electroid
d8114be820 bun run zig-format 2025-03-13 22:25:28 +00:00
Ashcon Partovi
99d6a8c709 Support ` in test.each()` 2025-03-13 15:22:59 -07:00
3 changed files with 477 additions and 2 deletions

View File

@@ -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).

View File

@@ -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);

View 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);
});
}