Repository: denoland/deno
Pull request: fix(ext/node): add basic support of suite/describe in node:test

This PR adds basic support of suite/describe in `node:test`

closes #27406

towards #28837 

Changed files:
filename: ext/node/polyfills/testing.ts
patch: @@ -1,7 +1,12 @@
 // Copyright 2018-2025 the Deno authors. MIT license.
 
 import { primordials } from "ext:core/mod.js";
-const { PromisePrototypeThen } = primordials;
+const {
+  PromisePrototypeThen,
+  ArrayPrototypePush,
+  SafePromiseAll,
+  SafePromisePrototypeFinally,
+} = primordials;
 import { notImplemented, warnNotImplemented } from "ext:deno_node/_utils.ts";
 
 export function run() {
@@ -87,6 +92,49 @@ class NodeTestContext {
   }
 }
 
+let currentSuite: TestSuite | null = null;
+
+class TestSuite {
+  #denoTestContext: Deno.TestContext;
+  steps: Promise<boolean>[] = [];
+
+  constructor(t: Deno.TestContext) {
+    this.#denoTestContext = t;
+  }
+
+  addTest(name, options, fn, overrides) {
+    const prepared = prepareOptions(name, options, fn, overrides);
+    const step = this.#denoTestContext.step({
+      name: prepared.name,
+      fn: (denoTestContext) => {
+        const newNodeTextContext = new NodeTestContext(denoTestContext);
+        return prepared.fn(newNodeTextContext);
+      },
+      ignore: prepared.options.todo || prepared.options.skip,
+      sanitizeExit: false,
+      sanitizeOps: false,
+      sanitizeResources: false,
+    });
+    ArrayPrototypePush(this.steps, step);
+  }
+
+  addSuite(name, options, fn, overrides) {
+    const prepared = prepareOptions(name, options, fn, overrides);
+    // deno-lint-ignore prefer-primordials
+    const { promise, resolve } = Promise.withResolvers();
+    const step = this.#denoTestContext.step({
+      name: prepared.name,
+      fn: wrapSuiteFn(prepared.fn, resolve),
+      ignore: prepared.options.todo || prepared.options.skip,
+      sanitizeExit: false,
+      sanitizeOps: false,
+      sanitizeResources: false,
+    });
+    ArrayPrototypePush(this.steps, step);
+    return promise;
+  }
+}
+
 function prepareOptions(name, options, fn, overrides) {
   if (typeof name === "function") {
     fn = name;
@@ -147,30 +195,104 @@ function prepareDenoTest(name, options, fn, overrides) {
   return promise;
 }
 
-export function test(name, options, fn) {
-  return prepareDenoTest(name, options, fn, {});
+function wrapSuiteFn(fn, resolve) {
+  return function (t) {
+    const prevSuite = currentSuite;
+    const suite = currentSuite = new TestSuite(t);
+    try {
+      fn();
+    } finally {
+      currentSuite = prevSuite;
+    }
+    return SafePromisePrototypeFinally(SafePromiseAll(suite.steps), resolve);
+  };
+}
+
+function prepareDenoTestForSuite(name, options, fn, overrides) {
+  const prepared = prepareOptions(name, options, fn, overrides);
+
+  // deno-lint-ignore prefer-primordials
+  const { promise, resolve } = Promise.withResolvers();
+
+  const denoTestOptions = {
+    name: prepared.name,
+    fn: wrapSuiteFn(prepared.fn, resolve),
+    only: prepared.options.only,
+    ignore: prepared.options.todo || prepared.options.skip,
+    sanitizeExit: false,
+    sanitizeOps: false,
+    sanitizeResources: false,
+  };
+  Deno.test(denoTestOptions);
+  return promise;
+}
+
+export function test(name, options, fn, overrides) {
+  if (currentSuite) {
+    return currentSuite.addTest(name, options, fn, overrides);
+  }
+  return prepareDenoTest(name, options, fn, overrides);
 }
 
 test.skip = function skip(name, options, fn) {
-  return prepareDenoTest(name, options, fn, { skip: true });
+  return test(name, options, fn, { skip: true });
 };
 
 test.todo = function todo(name, options, fn) {
-  return prepareDenoTest(name, options, fn, { todo: true });
+  return test(name, options, fn, { todo: true });
 };
 
 test.only = function only(name, options, fn) {
-  return prepareDenoTest(name, options, fn, { only: true });
+  return test(name, options, fn, { only: true });
+};
+
+export function describe(name, options, fn) {
+  return suite(name, options, fn, {});
+}
+
+describe.skip = function skip(name, options, fn) {
+  return suite.skip(name, options, fn);
+};
+describe.todo = function todo(name, options, fn) {
+  return suite.todo(name, options, fn);
+};
+describe.only = function only(name, options, fn) {
+  return suite.only(name, options, fn);
 };
 
-export function describe() {
-  notImplemented("test.describe");
+export function suite(name, options, fn, overrides) {
+  if (currentSuite) {
+    return currentSuite.addSuite(name, options, fn, overrides);
+  }
+  return prepareDenoTestForSuite(name, options, fn, overrides);
 }
 
-export function it() {
-  notImplemented("test.it");
+suite.skip = function skip(name, options, fn) {
+  return suite(name, options, fn, { skip: true });
+};
+suite.todo = function todo(name, options, fn) {
+  return suite(name, options, fn, { todo: true });
+};
+suite.only = function only(name, options, fn) {
+  return suite(name, options, fn, { only: true });
+};
+
+export function it(name, options, fn) {
+  return test(name, options, fn, {});
 }
 
+it.skip = function skip(name, options, fn) {
+  return test.skip(name, options, fn);
+};
+
+it.todo = function todo(name, options, fn) {
+  return test.todo(name, options, fn);
+};
+
+it.only = function only(name, options, fn) {
+  return test.only(name, options, fn);
+};
+
 export function before() {
   notImplemented("test.before");
 }
@@ -187,6 +309,10 @@ export function afterEach() {
   notImplemented("test.afterEach");
 }
 
+test.it = it;
+test.describe = describe;
+test.suite = suite;
+
 export const mock = {
   fn: () => {
     notImplemented("test.mock.fn");

filename: tests/specs/node/node_test_module/test.js
patch: @@ -5,7 +5,7 @@
 // deno-lint-ignore-file
 
 import assert from "node:assert";
-import test from "node:test";
+import test, { describe, it, suite } from "node:test";
 import util from "node:util";
 import { setImmediate } from "node:timers";
 
@@ -79,6 +79,57 @@ test("reject fail", () => {
   return Promise.reject(new Error("rejected from reject fail"));
 });
 
+suite("suite", () => {
+  test("test 1", () => {});
+  test("test 2", () => {});
+
+  suite("sub suite 1", () => {
+    test("nested test 1", () => {});
+    test("nested test 2", () => {});
+  });
+
+  suite("sub suite 2", () => {
+    test("nested test 1", () => {});
+    test("nested test 2", () => {});
+  });
+});
+
+describe("describe", () => {
+  it("test 1", () => {});
+  it("test 2", () => {});
+
+  describe("sub describe 1", () => {
+    it("nested test 1", () => {});
+    it("nested test 2", () => {});
+  });
+
+  describe("sub describe 2", () => {
+    it("nested test 1", () => {});
+    it("nested test 2", () => {});
+  });
+});
+
+suite("suite", () => {
+  test("test 1", () => {});
+  test("test 2", () => {
+    throw new Error("thrown from test 2");
+  });
+
+  suite("sub suite 1", () => {
+    test("nested test 1", () => {});
+    test("nested test 2", () => {
+      throw new Error("thrown from nested test 2");
+    });
+  });
+
+  suite("sub suite 2", () => {
+    test("nested test 1", () => {
+      throw new Error("thrown from nested test 1");
+    });
+    test("nested test 2", () => {});
+  });
+});
+
 test("unhandled rejection - passes but warns", () => {
   Promise.reject(new Error("rejected from unhandled rejection fail"));
 });

filename: tests/specs/node/node_test_module/test.out
patch: @@ -1,5 +1,5 @@
 [WILDCARD]
-running 63 tests from ./test.js
+running 66 tests from ./test.js
 sync pass todo ...
 ------- output -------
 Warning: Not implemented: test.TestContext.todo
@@ -56,6 +56,42 @@ async skip fail ... FAILED [WILDCARD]
 async assertion fail ... FAILED [WILDCARD]
 resolve pass ... ok [WILDCARD]
 reject fail ... FAILED [WILDCARD]
+suite ...
+  test 1 ... ok ([WILDLINE])
+  test 2 ... ok ([WILDLINE])
+  sub suite 1 ...
+    nested test 1 ... ok ([WILDLINE])
+    nested test 2 ... ok ([WILDLINE])
+  sub suite 1 ... ok ([WILDLINE])
+  sub suite 2 ...
+    nested test 1 ... ok ([WILDLINE])
+    nested test 2 ... ok ([WILDLINE])
+  sub suite 2 ... ok ([WILDLINE])
+suite ... ok ([WILDLINE])
+describe ...
+  test 1 ... ok ([WILDLINE])
+  test 2 ... ok ([WILDLINE])
+  sub describe 1 ...
+    nested test 1 ... ok ([WILDLINE])
+    nested test 2 ... ok ([WILDLINE])
+  sub describe 1 ... ok ([WILDLINE])
+  sub describe 2 ...
+    nested test 1 ... ok ([WILDLINE])
+    nested test 2 ... ok ([WILDLINE])
+  sub describe 2 ... ok ([WILDLINE])
+describe ... ok ([WILDLINE])
+suite ...
+  test 1 ... ok ([WILDLINE])
+  test 2 ... FAILED ([WILDLINE])
+  sub suite 1 ...
+    nested test 2 ... FAILED ([WILDLINE])
+    nested test 1 ... ok ([WILDLINE])
+  sub suite 1 ... FAILED (due to 1 failed step) ([WILDLINE])
+  sub suite 2 ...
+    nested test 1 ... FAILED ([WILDLINE])
+    nested test 2 ... ok ([WILDLINE])
+  sub suite 2 ... FAILED (due to 1 failed step) ([WILDLINE])
+suite ... FAILED (due to 3 failed steps) ([WILDLINE])
 unhandled rejection - passes but warns ...
 Uncaught error from ./test.js FAILED
 unhandled rejection - passes but warns ... cancelled ([WILDCARD])
@@ -151,6 +187,24 @@ error: Error: rejected from reject fail
                         ^
     at [WILDCARD]
 
+suite ... test 2 => ./test.js:[WILDLINE]
+error: Error: thrown from test 2
+    throw new Error("thrown from test 2");
+          ^
+    at [WILDCARD]
+
+suite ... sub suite 1 ... nested test 2 => ./test.js:[WILDLINE]
+error: Error: thrown from nested test 2
+      throw new Error("thrown from nested test 2");
+            ^
+    at [WILDCARD]
+
+suite ... sub suite 2 ... nested test 1 => ./test.js:[WILDLINE]
+error: Error: thrown from nested test 1
+      throw new Error("thrown from nested test 1");
+            ^
+    at [WILDCARD]
+
 ./test.js (uncaught error)
 error: (in promise) Error: rejected from unhandled rejection fail
   Promise.reject(new Error("rejected from unhandled rejection fail"));
@@ -168,8 +222,11 @@ async throw fail => ./test.js:53:1
 async skip fail => ./test.js:64:1
 async assertion fail => ./test.js:69:1
 reject fail => ./test.js:78:1
+suite ... test 2 => ./test.js:[WILDLINE]
+suite ... sub suite 1 ... nested test 2 => ./test.js:[WILDLINE]
+suite ... sub suite 2 ... nested test 1 => ./test.js:[WILDLINE]
 ./test.js (uncaught error)
 
-FAILED | 9 passed (2 steps) | 51 failed | 4 ignored [WILDCARD]
+FAILED | 11 passed (21 steps) | 52 failed (5 steps) | 4 ignored [WILDCARD]
 
 error: Test failed