Skip to content

bun:test: support the tagged-template table form of .each - #44402

Open
robobun wants to merge 7 commits into
mainfrom
robobun/39d30efb/test-each-tagged-template
Open

robobun wants to merge 7 commits into
mainfrom
robobun/39d30efb/test-each-tagged-template

Conversation

@robobun

@robobun robobun commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #6364

Problem

  • A backtick table on test.each or describe.each runs one test per string piece of the template. The callbacks get no row. A table that must fail prints 5 pass, exit 0.
  • fn_each (src/runtime/test_runner/ScopeFunctions.rs:120) read its first argument only. A tagged template calls each(strings, ...values), so the strings became the table.

Fix

  • fn_each builds one object per row from the template strings and values. The title call registers those rows as a table of objects.
  • Headings come from strings[0], one row of names. A malformed table throws at the title call, with jest-each's message.
  • Verified: test/js/bun/test/jest-each.test.ts and jest-each-gc-root.test.ts (stock bun fails both). Also bun-test.test.ts -t each, bun-types, clippy.
  • Self-reviewed: 9 concerns raised, 7 addressed. Not taken: the $name fix for null cells (bun:test: format test.each titles with Jest's placeholder semantics #41565) and template mode for arrays with extra arguments.

Background

Downsides

  • A malformed table stops the collection of its file or describe block. Jest runs the rest.
  • Unlike jest-each, an empty column name or a two-line heading row is an error. A $name for a null cell stays as text until bun:test: format test.each titles with Jest's placeholder semantics #41565.
  • Title call on a one-row array table: +90 instructions. With a string or undefined row: +729 and 1 malloc. Binary: +6,400 bytes.
Notes

Repro

import { test, expect } from "bun:test";
const price = { apple: 3, pear: 4 };
test.each`
  fruit      | expected
  ${"apple"} | ${999}
  ${"pear"}  | ${999}
`("price of $fruit is $expected", ({ fruit, expected }) => {
  expect(price[fruit]).toBe(expected); // both rows must fail
});
  • main and bun 1.4.2: 5 tests named price of $fruit is $expected, 5 pass, exit 0.
  • This PR: (fail) price of apple is 999, (fail) price of pear is 999, exit 1.
  • Jest 30.5.2 and Vitest 3.2.7: 2 tests, both fail.

What is in the diff

  • src/runtime/test_runner/ScopeFunctions.rs: the change. It uses strings::is_js_whitespace from bun_core (Add bun lint and bun format #44805).
  • packages/bun-types/test.d.ts: one template overload on each each set. The row type defaults to any, so a callback can annotate its row parameter. test.each<Row> types it too.
  • A template with no ${value} type-checks as a table of strings, because the array overloads take readonly T[] since Fix every type error in test/ #44614. The title call throws for it.
  • docs/test/writing-tests.mdx: a new section.

Designs weighed

  • A port of bun:test: support tagged-template table form of test.each/describe.each #34974 as written. It looks up an own raw on every .each(array): about +660 instructions and 1 malloc for each call. A template with headings and no values became Ran 0 tests, exit 0.
  • A throw from .each(). It fires for a result that never gets a title. Jest and Vitest stay silent there.
  • One failing test for a malformed table, as Jest does. It keeps the rest of the file running. But it exits 0 under .skip, .todo, .failing and -t, so a malformed table can hide. It also needs one more host function.
  • New row objects for each title call, as Jest does. It needs the strings and the values kept on the wrapper, and a parse for each title call. It adds 2 GC cells for each template .each. No .each call site in test/ (1,598 of them) can see the difference.
  • A matcher that gives the result of jest-each's heading regex for every string. The first version of this PR had one. It has to reproduce the backtracking of the regex, and the shapes it adds are the two that are errors now.

Heading row

jest-each reads the headings with /^\n\s*[^\s]+\s*(\|\s*[^\s]+\s*)*\n/, then removes the whitespace and splits on |. template_headings reads one row: a newline, names with | between them, a newline.

  • For every table that it accepts, the regex gives the same headings. A JavaScript model of it agrees on 12,131,170 strings: every string up to 11 characters of newline, space, a and |, and two more alphabets with tab, carriage return, no-break space, U+3000 and non-ASCII letters.
  • It rejects three shapes that the regex accepts: an empty name (a||b), a row that goes on after a newline (a | then b), and a | with a name at the start of the next line. They get the same error as any other malformed heading row.
  • A value row can start with |, as in | ${1} | ${2} |. Only a | with a name after it counts as more headings.
  • The native code agrees with the regex, less those three shapes, on 199,026 strings in a one-off run. The committed test checks 362 strings the same way.

Differences from Jest that stay

  • A malformed table throws at the title call. Jest registers one failing test.
  • Two title calls on one .each result get the same row objects. jest-each builds new ones. Vitest and bun's array tables share them too.
  • Title formatting is format_label, the same as for a table of objects. $name with a null or undefined value and $# are in bun:test: format test.each titles with Jest's placeholder semantics #41565. A non-ASCII $name prints broken bytes today: the owner of bun:test: format test.each titles with Jest's placeholder semantics #41565 has the report. Jest leaves the % specifiers literal for a template table. bun applies them to the row object, so %j throws for a row that JSON cannot serialize.
  • this in the callback is the row, as for array tables.
  • Array tables keep their differences from Jest: test.each([]) registers nothing, test.each(123) throws from .each(), and an array with more arguments after it is a table.
  • A strings array with no own raw and values after it, and a strings array of several strings with no values, stay array tables.
  • The test discovery of the VS Code extension matches .each( only.

Measurements

Taken at 9bbaa04, before the merge with main. The code on the measured paths did not change after that. template_headings changed by one condition, and it runs only for a template table.

Two release builds of the same tree, with src/ of the merge base and of this PR (x64 Linux). Instruction counts are gdb stepi over one call, inclusive, for 5 consecutive calls after a warm-up. calls are call instructions. mallocs and frees are entries into the mimalloc entry points.

Call base PR delta
.each(array): [[1,2,3]], ["apple"], [{a:1}] 1,809 instr, 36 calls, 5 mallocs, 3 frees 1,808, 35, 5, 3 -1 instr, -1 call
plain test() registration 1,628, 27, 3, 1 1,625, 27, 3, 1 -3 instr
title call, [[1,2],[3,4]] 6,415, 126, 18, 9 6,427, 126, 18, 9 +12 instr
title call, [[5,6]] 4,078, 78, 10, 5 4,168, 80, 10, 5 +90 instr, +2 calls
title call, [{a:1}] 4,443, 84, 11, 6 4,538, 86, 11, 6 +95 instr, +2 calls
title call, ["apple"] 3,648, 70, 11, 6 4,377, 86, 12, 7 +729 instr, +16 calls, +1 malloc, +1 free
title call, [undefined] 3,178, 61, 9, 5 3,903, 77, 10, 6 +725 instr, +16 calls, +1 malloc, +1 free
  • .each(array): +0 GC cells (4 before and after: 2 Function, 1 string, 1 ScopeFunctions. bun:jsc heapStats over 20,000 kept results).
  • raw lookups on existing array tables: 0 in fn_each, 0 at the title call, over 36 .each calls and 35 title calls, 9 of them on a table of one row (temporary debug counter. main's jest-each.test.ts and the .each block of bun-test.test.ts).
  • Release binary: +6,400 bytes .text (size). Host functions: +0 (103 __jsc_host_ symbols in both). Generated classes: 0 lines of diff.
  • Syscalls for a file of array tables (gdb catch syscall): +0 for each of the 36 syscalls with a stable count (openat 23, read 22, write 13, close 22 and 32 more). The counts of futex, epoll_pwait2, restart_syscall, madvise, getdents64, clone, mmap and prctl differ between two runs of one binary.
  • Template .each of 2 rows and 2 columns: 7 GC cells for each call (the 4 above, 1 Array, 2 Object). A malformed template: 6 (the 4 above, 1 Error, 1 string).
  • Repro: registrations 5 to 2. base: 5 pass, exit 0. PR: 0 pass, 2 fail, exit 1.

Self-review

Addressed: the heading matcher is now one row of names (it was an emulation of the regex). The row type defaults to any. The docs and JSDoc sentences about the heading row and the value count are exact. The heading test has a string that changes the result for each of 14 mutants of a JavaScript model of the parser. This body lists the other differences from Jest and has no open question.

After the review on this PR: a value row that starts with | now runs, as in Jest. The three new GC tests keep their timeout, with the reason beside it: a debug ASAN build takes 3 s to 7 s for each, because the child also collects while it starts.

Not taken:

  • Carrying the $name fix for null and undefined cells into this PR. bun:test: format test.each titles with Jest's placeholder semantics #41565 owns format_label, and the fix changes the titles and snapshot keys of existing array tables.
  • Template mode for every call with more than one argument, as jest-each does. test.each([[1, 2]], extra) works today, and it stays a table.

Earlier work

#34974 built the rows in fn_each and stored them as an ordinary array. This PR keeps that, and its docs section and child-run test. #30625 and #11358 implemented the same form before it.

Suites run

On the merge with main (408c937): bun bd test of test/js/bun/test/jest-each.test.ts (also with BUN_JSC_validateExceptionChecks=1), test/js/bun/test/jest-each-gc-root.test.ts, test/regression/issue/11793.test.ts and test/cli/test/bun-test.test.ts -t each (25 pass). bun test of test/integration/bun-types/bun-types.test.ts and test/internal/source-lints/. bun lint, bun fmt --check on the changed files, cargo clippy -p bun_runtime. bun check in test/ reports no error in the two test files.

With stock bun: 69 tests fail in jest-each.test.ts, and the 3 new tests fail in jest-each-gc-root.test.ts. The new tests that pass there pin behaviour that must not change (an array table with more arguments, a .skip row, a malformed table with no title call).


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/test/jest-each-gc-root.test.ts

test.each`...`, it.each`...` and describe.each`...` call each(strings, ...values).
fn_each read the first argument only, so the strings array became the table: one
test per string segment, no values, and the title was not interpolated.

fn_each now builds one row object per row when it gets a template strings array
and values. The headings come from strings[0] with jest-each's grammar. The rows
go in the existing table slot, so the title call runs the array-of-objects path.
A malformed table does not throw from .each: the title call throws the error,
with jest-each's wording.

is_js_whitespace moves from node_assert.rs to bun_core::strings, and both users
share it.
The heading row is one line of names with | between them. jest-each also
takes an empty name, and a row that goes on after a | on the next line.
Those are malformed here, with the same error. For every other table the
headings are the ones jest-each reads.

The row type of the template overloads defaults to any, so a callback can
annotate its row parameter.
@robobun

robobun commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (9f70da0) and on bun 1.4.3-canary with this file:

import { test, expect } from "bun:test";
const price = { apple: 3, pear: 4 };
test.each`
  fruit      | expected
  ${"apple"} | ${999}
  ${"pear"}  | ${999}
`("price of $fruit is $expected", ({ fruit, expected }) => {
  expect(price[fruit]).toBe(expected);
});

bun test runs 5 tests named price of $fruit is $expected. All 5 pass and the exit code is 0. Jest 30.5.2 and Vitest 3.2.7 run 2 tests, and both fail.

With this PR, bun test runs 2 tests: price of apple is 999 and price of pear is 999. Both fail and the exit code is 1.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: eef24356-1679-4bbe-8e01-1066b7fd6ec3

📥 Commits

Reviewing files that changed from the base of the PR and between f4d755a and 9bbaa04.

📒 Files selected for processing (8)
  • docs/test/writing-tests.mdx
  • packages/bun-types/test.d.ts
  • src/bun_core/string/immutable.rs
  • src/runtime/node/node_assert.rs
  • src/runtime/test_runner/ScopeFunctions.rs
  • test/integration/bun-types/fixture/test.ts
  • test/js/bun/test/jest-each-gc-root.test.ts
  • test/js/bun/test/jest-each.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

Bun now supports tagged-template tables with test.each and describe.each. The change adds table parsing, row typing, documentation, and runtime coverage. It also centralizes JavaScript whitespace detection and uses it in rendered line diffs.

Changes

Tagged-template test tables

Layer / File(s) Summary
Table API and type contract
packages/bun-types/test.d.ts, test/integration/bun-types/fixture/test.ts, docs/test/writing-tests.mdx
Adds tagged-template each overloads and documents the table format. Type tests cover row types and tables without interpolated values.
Parse tables and construct rows
src/runtime/test_runner/ScopeFunctions.rs
Detects tagged-template calls, parses and validates headings, groups values into row objects, and throws stored errors.
Runtime behavior and GC coverage
test/js/bun/test/jest-each.test.ts, test/js/bun/test/jest-each-gc-root.test.ts
Tests row values, headings, modifiers, titles, filtering, malformed tables, and GC retention.

Shared JavaScript whitespace handling

Layer / File(s) Summary
Share JavaScript whitespace detection
src/bun_core/string/immutable.rs, src/runtime/node/node_assert.rs
Adds a shared JavaScript-whitespace predicate and uses it to trim trailing whitespace in rendered line diffs.

Suggested reviewers: alii, jarred-sumner

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 9bbaa

The tagged-template support is consistent across the documented API, types, runtime behavior, and tests. No actionable merge-blocking risk was established; merge after normal checks.

Pre-merge checks: Passed 4
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed The PR satisfies the coding objective in issue #6364. ScopeFunctions::fn_each detects tagged-template calls, parses the first string as column headings, groups interpolated values into rows, and sto…
Out of Scope Changes check Passed The changes stay within the scope of issue #6364. The documentation, TypeScript declarations, runtime tests, GC tests, and malformed-table handling support the tagged-template .each feature. The `is…
Title check Passed The title clearly and concisely identifies the main change: support for tagged-template table syntax in bun:test .each methods.
Description check Passed The description is comprehensive and explains the problem, implementation, verification, limitations, design decisions, and test results. It does not use the exact template headings, but it provides t…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked two things that turned out fine: the is_js_whitespace move into bun_core::strings keeps the exact code point set node_assert used before (0x09..=0x0D, 0x2028/0x2029, 0xFEFF plus the Zs separators), so the assert diff trimEnd behavior is unchanged; and values.len() % headings.len() in template_table cannot divide by zero because template_headings returns None for an empty heading row.

Extended reasoning...

The change adds the tagged-template form of test.each/describe.each in src/runtime/test_runner/ScopeFunctions.rs, moves a whitespace predicate into bun_core, and ships matching .d.ts overloads, docs, and tests; it touches no security-sensitive surface. The template table and any malformed-table Error are built inside a MarkedArgumentBuffer closure and handed to create_bound, so no unrooted JSValue is held across JS-entering calls. Inline findings on title formatting, heading-row strictness, VS Code discovery, and test timeouts are already posted, which is why this run does not approve.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 packages/bun-vscode/src/features/tests/bun-test-controller.ts — Users of the in-repo VS Code extension get no Test Explorer entry or run button for any test written with the new test.each / describe.each template form. The discovery regex at packages/bun-vscode/src/features/tests/bun-test-controller.ts:343 only accepts .each followed by (, so test.each...("title", fn) never matches and the title call is skipped. Fix: let discovery accept .each followed by a template literal (and expandEachTests at :453 fall back to one node for it) for every modifier chain the regex already covers, so the runtime surface this PR ships is visible in the extension. The PR notes this gap but leaves the only consumer in the repo unchanged.

    Why this was flagged

    A test file contains a tagged-template table such as the one documented in docs/test/writing-tests.mdx, e.g. test.each\n a | b\n ${1} | ${2}\n("add($a, $b)", fn). The extension reads the file through parseTestBlocks (packages/bun-vscode/src/features/tests/bun-test-controller.ts:334). Its testRegex at :343 requires the optional group (?:\.each\s*\([^)]*\))? and then \s*\( directly after the modifier chain; the characters after test are .each followed by a backtick, so neither alternative matches and exec moves on. No TestNode is created, so the Test Explorer tree, gutter run buttons and per-test run/debug targets omit every row of that table. On the base branch the same source also does not match, but there the template form is not a supported, documented bun:test API; after this PR the extension in the same repository disagrees with the runtime it ships for. The array form keeps working because .each( still matches at :343 and :453.

    Verification: Triggering condition: a user of the in-repo VS Code extension writes a test with the newly supported test.each\...`("title", fn)form. The only.eachalternative in the discovery regex at packages/bun-vscode/src/features/tests/bun-test-controller.ts:343 is.each\s*(`, so no match is produced. The base branch has the same regex, but the base had no working template form.

Comment thread src/runtime/test_runner/ScopeFunctions.rs
Comment thread src/runtime/test_runner/ScopeFunctions.rs
Comment thread test/js/bun/test/jest-each-gc-root.test.ts
A `|` at the start of the text after the heading row made the table
malformed. jest-each reads more headings there only when a name follows
the `|`. Now a `|` with no name after it starts a row of values, so a
table with `| ${1} | ${2} |` rows runs as it does in Jest.

A `|` and a name at the start of the next line stay malformed, with or
without a newline after the name.
@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the review above. The details are in the three threads.

  • % in the title of a template table: not changed here. bun:test: format test.each titles with Jest's placeholder semantics #41565 owns that code and keeps an unknown %.
  • Heading row: a value row that starts with | now runs, as in Jest (f829cf1 on the branch robobun/39d30efb/each-template-pipe-rows). A wrapped heading row and an empty column name stay errors.
  • The 60_000 timeouts on the three new GC tests: removed (7f266de on the same branch).
  • VS Code test discovery: confirmed by a read of packages/bun-vscode/src/features/tests/bun-test-controller.ts. The regex at line 343 needs .each(, so it does not match a template table, and static discovery does not list those tests. That is a change to the extension and needs its own PR.

The two commits are not on this PR, so that the head under review (9bbaa04) does not move. They go on this PR if a maintainer asks for them. Otherwise they go into a follow-up PR after the merge.

test/integration/bun-types/fixture/test.ts: both sides added cases at
the same place. Both stay.

The `each` overloads on main take `readonly T[]`, so a template with no
`${value}` type-checks as a table of strings. The `@ts-expect-error`
cases for it are gone from the fixture and from jest-each.test.ts. The
title call still throws for such a table.

main has `strings::is_js_whitespace` (#44805). The copy this branch
added to immutable.rs is gone, and node_assert.rs is as on main.
In a debug ASAN build the child takes 3.5 s to start when it collects
at every third slow allocation. The three tests take 3 s to 7 s there,
so the default timeout of 5 s is not enough.
Comment thread src/runtime/test_runner/ScopeFunctions.rs Outdated
Comment thread src/runtime/test_runner/ScopeFunctions.rs Outdated
Comment thread src/runtime/test_runner/ScopeFunctions.rs Outdated
Comment thread src/runtime/test_runner/ScopeFunctions.rs Outdated
Comment thread src/runtime/test_runner/ScopeFunctions.rs Outdated
@robobun

robobun commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:11 AM PT - Oct 14th, 2026

❌ @robobun, your commit ad90f9a has 2 failures in Build #125463 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 44402

That installs a local version of the PR into your bun-44402 executable, so you can run:

bun-44402 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

This branch had a conflict with main, so the head moved from 9bbaa04 to ad90f9a. What changed:

  • Merge with main (9d342b1). It had one text conflict, in test/integration/bun-types/fixture/test.ts: both sides added cases at the same place. Both stay.
  • main has strings::is_js_whitespace since Add bun lint and bun format #44805. The copy that this PR added is gone, so the PR no longer touches immutable.rs or node_assert.rs.
  • The array overloads of each take readonly T[] since Fix every type error in test/ #44614, so a template with no ${value} type-checks as a table of strings. The @ts-expect-error cases for it are gone. The title call still throws for such a table.
  • A value row that starts with | runs, as in Jest (f829cf1, from the review above).
  • The three new GC tests keep their timeout, with the reason beside it (c3028ee). In a debug ASAN build of main they take 3 s to 7 s.
  • Each new comment in ScopeFunctions.rs is one line (ad90f9a).

The diff against main is 6 files now. The PR body describes this state.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support parameterized test.each with backticks

2 participants