bun test: detect obsolete snapshots and fix -u "added" mislabel
What
bun test now reports snapshot keys that exist in a .snap file but were never matched during the run, and bun test -u stops mislabeling rewritten entries as "added".
Fixes #12114
Problem
$ bun test # .snap file has a stale `obsolete-gone 1` key
1 pass
0 fail
1 snapshots, 1 expect() calls # no signal that a stale key exists
$ bun test -u
snapshots: +1 added # nothing was added; one entry was rewritten, one removed
Jest prints "1 snapshot obsolete" on the first run and "1 snapshot removed" on the second. Bun had no way to represent "in the file but never matched": Snapshots carried only added/passed/failed, and under -u the file was opened with O_TRUNC so every entry looked brand new. The early truncate also left the .snap file empty for anything that read it during afterAll() (#12114).
Fix
src/runtime/test_runner/snapshot.rs
Snapshotsgainsobsolete,removedcounters and anunchecked_keys: StringHashMap<()>populated byparse_fileand drained on each match inget_or_put.- Under
-u,get_snapshot_filereads and parses the old file before clearing the buffer so a key that already existed counts aspassedrather thanadded;write_snapshot_filetruncates after write instead of relying onO_TRUNCat open. A malformed old file is ignored under-uso the existing "replace unparseable file" behaviour is preserved. mark_snapshots_as_checked_for_testremoves every{testName} Nkey for a given test (Jest'skeyToTestNamerule) so skipped/todo/filtered tests are not counted as obsolete.
src/runtime/test_runner/Execution.rs
on_sequence_completedcalls the new hook forSkip/Todo/SkippedBecauseLabelresults, building the name identically toExpect::get_snapshot_name.
src/runtime/cli/test_command.rs
- Summary prints
N removed/N obsolete(with abun test -uhint). The obsolete line is suppressed under--onlysince non-only tests never reach the reporter there.
Verification
$ bun test
snapshots: 1 passed, 1 obsolete
To remove obsolete snapshots, run bun test -u
$ bun test -u
snapshots: 1 passed, 1 removed
New coverage in test/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts: plain-run obsolete count, -u removed vs added, test.skip and -t not producing false positives, a truly new key still reporting added, and the #12114 repro (.snap file readable in afterAll under -u). All six fail on current main and pass with this change. Existing snapshot-tests/, ci-restrictions.test.ts, and bun_test.test.ts pass unchanged.
[review] gate passed · iteration 3 · 6 files touched
fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (a024016cd)
test/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts:
31 | "// Bun Snapshot v1, https://bun.sh/docs/test/snapshots\n\n" +
32 | "exports[`foo 1`] = `1`;\n\n" +
33 | "exports[`foo 2`] = `2`;\n",
34 | });
35 | const { stderr, exitCode } = await run(dir);
36 | expect(stderr).toContain("1 obsolete");
^
error: expect(received).toContain(expected)
Expected to contain: "1 obsolete"
Received: "\nsnap.test.ts:\n(pass) foo [3.09ms]\n\n 1 pass\n 0 fail\n 1 snapshots, 1 expect() calls\nRan 1 test across 1 file. [395.00ms]\n"
at <anonymous> (/workspace/bun/test/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts:36:20)
(fail) obsolete
... (truncated)
release without fix: all passed
bun test v1.4.0-canary.1 (d9ac81530)
test/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts:
(pass) obsolete snapshot detection > passing test with fewer toMatchSnapshot calls than keys on disk reports obsolete [17.65ms]
(pass) obsolete snapshot detection > -u labels dropped entries as removed, not added [20.44ms]
(pass) obsolete snapshot detection > reports obsolete snapshots without -u [21.28ms]
(pass) obsolete snapshot detection > skipped test's snapshot is not counted obsolete (skip before first match) [18.43ms]
(pass) obsolete snapshot detection > -u counts a skipped test's dropped entry in removed [18.30ms]
(pass) obsolete snapshot detection > in-source test.only() suppresses obsolete [17.42ms]
(pass) obsolete snapshot detection > skipped test's snapshot is not counted obsolete (skip after first match) [20.23ms]
(pass) obsolete snapshot detection > beforeAll failure suppresses obsolete for jumped-over tests [16.77ms]
(pass) obsolete snapshot detection > skipped test with a hinted snapshot is not counted obsolete [15.67ms]
(pass) obsolete snapshot detection > -t filtered test's snapshot is not counted obsolete [14.25ms]
(pass) obsolete snapshot detection >
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (a024016cd)
test/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts:
(pass) obsolete snapshot detection > passing test with fewer toMatchSnapshot calls than keys on disk reports obsolete [486.97ms]
(pass) obsolete snapshot detection > reports obsolete snapshots without -u [474.96ms]
(pass) obsolete snapshot detection > skipped test's snapshot is not counted obsolete (skip after first match) [489.76ms]
(pass) obsolete snapshot detection > skipped test's snapshot is not counted obsolete (skip before first match) [507.33ms]
(pass) obsolete snapshot detection > -u labels dropped entries as removed, not added [543.50ms]
(pass) obsolete snapshot detection > -u counts a skipped test's dropped entry in removed [4
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 675ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date
nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)
info: checking for self-update (current version: 1.29.0)
[1m[92m Compiling[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
[1m[92m Compiling[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
[1m
... (truncated)
diff hotspot
src/runtime/cli/test_command.rs | 45 ++++-
src/runtime/test_runner/Execution.rs | 22 +++
src/runtime/test_runner/bun_test.rs | 4 +-
src/runtime/test_runner/expect.rs | 14 +-
src/runtime/test_runner/snapshot.rs | 137 +++++++++++--
.../test/snapshot-tests/obsolete-snapshots.test.ts | 212 +++++++++++++++++++++
6 files changed, 408 insertions(+), 26 deletions(-)
gate history · 7 passed · 0 rejected · iteration 3
evidence per changed file
file reads edits tests
src/runtime/cli/test_command.rs 8 17 0
src/runtime/test_runner/Execution.rs 11 14 0
src/runtime/test_runner/bun_test.rs 2 1 0
src/runtime/test_runner/expect.rs 3 1 0
src/runtime/test_runner/snapshot.rs 13 26 0
…t/js/bun/test/snapshot-tests/obsolete-snapshots.test.ts 1 10 0