mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
feat(test): implement onTestFinished hook for bun:test (#24038)
## Summary Implements `onTestFinished()` for `bun:test`, which runs after all `afterEach` hooks have completed. ## Implementation - Added `onTestFinished` export to the test module in `jest.zig` - Modified `genericHook` in `bun_test.zig` to handle `onTestFinished` as a special case that: - Can only be called inside a test (not in describe blocks or preload) - Appends hooks at the very end of the execution sequence - Added comprehensive tests covering basic ordering, multiple callbacks, async callbacks, and interaction with other hooks ## Execution Order When called inside a test: 1. Test body executes 2. `afterAll` hooks (if added inside the test) 3. `afterEach` hooks 4. `onTestFinished` hooks ✨ ## Test Plan - ✅ All new tests pass with `bun bd test` - ✅ Tests correctly fail with `USE_SYSTEM_BUN=1` (feature not in released version) - ✅ Verifies correct ordering with `afterEach`, `afterAll`, and multiple `onTestFinished` calls - ✅ Tests async `onTestFinished` callbacks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: pfg <pfg@pfg.pw>
This commit is contained in:
@@ -257,12 +257,13 @@ $ bun test --watch
|
||||
|
||||
Bun supports the following lifecycle hooks:
|
||||
|
||||
| Hook | Description |
|
||||
| ------------ | --------------------------- |
|
||||
| `beforeAll` | Runs once before all tests. |
|
||||
| `beforeEach` | Runs before each test. |
|
||||
| `afterEach` | Runs after each test. |
|
||||
| `afterAll` | Runs once after all tests. |
|
||||
| Hook | Description |
|
||||
| ---------------- | -------------------------------------------------------- |
|
||||
| `beforeAll` | Runs once before all tests. |
|
||||
| `beforeEach` | Runs before each test. |
|
||||
| `afterEach` | Runs after each test. |
|
||||
| `afterAll` | Runs once after all tests. |
|
||||
| `onTestFinished` | Runs after a test finishes, including after `afterEach`. |
|
||||
|
||||
These hooks can be defined inside test files, or in a separate file that is preloaded with the `--preload` flag.
|
||||
|
||||
|
||||
22
packages/bun-types/test.d.ts
vendored
22
packages/bun-types/test.d.ts
vendored
@@ -358,6 +358,28 @@ declare module "bun:test" {
|
||||
fn: (() => void | Promise<unknown>) | ((done: (err?: unknown) => void) => void),
|
||||
options?: HookOptions,
|
||||
): void;
|
||||
/**
|
||||
* Runs a function after a test finishes, including after all afterEach hooks.
|
||||
*
|
||||
* This is useful for cleanup tasks that need to run at the very end of a test,
|
||||
* after all other hooks have completed.
|
||||
*
|
||||
* Can only be called inside a test, not in describe blocks.
|
||||
*
|
||||
* @example
|
||||
* test("my test", () => {
|
||||
* onTestFinished(() => {
|
||||
* // This runs after all afterEach hooks
|
||||
* console.log("Test finished!");
|
||||
* });
|
||||
* });
|
||||
*
|
||||
* @param fn the function to run
|
||||
*/
|
||||
export function onTestFinished(
|
||||
fn: (() => void | Promise<unknown>) | ((done: (err?: unknown) => void) => void),
|
||||
options?: HookOptions,
|
||||
): void;
|
||||
/**
|
||||
* Sets the default timeout for all tests in the current file. If a test specifies a timeout, it will
|
||||
* override this value. The default timeout is 5000ms (5 seconds).
|
||||
|
||||
@@ -56,6 +56,9 @@ pub const js_fns = struct {
|
||||
.timeout = args.options.timeout,
|
||||
};
|
||||
const bunTest = bunTestRoot.getActiveFileUnlessInPreload(globalThis.bunVM()) orelse {
|
||||
if (tag == .onTestFinished) {
|
||||
return globalThis.throw("Cannot call {s}() in preload. It can only be called inside a test.", .{@tagName(tag)});
|
||||
}
|
||||
group.log("genericHook in preload", .{});
|
||||
|
||||
_ = try bunTestRoot.hook_scope.appendHook(bunTestRoot.gpa, tag, args.callback, cfg, .{}, .preload);
|
||||
@@ -64,36 +67,49 @@ pub const js_fns = struct {
|
||||
|
||||
switch (bunTest.phase) {
|
||||
.collection => {
|
||||
if (tag == .onTestFinished) {
|
||||
return globalThis.throw("Cannot call {s}() outside of a test. It can only be called inside a test.", .{@tagName(tag)});
|
||||
}
|
||||
_ = try bunTest.collection.active_scope.appendHook(bunTest.gpa, tag, args.callback, cfg, .{}, .collection);
|
||||
|
||||
return .js_undefined;
|
||||
},
|
||||
.execution => {
|
||||
if (tag == .afterAll or tag == .afterEach) {
|
||||
// allowed
|
||||
const active = bunTest.getCurrentStateData();
|
||||
const sequence, _ = bunTest.execution.getCurrentAndValidExecutionSequence(active) orelse {
|
||||
return globalThis.throw("Cannot call {s}() here. It cannot be called inside a concurrent test. Call it inside describe() instead.", .{@tagName(tag)});
|
||||
};
|
||||
var append_point = sequence.active_entry;
|
||||
const active = bunTest.getCurrentStateData();
|
||||
const sequence, _ = bunTest.execution.getCurrentAndValidExecutionSequence(active) orelse {
|
||||
const message = if (tag == .onTestFinished)
|
||||
"Cannot call {s}() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent."
|
||||
else
|
||||
"Cannot call {s}() here. It cannot be called inside a concurrent test. Call it inside describe() instead.";
|
||||
return globalThis.throw(message, .{@tagName(tag)});
|
||||
};
|
||||
|
||||
var iter = append_point;
|
||||
const before_test_entry = while (iter) |entry| : (iter = entry.next) {
|
||||
if (entry == sequence.test_entry) break true;
|
||||
} else false;
|
||||
const append_point = switch (tag) {
|
||||
.afterAll, .afterEach => blk: {
|
||||
var iter = sequence.active_entry;
|
||||
while (iter) |entry| : (iter = entry.next) {
|
||||
if (entry == sequence.test_entry) break :blk sequence.test_entry.?;
|
||||
}
|
||||
|
||||
if (before_test_entry) append_point = sequence.test_entry;
|
||||
break :blk sequence.active_entry orelse return globalThis.throw("Cannot call {s}() here. Call it inside describe() instead.", .{@tagName(tag)});
|
||||
},
|
||||
.onTestFinished => blk: {
|
||||
// Find the last entry in the sequence
|
||||
var last_entry = sequence.active_entry orelse return globalThis.throw("Cannot call {s}() here. Call it inside a test instead.", .{@tagName(tag)});
|
||||
while (last_entry.next) |next_entry| {
|
||||
last_entry = next_entry;
|
||||
}
|
||||
break :blk last_entry;
|
||||
},
|
||||
else => return globalThis.throw("Cannot call {s}() inside a test. Call it inside describe() instead.", .{@tagName(tag)}),
|
||||
};
|
||||
|
||||
const append_point_value = append_point orelse return globalThis.throw("Cannot call {s}() here. Call it inside describe() instead.", .{@tagName(tag)});
|
||||
const new_item = ExecutionEntry.create(bunTest.gpa, null, args.callback, cfg, null, .{}, .execution);
|
||||
new_item.next = append_point.next;
|
||||
append_point.next = new_item;
|
||||
bun.handleOom(bunTest.extra_execution_entries.append(new_item));
|
||||
|
||||
const new_item = ExecutionEntry.create(bunTest.gpa, null, args.callback, cfg, null, .{}, .execution);
|
||||
new_item.next = append_point_value.next;
|
||||
append_point_value.next = new_item;
|
||||
bun.handleOom(bunTest.extra_execution_entries.append(new_item));
|
||||
|
||||
return .js_undefined;
|
||||
}
|
||||
return globalThis.throw("Cannot call {s}() inside a test. Call it inside describe() instead.", .{@tagName(tag)});
|
||||
return .js_undefined;
|
||||
},
|
||||
.done => return globalThis.throw("Cannot call {s}() after the test run has completed", .{@tagName(tag)}),
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ pub const Jest = struct {
|
||||
}
|
||||
|
||||
pub fn createTestModule(globalObject: *JSGlobalObject) bun.JSError!JSValue {
|
||||
const module = JSValue.createEmptyObject(globalObject, 19);
|
||||
const module = JSValue.createEmptyObject(globalObject, 20);
|
||||
|
||||
const test_scope_functions = try bun_test.ScopeFunctions.createBound(globalObject, .@"test", .zero, .{}, bun_test.ScopeFunctions.strings.@"test");
|
||||
module.put(globalObject, ZigString.static("test"), test_scope_functions);
|
||||
@@ -183,6 +183,7 @@ pub const Jest = struct {
|
||||
module.put(globalObject, ZigString.static("beforeAll"), jsc.host_fn.NewFunction(globalObject, ZigString.static("beforeAll"), 1, bun_test.js_fns.genericHook(.beforeAll).hookFn, false));
|
||||
module.put(globalObject, ZigString.static("afterAll"), jsc.host_fn.NewFunction(globalObject, ZigString.static("afterAll"), 1, bun_test.js_fns.genericHook(.afterAll).hookFn, false));
|
||||
module.put(globalObject, ZigString.static("afterEach"), jsc.host_fn.NewFunction(globalObject, ZigString.static("afterEach"), 1, bun_test.js_fns.genericHook(.afterEach).hookFn, false));
|
||||
module.put(globalObject, ZigString.static("onTestFinished"), jsc.host_fn.NewFunction(globalObject, ZigString.static("onTestFinished"), 1, bun_test.js_fns.genericHook(.onTestFinished).hookFn, false));
|
||||
module.put(globalObject, ZigString.static("setDefaultTimeout"), jsc.host_fn.NewFunction(globalObject, ZigString.static("setDefaultTimeout"), 1, jsSetDefaultTimeout, false));
|
||||
module.put(globalObject, ZigString.static("expect"), Expect.js.getConstructor(globalObject));
|
||||
module.put(globalObject, ZigString.static("expectTypeOf"), ExpectTypeOf.js.getConstructor(globalObject));
|
||||
|
||||
116
test/js/bun/test/test-on-test-finished.test.ts
Normal file
116
test/js/bun/test/test-on-test-finished.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterAll, afterEach, describe, expect, onTestFinished, test } from "bun:test";
|
||||
|
||||
// Test the basic ordering of onTestFinished
|
||||
describe("onTestFinished ordering", () => {
|
||||
const output: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
output.push("afterEach");
|
||||
});
|
||||
|
||||
test("test 1", () => {
|
||||
afterAll(() => {
|
||||
output.push("inner afterAll");
|
||||
});
|
||||
onTestFinished(() => {
|
||||
output.push("onTestFinished");
|
||||
});
|
||||
output.push("test 1");
|
||||
});
|
||||
|
||||
test("test 2", () => {
|
||||
// After test 2 starts, verify the order from test 1
|
||||
expect(output).toEqual(["test 1", "inner afterAll", "afterEach", "onTestFinished"]);
|
||||
});
|
||||
});
|
||||
|
||||
// Test multiple onTestFinished calls
|
||||
describe("multiple onTestFinished", () => {
|
||||
const output: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
output.push("afterEach");
|
||||
});
|
||||
|
||||
test("test with multiple onTestFinished", () => {
|
||||
onTestFinished(() => {
|
||||
output.push("onTestFinished 1");
|
||||
});
|
||||
onTestFinished(() => {
|
||||
output.push("onTestFinished 2");
|
||||
});
|
||||
output.push("test");
|
||||
});
|
||||
|
||||
test("verify order", () => {
|
||||
expect(output).toEqual(["test", "afterEach", "onTestFinished 1", "onTestFinished 2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// Test onTestFinished with async callbacks
|
||||
describe("async onTestFinished", () => {
|
||||
const output: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
output.push("afterEach");
|
||||
});
|
||||
|
||||
test("async onTestFinished", async () => {
|
||||
onTestFinished(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
output.push("onTestFinished async");
|
||||
});
|
||||
output.push("test");
|
||||
});
|
||||
|
||||
test("verify async order", () => {
|
||||
expect(output).toEqual(["test", "afterEach", "onTestFinished async"]);
|
||||
});
|
||||
});
|
||||
|
||||
// Test that onTestFinished throws proper error in concurrent tests
|
||||
describe("onTestFinished errors", () => {
|
||||
test.concurrent("cannot be called in concurrent test 1", () => {
|
||||
expect(() => {
|
||||
onTestFinished(() => {
|
||||
console.log("should not run");
|
||||
});
|
||||
}).toThrow(
|
||||
"Cannot call onTestFinished() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.",
|
||||
);
|
||||
});
|
||||
|
||||
test.concurrent("cannot be called in concurrent test 2", () => {
|
||||
expect(() => {
|
||||
onTestFinished(() => {
|
||||
console.log("should not run");
|
||||
});
|
||||
}).toThrow(
|
||||
"Cannot call onTestFinished() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Test onTestFinished with afterEach and afterAll together
|
||||
describe("onTestFinished with all hooks", () => {
|
||||
const output: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
output.push("afterEach");
|
||||
});
|
||||
|
||||
test("test with all hooks", () => {
|
||||
afterAll(() => {
|
||||
output.push("inner afterAll");
|
||||
});
|
||||
onTestFinished(() => {
|
||||
output.push("onTestFinished");
|
||||
});
|
||||
output.push("test");
|
||||
});
|
||||
|
||||
test("verify complete order", () => {
|
||||
// Expected order: test body, inner afterAll, afterEach, onTestFinished
|
||||
expect(output).toEqual(["test", "inner afterAll", "afterEach", "onTestFinished"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user