Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/test/runtime-behavior.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ test("test without timeout", async () => {

### Unhandled Errors

`bun test` tracks unhandled promise rejections and errors that occur between tests. If any occur, `bun test` exits with a non-zero code even when no test failed. In both examples below the error happens while the file is being loaded, so the file's tests are not run at all.
`bun test` tracks unhandled promise rejections and errors that occur between tests. If any occur, `bun test` exits with a non-zero code even when no test failed. In both examples below the error happens while the file is being loaded: it is reported as an unhandled error and the file's tests still run.

This helps catch errors in asynchronous code that might otherwise go unnoticed:

Expand All @@ -107,8 +107,8 @@ test("test 2", () => {
expect(true).toBe(true);
});

// bun test reports this as "Unhandled error between tests", does not run
// this file's tests (0 pass, 1 error), and exits with code 1
// bun test reports this as "Unhandled error between tests", still runs both
// tests (2 pass, 1 error), and exits with code 1
```

### Promise Rejections
Expand All @@ -122,8 +122,8 @@ test("test 1", () => {
expect(1).toBe(1);
});

// bun test reports this as "Unhandled error between tests", does not run
// this file's tests, and exits with code 1
// bun test reports this as "Unhandled error between tests", still runs
// "test 1" (1 pass, 1 error), and exits with code 1
Promise.reject(new Error("Unhandled rejection"));
```

Expand Down
22 changes: 17 additions & 5 deletions src/runtime/test_runner/Collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use crate::test_runner::jest::Jest;
pub struct Collection {
/// set to true after collection phase ends
pub(crate) locked: bool,
/// an async describe() callback's promise is outstanding; an uncaught error now is charged to it
pub(crate) describe_callback_pending: bool,
pub(crate) describe_callback_queue: Vec<QueuedDescribe>,
pub(crate) current_scope_callback_queue: Vec<QueuedDescribe>,
// The two queues above are self-referential — their `NonNull<DescribeScope>` fields point
Expand Down Expand Up @@ -79,6 +81,7 @@ impl Collection {

Collection {
locked: false,
describe_callback_pending: false,
describe_callback_queue: Vec::new(),
current_scope_callback_queue: Vec::new(),
root_scope,
Expand Down Expand Up @@ -170,6 +173,7 @@ impl Collection {
"collection:runOneCompleted reset scope back from {}",
bstr::BStr::new(self.active_scope().base.name.as_deref().unwrap_or(b"undefined")),
));
self.describe_callback_pending = false;
self.active_scope = prev_scope;
group::log(format_args!(
"collection:runOneCompleted reset scope back to {}",
Expand Down Expand Up @@ -234,17 +238,20 @@ impl Collection {
bstr::BStr::new(this.active_scope().base.name.as_deref().unwrap_or(b"undefined")),
));

if let Some(cfg_data) = BunTest::run_test_callback(
let sync_result = BunTest::run_test_callback(
buntest_strong,
global_this,
callback.get(),
false,
RefDataValue::Collection { active_scope: previous_scope },
&Timespec::EPOCH,
) {
);
// Re-derive after re-entrant call per BunTestCell::get aliasing contract.
let buntest = buntest_strong.get();
match sync_result {
// the result is available immediately; queue
// Re-derive after re-entrant call per BunTestCell::get aliasing contract.
buntest_strong.get().add_result(cfg_data);
Some(cfg_data) => buntest.add_result(cfg_data),
None => buntest.collection.describe_callback_pending = true,
}

return Ok(StepResult::Waiting { timeout: Timespec::EPOCH });
Expand All @@ -254,10 +261,15 @@ impl Collection {

pub(crate) fn handle_uncaught_exception(
&mut self,
_: &RefDataValue,
data: &RefDataValue,
) -> HandleUncaughtExceptionResult {
let _g = group::begin();

// An error that no describe() callback owns has no scope to fail.
let RefDataValue::Collection { .. } = data else {
return HandleUncaughtExceptionResult::ShowUnhandledErrorBetweenTests;
};

self.active_scope_mut().failed = true;

HandleUncaughtExceptionResult::ShowUnhandledErrorInDescribe // unhandled because it needs to exit with code 1
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/test_runner/jest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,11 @@ pub(crate) mod on_unhandled_rejection {
// re-borrows. Const→mut projection is centralized in `buntest_as_mut`
// pending the BunTestPtr interior-mut reshape (see bun_test.rs).
let buntest = unsafe { bun_test::buntest_as_mut(&buntest_strong) };
if buntest.phase == bun_test::Phase::Collection && !buntest.collection.describe_callback_pending {
// No callback owns this error: report it, fail no scope, and do not step collection.
buntest.on_uncaught_exception(global_object, Some(rejection), true, &RefDataValue::Start);
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// mark unhandled errors as belonging to the currently active test. note that this can be misleading.
let mut current_state_data = buntest.get_current_state_data();
// split entry()/sequence() borrows via raw-ptr capture (per-use reborrow).
Expand Down
196 changes: 195 additions & 1 deletion test/js/bun/test/test-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { spawn, spawnSync } from "bun";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from "bun:test";
import { copyFileSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "fs";
import { rm, writeFile } from "fs/promises";
import { bunEnv, bunExe, tempDir, tmpdirSync } from "harness";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness";
import { tmpdir } from "os";
import { dirname, join } from "path";

Expand Down Expand Up @@ -749,3 +749,197 @@ test("my-test", () => {
});
}
});

// An unhandled rejection or uncaught exception that lands while a file is still
// registering its tests does not belong to any describe() callback. It is reported
// as an unhandled error and the file's tests still run.
describe.concurrent("unhandled error during collection does not drop the file's tests", () => {
test("from module top level, a .each table, and a describe body", async () => {
using dir = tempDir("unhandled-collection", {
"package.json": "{}",
// Flush left, no blank lines: the code frames below are part of the snapshot.
"stray.test.ts": [
`import { describe, expect, test } from "bun:test";`,
`Promise.reject(new Error("stray-top-level"));`,
`test.each([[Promise.reject(new Error("stray-each-row"))], [Promise.resolve(1)]])("row %#", value => {`,
` expect(value).toBeInstanceOf(Promise);`,
`});`,
`describe("d", () => {`,
` Promise.reject(new Error("stray-in-describe"));`,
` test("t1", () => {});`,
` describe("inner", () => {`,
` test("t2", () => {});`,
` });`,
`});`,
`test("failing", () => {`,
` expect(1).toBe(2);`,
`});`,
].join("\n"),
});

await using proc = spawn({
cmd: [bunExe(), "test", "./stray.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe(`bun test ${Bun.version_with_sha}\n`);
expect(normalizeBunSnapshot(stderr, dir)).toMatchInlineSnapshot(`
"stray.test.ts:

# Unhandled error between tests
-------------------------------
1 | import { describe, expect, test } from "bun:test";
2 | Promise.reject(new Error("stray-top-level"));
^
error: stray-top-level
at <dir>/stray.test.ts:2:20
-------------------------------


# Unhandled error between tests
-------------------------------
1 | import { describe, expect, test } from "bun:test";
2 | Promise.reject(new Error("stray-top-level"));
3 | test.each([[Promise.reject(new Error("stray-each-row"))], [Promise.resolve(1)]])("row %#", value => {
^
error: stray-each-row
at <dir>/stray.test.ts:3:32
-------------------------------


# Unhandled error between tests
-------------------------------
2 | Promise.reject(new Error("stray-top-level"));
3 | test.each([[Promise.reject(new Error("stray-each-row"))], [Promise.resolve(1)]])("row %#", value => {
4 | expect(value).toBeInstanceOf(Promise);
5 | });
6 | describe("d", () => {
7 | Promise.reject(new Error("stray-in-describe"));
^
error: stray-in-describe
at <anonymous> (file:NN:NN)
-------------------------------

(pass) row 0
(pass) row 1
(pass) d > t1
(pass) d > inner > t2
9 | describe("inner", () => {
10 | test("t2", () => {});
11 | });
12 | });
13 | test("failing", () => {
14 | expect(1).toBe(2);
^
error: expect(received).toBe(expected)

Expected: 2
Received: 1
at <anonymous> (file:NN:NN)
(fail) failing

4 pass
1 fail
3 errors
3 expect() calls
Ran 5 tests across 1 file."
`);
expect(exitCode).toBe(1);
});

test("from a --preload module", async () => {
using dir = tempDir("unhandled-collection-preload", {
"package.json": "{}",
// Flush left, no blank lines: the code frames below are part of the snapshot.
"preload.ts": [
`Promise.reject(new Error("stray-preload-rejection"));`,
`queueMicrotask(() => {`,
` throw new Error("stray-preload-exception");`,
`});`,
].join("\n"),
"a.test.ts": [
`import { expect, test } from "bun:test";`,
`test("a1", () => {`,
` expect(1).toBe(2);`,
`});`,
`test("a2", async () => {`,
` await 0;`,
`});`,
].join("\n"),
"b.test.ts": [
`import { describe, test } from "bun:test";`,
`describe("d", () => {`,
` test("b1", () => {});`,
`});`,
].join("\n"),
});

await using proc = spawn({
cmd: [
bunExe(),
"test",
"--preload",
"./preload.ts",
"--reporter=junit",
"--reporter-outfile=junit.xml",
"./a.test.ts",
"./b.test.ts",
],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe(`bun test ${Bun.version_with_sha}\n`);
expect(normalizeBunSnapshot(stderr, dir)).toMatchInlineSnapshot(`
"a.test.ts:

# Unhandled error between tests
-------------------------------
1 | Promise.reject(new Error("stray-preload-rejection"));
2 | queueMicrotask(() => {
3 | throw new Error("stray-preload-exception");
^
error: stray-preload-exception
at <anonymous> (file:NN:NN)
-------------------------------


# Unhandled error between tests
-------------------------------
1 | Promise.reject(new Error("stray-preload-rejection"));
^
error: stray-preload-rejection
at <dir>/preload.ts:1:20
-------------------------------

1 | import { expect, test } from "bun:test";
2 | test("a1", () => {
3 | expect(1).toBe(2);
^
error: expect(received).toBe(expected)

Expected: 2
Received: 1
at <anonymous> (file:NN:NN)
(fail) a1
(pass) a2

b.test.ts:
(pass) d > b1

2 pass
1 fail
2 errors
1 expect() calls
Ran 3 tests across 2 files."
`);
const junit = await Bun.file(join(String(dir), "junit.xml")).text();
expect([...junit.matchAll(/<testcase name="([^"]+)"/g)].map(m => m[1]).sort()).toEqual(["a1", "a2", "b1"]);
expect(exitCode).toBe(1);
});
});
Loading