Repository: denoland/deno
Pull request: feat(ext/node): implement mock API for node:test module

Implements the `mock` API for the `node:test` module, adding support for `mock.fn()`, `mock.method()`, `mock.reset()`, and `mock.restoreAll()`.

Changed files:
filename: ext/node/polyfills/testing.ts
patch: @@ -2,14 +2,21 @@
 
 import { primordials } from "ext:core/mod.js";
 const {
-  PromisePrototypeThen,
-  ArrayPrototypePush,
   ArrayPrototypeForEach,
-  SafePromiseAll,
-  TypeError,
+  ArrayPrototypeIndexOf,
+  ArrayPrototypePush,
+  ArrayPrototypeSplice,
+  Error,
+  ObjectDefineProperty,
+  Promise,
+  PromisePrototypeThen,
+  ReflectApply,
   SafeArrayIterator,
+  SafePromiseAll,
   SafePromisePrototypeFinally,
+  String,
   Symbol,
+  TypeError,
 } = primordials;
 import { notImplemented } from "ext:deno_node/_utils.ts";
 import assert from "node:assert";
@@ -264,7 +271,27 @@ function wrapTestFn(fn, resolve) {
   return async function (t) {
     const nodeTestContext = new NodeTestContext(t, undefined);
     try {
-      await fn(nodeTestContext);
+      // Check if the test function expects a done callback (2 parameters)
+      if (fn.length >= 2) {
+        // Callback-style async test
+        await new Promise((testResolve, testReject) => {
+          const done = (err?: Error) => {
+            if (err) {
+              testReject(err);
+            } else {
+              testResolve(undefined);
+            }
+          };
+          try {
+            fn(nodeTestContext, done);
+          } catch (err) {
+            testReject(err);
+          }
+        });
+      } else {
+        // Promise-style or sync test
+        await fn(nodeTestContext);
+      }
     } catch (err) {
       if (!nodeTestContext[skippedSymbol]) {
         throw err;
@@ -413,25 +440,245 @@ test.it = it;
 test.describe = describe;
 test.suite = suite;
 
+// Store all active mocks for restoreAll()
+const activeMocks: MockFunctionContext[] = [];
+
+/** Represents a call to a mock function */
+interface MockCall {
+  arguments: unknown[];
+  error?: Error;
+  result?: unknown;
+  stack: Error;
+  target?: unknown;
+  this: unknown;
+}
+
+/** Context for a mock function with call tracking */
+class MockFunctionContext {
+  #calls: MockCall[] = [];
+  #implementation: ((...args: unknown[]) => unknown) | undefined;
+  #restore: (() => void) | undefined;
+  #times: number | undefined;
+
+  constructor(
+    implementation?: (...args: unknown[]) => unknown,
+    restore?: () => void,
+    times?: number,
+  ) {
+    this.#implementation = implementation;
+    this.#restore = restore;
+    this.#times = times;
+  }
+
+  /** Array of call information */
+  get calls(): readonly MockCall[] {
+    return this.#calls;
+  }
+
+  /** Number of times the mock has been called */
+  callCount(): number {
+    return this.#calls.length;
+  }
+
+  /** Reset the call history */
+  resetCalls(): void {
+    ArrayPrototypeSplice(this.#calls, 0, this.#calls.length);
+  }
+
+  /** Restore the original function */
+  restore(): void {
+    if (this.#restore) {
+      this.#restore();
+      this.#restore = undefined;
+    }
+    // Remove from active mocks
+    const idx = ArrayPrototypeIndexOf(activeMocks, this);
+    if (idx !== -1) {
+      ArrayPrototypeSplice(activeMocks, idx, 1);
+    }
+  }
+
+  /** Internal: record a call */
+  _recordCall(
+    thisArg: unknown,
+    args: unknown[],
+    result: unknown,
+    error?: Error,
+  ): void {
+    ArrayPrototypePush(this.#calls, {
+      arguments: args,
+      error,
+      result,
+      stack: new Error(),
+      this: thisArg,
+    });
+  }
+
+  /** Internal: check if mock should still be active based on times limit */
+  _shouldMock(): boolean {
+    if (this.#times === undefined) return true;
+    return this.#calls.length < this.#times;
+  }
+
+  /** Internal: get the mock implementation */
+  _getImplementation(): ((...args: unknown[]) => unknown) | undefined {
+    return this.#implementation;
+  }
+}
+
+/** Creates a mock function wrapper */
+function createMockFunction(
+  original: ((...args: unknown[]) => unknown) | undefined,
+  implementation: ((...args: unknown[]) => unknown) | undefined,
+  ctx: MockFunctionContext,
+): (...args: unknown[]) => unknown {
+  const mockFn = function (this: unknown, ...args: unknown[]): unknown {
+    const impl = ctx._shouldMock() ? (implementation ?? original) : original;
+
+    let result: unknown;
+    let error: Error | undefined;
+
+    try {
+      result = impl ? ReflectApply(impl, this, args) : undefined;
+    } catch (e) {
+      error = e;
+      ctx._recordCall(this, args, undefined, error);
+      throw e;
+    }
+
+    ctx._recordCall(this, args, result);
+    return result;
+  };
+
+  // Attach the mock context to the function
+  ObjectDefineProperty(mockFn, "mock", {
+    __proto__: null,
+    value: ctx,
+    writable: false,
+    enumerable: false,
+    configurable: false,
+  });
+
+  return mockFn;
+}
+
 export const mock = {
-  fn: () => {
-    notImplemented("test.mock.fn");
+  /**
+   * Creates a mock function.
+   * @param original - Optional original function to wrap
+   * @param implementation - Optional mock implementation
+   * @param options - Optional configuration
+   */
+  fn: (
+    original?: (...args: unknown[]) => unknown,
+    implementation?: (...args: unknown[]) => unknown,
+    options?: { times?: number },
+  ): ((...args: unknown[]) => unknown) & { mock: MockFunctionContext } => {
+    const ctx = new MockFunctionContext(
+      implementation ?? original,
+      undefined,
+      options?.times,
+    );
+    ArrayPrototypePush(activeMocks, ctx);
+
+    const mockFn = createMockFunction(
+      original,
+      implementation ?? original,
+      ctx,
+    );
+    return mockFn as ((...args: unknown[]) => unknown) & {
+      mock: MockFunctionContext;
+    };
   },
-  getter: () => {
+
+  /**
+   * Mocks a getter on an object.
+   */
+  getter: (
+    _object: object,
+    _methodName: string,
+    _implementation?: () => unknown,
+    _options?: { times?: number },
+  ) => {
     notImplemented("test.mock.getter");
   },
-  method: () => {
-    notImplemented("test.mock.method");
+
+  /**
+   * Mocks a method on an object.
+   * @param object - The object containing the method
+   * @param methodName - The name of the method to mock
+   * @param implementation - Optional mock implementation
+   * @param options - Optional configuration
+   */
+  method: <T extends object>(
+    object: T,
+    methodName: keyof T,
+    implementation?: (...args: unknown[]) => unknown,
+    options?: { times?: number },
+  ): ((...args: unknown[]) => unknown) & { mock: MockFunctionContext } => {
+    const original = object[methodName] as (
+      ...args: unknown[]
+    ) => unknown;
+
+    if (typeof original !== "function") {
+      throw new TypeError(
+        `Cannot mock property '${
+          String(methodName)
+        }' because it is not a function`,
+      );
+    }
+
+    const restore = () => {
+      object[methodName] = original as T[keyof T];
+    };
+
+    const ctx = new MockFunctionContext(
+      implementation,
+      restore,
+      options?.times,
+    );
+    ArrayPrototypePush(activeMocks, ctx);
+
+    const mockFn = createMockFunction(original, implementation, ctx);
+    object[methodName] = mockFn as T[keyof T];
+
+    return mockFn as ((...args: unknown[]) => unknown) & {
+      mock: MockFunctionContext;
+    };
   },
-  reset: () => {
-    notImplemented("test.mock.reset");
+
+  /**
+   * Resets the call history of all mocks.
+   */
+  reset: (): void => {
+    ArrayPrototypeForEach(activeMocks, (ctx) => {
+      ctx.resetCalls();
+    });
   },
-  restoreAll: () => {
-    notImplemented("test.mock.restoreAll");
+
+  /**
+   * Restores all mocked methods to their original implementations.
+   */
+  restoreAll: (): void => {
+    // Restore in reverse order
+    while (activeMocks.length > 0) {
+      const ctx = activeMocks[activeMocks.length - 1];
+      ctx.restore();
+    }
   },
-  setter: () => {
+
+  /**
+   * Mocks a setter on an object.
+   */
+  setter: (
+    _object: object,
+    _methodName: string,
+    _implementation?: (value: unknown) => void,
+    _options?: { times?: number },
+  ) => {
     notImplemented("test.mock.setter");
   },
+
   timers: {
     enable: () => {
       notImplemented("test.mock.timers.enable");
@@ -449,5 +696,6 @@ export const mock = {
 };
 
 test.test = test;
+test.mock = mock;
 
 export default test;

filename: tests/specs/node/node_test_mock/__test__.jsonc
patch: @@ -0,0 +1,5 @@
+{
+  "args": "test test.js",
+  "output": "test.out",
+  "exitCode": 1
+}

filename: tests/specs/node/node_test_mock/test.js
patch: @@ -0,0 +1,199 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+import assert from "node:assert/strict";
+import test, { mock } from "node:test";
+
+test("mock.fn() creates a mock function", () => {
+  const fn = mock.fn();
+  assert.strictEqual(typeof fn, "function");
+  assert.strictEqual(fn.mock.callCount(), 0);
+
+  fn();
+  assert.strictEqual(fn.mock.callCount(), 1);
+
+  fn("arg1", "arg2");
+  assert.strictEqual(fn.mock.callCount(), 2);
+  assert.deepStrictEqual(fn.mock.calls[0].arguments, []);
+  assert.deepStrictEqual(fn.mock.calls[1].arguments, ["arg1", "arg2"]);
+
+  mock.restoreAll();
+});
+
+test("mock.fn() with implementation", () => {
+  const fn = mock.fn((a, b) => a + b);
+  const result = fn(1, 2);
+  assert.strictEqual(result, 3);
+  assert.strictEqual(fn.mock.callCount(), 1);
+  assert.strictEqual(fn.mock.calls[0].result, 3);
+
+  mock.restoreAll();
+});
+
+test("mock.fn() with original and implementation override", () => {
+  const original = (x) => x * 2;
+  const impl = (x) => x * 3;
+  const fn = mock.fn(original, impl);
+
+  assert.strictEqual(fn(5), 15);
+  assert.strictEqual(fn.mock.callCount(), 1);
+
+  mock.restoreAll();
+});
+
+test("mock.fn() tracks errors", () => {
+  const fn = mock.fn(() => {
+    throw new Error("test error");
+  });
+  assert.throws(() => fn(), { message: "test error" });
+  assert.strictEqual(fn.mock.callCount(), 1);
+  assert.strictEqual(fn.mock.calls[0].error.message, "test error");
+  assert.strictEqual(fn.mock.calls[0].result, undefined);
+
+  mock.restoreAll();
+});
+
+test("mock.fn() tracks this context", () => {
+  const fn = mock.fn(function () {
+    return this;
+  });
+  const obj = { fn };
+  obj.fn();
+  assert.strictEqual(fn.mock.calls[0].this, obj);
+
+  mock.restoreAll();
+});
+
+test("mock.fn() with times option", () => {
+  const original = () => "original";
+  const impl = () => "mocked";
+  const fn = mock.fn(original, impl, { times: 2 });
+
+  assert.strictEqual(fn(), "mocked");
+  assert.strictEqual(fn(), "mocked");
+  assert.strictEqual(fn(), "original");
+  assert.strictEqual(fn.mock.callCount(), 3);
+
+  mock.restoreAll();
+});
+
+test("mock.method() mocks an object method", () => {
+  const obj = {
+    add(a, b) {
+      return a + b;
+    },
+  };
+
+  const mockMethod = mock.method(obj, "add", () => 42);
+  assert.strictEqual(obj.add(1, 2), 42);
+  assert.strictEqual(mockMethod.mock.callCount(), 1);
+  assert.deepStrictEqual(mockMethod.mock.calls[0].arguments, [1, 2]);
+
+  // Restore and verify original is back
+  mockMethod.mock.restore();
+  assert.strictEqual(obj.add(1, 2), 3);
+
+  mock.restoreAll();
+});
+
+test("mock.method() as a spy", () => {
+  const obj = {
+    greet(name) {
+      return "hello " + name;
+    },
+  };
+
+  mock.method(obj, "greet");
+  assert.strictEqual(obj.greet("world"), "hello world");
+  assert.strictEqual(obj.greet.mock.callCount(), 1);
+  assert.deepStrictEqual(obj.greet.mock.calls[0].arguments, ["world"]);
+  assert.strictEqual(obj.greet.mock.calls[0].result, "hello world");
+
+  mock.restoreAll();
+});
+
+test("mock.method() throws for non-function property", () => {
+  const obj = { value: 42 };
+  assert.throws(
+    () => mock.method(obj, "value"),
+    { message: "Cannot mock property 'value' because it is not a function" },
+  );
+
+  mock.restoreAll();
+});
+
+test("mock.reset() clears call history of all mocks", () => {
+  const fn1 = mock.fn();
+  const fn2 = mock.fn();
+
+  fn1();
+  fn1();
+  fn2();
+
+  assert.strictEqual(fn1.mock.callCount(), 2);
+  assert.strictEqual(fn2.mock.callCount(), 1);
+
+  mock.reset();
+
+  assert.strictEqual(fn1.mock.callCount(), 0);
+  assert.strictEqual(fn2.mock.callCount(), 0);
+
+  mock.restoreAll();
+});
+
+test("mock.restoreAll() restores all mocked methods", () => {
+  const obj = {
+    greet() {
+      return "hello";
+    },
+    farewell() {
+      return "goodbye";
+    },
+  };
+
+  mock.method(obj, "greet", () => "mocked hello");
+  mock.method(obj, "farewell", () => "mocked goodbye");
+
+  assert.strictEqual(obj.greet(), "mocked hello");
+  assert.strictEqual(obj.farewell(), "mocked goodbye");
+
+  mock.restoreAll();
+
+  assert.strictEqual(obj.greet(), "hello");
+  assert.strictEqual(obj.farewell(), "goodbye");
+});
+
+test("test.mock is the same as mock", () => {
+  assert.strictEqual(test.mock, mock);
+  assert.strictEqual(typeof test.mock.fn, "function");
+  assert.strictEqual(typeof test.mock.method, "function");
+  assert.strictEqual(typeof test.mock.reset, "function");
+  assert.strictEqual(typeof test.mock.restoreAll, "function");
+});
+
+test("MockFunctionContext.resetCalls()", () => {
+  const fn = mock.fn();
+  fn(1);
+  fn(2);
+  assert.strictEqual(fn.mock.callCount(), 2);
+
+  fn.mock.resetCalls();
+  assert.strictEqual(fn.mock.callCount(), 0);
+  assert.strictEqual(fn.mock.calls.length, 0);
+
+  mock.restoreAll();
+});
+
+test("mock calls have stack traces", () => {
+  const fn = mock.fn();
+  fn();
+  assert.ok(fn.mock.calls[0].stack instanceof Error);
+
+  mock.restoreAll();
+});
+
+test("done callback support", (t, done) => {
+  setTimeout(() => done(), 10);
+});
+
+test("done callback with error", (t, done) => {
+  setTimeout(() => done(new Error("callback error")), 10);
+});

filename: tests/specs/node/node_test_mock/test.out
patch: @@ -0,0 +1,31 @@
+running 16 tests from ./test.js
+mock.fn() creates a mock function ... ok ([WILDLINE])
+mock.fn() with implementation ... ok ([WILDLINE])
+mock.fn() with original and implementation override ... ok ([WILDLINE])
+mock.fn() tracks errors ... ok ([WILDLINE])
+mock.fn() tracks this context ... ok ([WILDLINE])
+mock.fn() with times option ... ok ([WILDLINE])
+mock.method() mocks an object method ... ok ([WILDLINE])
+mock.method() as a spy ... ok ([WILDLINE])
+mock.method() throws for non-function property ... ok ([WILDLINE])
+mock.reset() clears call history of all mocks ... ok ([WILDLINE])
+mock.restoreAll() restores all mocked methods ... ok ([WILDLINE])
+test.mock is the same as mock ... ok ([WILDLINE])
+MockFunctionContext.resetCalls() ... ok ([WILDLINE])
+mock calls have stack traces ... ok ([WILDLINE])
+done callback support ... ok ([WILDLINE])
+done callback with error ... FAILED ([WILDLINE])
+
+ ERRORS 
+
+done callback with error => ./test.js[WILDCARD]
+error: Error: callback error
+[WILDCARD]
+
+ FAILURES 
+
+done callback with error => ./test.js[WILDCARD]
+
+FAILED | 15 passed | 1 failed ([WILDLINE])
+
+error: Test failed