Skip to content
Open
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
93 changes: 93 additions & 0 deletions docs/guides/test/assert.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
title: Use assert in the Bun test runner
sidebarTitle: assert
mode: center
---

Bun's test runner exports `assert` from `bun:test`, which is a re-export of Node.js's `node:assert` module. This provides vitest-style assertions alongside Jest-style `expect()`.

```ts test.ts icon="/icons/typescript.svg"
import { test, assert } from "bun:test";

test("basic assertion", () => {
assert(true);
assert(1 + 1 === 2, "math should work");
});
```

---

## Type narrowing

The `assert` function works as a TypeScript type guard, narrowing types after assertion:

```ts test.ts icon="/icons/typescript.svg"
import { test, assert } from "bun:test";

test("type narrowing", () => {
const value: string | null = getValue();
assert(value !== null);
// TypeScript now knows value is string
console.log(value.toUpperCase());
});
```

---

## Available methods

Since `assert` is a re-export of `node:assert`, all Node.js assertion methods are available:

```ts test.ts icon="/icons/typescript.svg"
import { test, assert } from "bun:test";

test("equality checks", () => {
assert.strictEqual(1, 1);
assert.deepStrictEqual({ a: 1 }, { a: 1 });
assert.notStrictEqual(1, 2);
});

test("error handling", () => {
assert.throws(() => {
throw new Error("expected");
});

assert.doesNotThrow(() => {
// safe code
});
});

test("async assertions", async () => {
await assert.rejects(async () => {
throw new Error("async error");
});
});
```

---

## Using with expect

You can use `assert` alongside Bun's `expect()` in the same test:

```ts test.ts icon="/icons/typescript.svg"
import { test, expect, assert } from "bun:test";

test("mixed assertions", () => {
const result = compute();

// Use assert for type narrowing
assert(result !== null);

// Use expect for rich matchers
expect(result.value).toBeGreaterThan(0);
expect(result.items).toContain("expected");
});
```

---

See also:

- [Node.js assert documentation](https://nodejs.org/api/assert.html)
- [Docs > Test runner > Writing tests](/test/writing-tests)
17 changes: 17 additions & 0 deletions packages/bun-types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2372,4 +2372,21 @@ declare module "bun:test" {
}

export const expectTypeOf: typeof import("./vendor/expect-type").expectTypeOf;

/**
* Re-export of `node:assert` for vitest compatibility.
*
* @example
* ```ts
* import { assert } from "bun:test";
*
* const value: string | null = getValue();
* assert(value !== null); // Type narrows to string
* console.log(value.toUpperCase()); // OK
*
* assert.deepEqual(obj1, obj2);
* assert.throws(() => dangerousCode());
* ```
*/
export import assert = require("node:assert");
}
13 changes: 11 additions & 2 deletions src/bun.js/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1812,10 +1812,19 @@ void GlobalObject::finishCreation(VM& vm)

m_lazyTestModuleObject.initLater(
[](const Initializer<JSObject>& init) {
JSC::JSGlobalObject* globalObject = init.owner;
auto* globalObject = jsCast<Zig::GlobalObject*>(init.owner);
auto& vm = init.vm;

JSValue result = JSValue::decode(Bun__Jest__createTestModuleObject(globalObject));
init.set(result.toObject(globalObject));
JSObject* testModule = result.toObject(globalObject);

// Add node:assert as "assert" export for vitest compatibility
JSValue assertModule = globalObject->internalModuleRegistry()->requireId(globalObject, vm, Bun::InternalModuleRegistry::Field::NodeAssert);
if (assertModule && !assertModule.isUndefinedOrNull()) {
testModule->putDirect(vm, Identifier::fromString(vm, "assert"_s), assertModule);
}

init.set(testModule);
Comment on lines +1815 to +1827
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Guard lazy test module initializer with a ThrowScope when loading node:assert

Inside m_lazyTestModuleObject.initLater you now:

  • Decode the Jest test module object.
  • Call internalModuleRegistry()->requireId(..., Field::NodeAssert).
  • putDirect the returned value as "assert".

Unlike the nearby m_utilInspectFunction initializer, this path doesn’t use DECLARE_THROW_SCOPE / RETURN_IF_EXCEPTION, so any unexpected exception from Bun__Jest__createTestModuleObject, requireId, or putDirect would escape without a controlled early return from the initializer.

You can align this with existing patterns and make it more robust with a small change:

    m_lazyTestModuleObject.initLater(
        [](const Initializer<JSObject>& init) {
-            auto* globalObject = jsCast<Zig::GlobalObject*>(init.owner);
-            auto& vm = init.vm;
-
-            JSValue result = JSValue::decode(Bun__Jest__createTestModuleObject(globalObject));
-            JSObject* testModule = result.toObject(globalObject);
-
-            // Add node:assert as "assert" export for vitest compatibility
-            JSValue assertModule = globalObject->internalModuleRegistry()->requireId(globalObject, vm, Bun::InternalModuleRegistry::Field::NodeAssert);
-            if (assertModule && !assertModule.isUndefinedOrNull()) {
-                testModule->putDirect(vm, Identifier::fromString(vm, "assert"_s), assertModule);
-            }
-
-            init.set(testModule);
+            auto* globalObject = jsCast<Zig::GlobalObject*>(init.owner);
+            auto& vm = init.vm;
+            auto scope = DECLARE_THROW_SCOPE(vm);
+
+            JSValue result = JSValue::decode(Bun__Jest__createTestModuleObject(globalObject));
+            JSObject* testModule = result.toObject(globalObject);
+            RETURN_IF_EXCEPTION(scope, );
+
+            // Add node:assert as "assert" export for vitest compatibility
+            JSValue assertModule =
+                globalObject->internalModuleRegistry()->requireId(globalObject, vm, Bun::InternalModuleRegistry::Field::NodeAssert);
+            RETURN_IF_EXCEPTION(scope, );
+            if (assertModule && !assertModule.isUndefinedOrNull()) {
+                testModule->putDirect(vm, Identifier::fromString(vm, "assert"_s), assertModule);
+                RETURN_IF_EXCEPTION(scope, );
+            }
+
+            init.set(testModule);
        });

This way a failure to load node:assert simply means the assert export is absent, without leaving a stray pending exception from a lazy initializer.

});

m_testMatcherUtilsObject.initLater(
Expand Down
149 changes: 149 additions & 0 deletions test/js/bun/test/assert-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { assert, expect, test } from "bun:test";

test("assert is exported from bun:test", () => {
expect(typeof assert).toBe("function");
});

test("assert(condition) works", () => {
assert(true);
assert(1);
assert("non-empty string");

expect(() => assert(false)).toThrow();
expect(() => assert(0)).toThrow();
expect(() => assert("")).toThrow();
});

test("assert with message works", () => {
assert(true, "should not throw");

expect(() => assert(false, "custom error message")).toThrow(/custom error message/);
});

test("assert.ok works", () => {
assert.ok(true);
assert.ok(1);

expect(() => assert.ok(false)).toThrow();
});

test("assert.strictEqual works", () => {
assert.strictEqual(1, 1);
assert.strictEqual("hello", "hello");

expect(() => assert.strictEqual(1, 2)).toThrow();
expect(() => assert.strictEqual(1, "1")).toThrow();
});

test("assert.deepStrictEqual works", () => {
assert.deepStrictEqual({ a: 1 }, { a: 1 });
assert.deepStrictEqual([1, 2, 3], [1, 2, 3]);

expect(() => assert.deepStrictEqual({ a: 1 }, { a: 2 })).toThrow();
});

test("assert.notStrictEqual works", () => {
assert.notStrictEqual(1, 2);
assert.notStrictEqual(1, "1");

expect(() => assert.notStrictEqual(1, 1)).toThrow();
});

test("assert.throws works", () => {
assert.throws(() => {
throw new Error("test error");
});

expect(() =>
assert.throws(() => {
// does not throw
}),
).toThrow();
});

test("assert.doesNotThrow works", () => {
assert.doesNotThrow(() => {
// does not throw
});

expect(() =>
assert.doesNotThrow(() => {
throw new Error("test error");
}),
).toThrow();
});

test("assert.rejects works", async () => {
await assert.rejects(async () => {
throw new Error("async error");
});

await expect(
assert.rejects(async () => {
// does not reject
}),
).rejects.toThrow();
});

test("assert.doesNotReject works", async () => {
await assert.doesNotReject(async () => {
// does not reject
});

await expect(
assert.doesNotReject(async () => {
throw new Error("async error");
}),
).rejects.toThrow();
});

test("assert.equal works (loose equality)", () => {
assert.equal(1, 1);
assert.equal(1, "1"); // loose equality allows this

expect(() => assert.equal(1, 2)).toThrow();
});

test("assert.notEqual works (loose inequality)", () => {
assert.notEqual(1, 2);

expect(() => assert.notEqual(1, 1)).toThrow();
});

test("assert.deepEqual works", () => {
assert.deepEqual({ a: 1 }, { a: 1 });
assert.deepEqual([1, 2], [1, 2]);

expect(() => assert.deepEqual({ a: 1 }, { a: 2 })).toThrow();
});

test("assert.notDeepEqual works", () => {
assert.notDeepEqual({ a: 1 }, { a: 2 });

expect(() => assert.notDeepEqual({ a: 1 }, { a: 1 })).toThrow();
});

test("assert.fail works", () => {
expect(() => assert.fail()).toThrow();
expect(() => assert.fail("custom message")).toThrow(/custom message/);
});

test("assert.ifError works", () => {
assert.ifError(null);
assert.ifError(undefined);

expect(() => assert.ifError(new Error("test"))).toThrow();
expect(() => assert.ifError("some error")).toThrow();
});

test("assert.match works", () => {
assert.match("hello world", /world/);

expect(() => assert.match("hello", /world/)).toThrow();
});

test("assert.doesNotMatch works", () => {
assert.doesNotMatch("hello", /world/);

expect(() => assert.doesNotMatch("hello world", /world/)).toThrow();
});
Comment on lines +1 to +149
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Comprehensive coverage of the assert export; consider also asserting identity with node:assert

These tests do a solid job of exercising the assert export from bun:test across the core Node APIs (sync, async, loose/strict, deep vs non‑deep, and helper methods). They align with Bun’s testing style guidelines and should quickly catch regressions in the wiring to node:assert.

If you want to further lock in the “re‑export” guarantee (not just behavioral similarity), you could add a small identity check:

-import { assert, expect, test } from "bun:test";
+import { assert, expect, test } from "bun:test";
+import nodeAssert from "node:assert";
+
+test("assert is the node:assert export", () => {
+  expect(assert).toBe(nodeAssert);
+});

Optional, but it would guarantee that bun:test and node:assert stay in sync at the object level.

🤖 Prompt for AI Agents
In test/js/bun/test/assert-export.test.ts around lines 1 to 149, add a small
identity check to ensure the exported assert from bun:test is the same object
exported by node:assert; require or import node:assert (e.g., import * as
nodeAssert from "node:assert" or require("node:assert")) and add an expectation
that assert === nodeAssert (or otherwise assert strict identity), so the test
verifies a re-export rather than only behavioral equivalence.