如何用TS-Jest正确模拟child_process的spawn命名导入?
如何用TS-Jest正确模拟ESM命名导入的child_process.spawn?
我尝试模拟从child_process命名导入的spawn,但该属性为只读,导致模拟失败。进一步发现child_process.spawn和import { spawn } from "child_process"并非同一引用,目前只能临时改用默认导入,但大型项目中这种修改方案不现实。请问是否存在使用TS-Jest模拟ESM命名导入的正确方法?
相关代码
src/factory.ts
import { spawn } from "child_process"; class ComposeFactory implements ContainerFactory { constructor(type: string) { this.type = type; } async create(name: string) { console.log(`[${this.type} ${name}]: Creating.`); await spawn( "/usr/bin/docker", [ "compose", "-f", `${process.env.DOCKER_COMPOSE_PATH}/docker-compose.${this.type}.yaml`, "create", ], { stdio: "ignore", detached: true, } ) .unref(); this.containerInstances.set(name, new ComposeInstance(this.type, name)); } // ...(delete函数逻辑类似) }
test/factory.test.ts(原失败的模拟代码)
import { describe, expect, test, jest } from "@jest/globals"; import child_process, { ChildProcess } from "child_process"; import ComposeFactory from "../src/factory"; const spawn = jest .spyOn(child_process, "spawn") .mockImplementation(function ( this: ChildProcess, command: string, args: readonly string[], options: child_process.SpawnOptions ): ChildProcess { this.unref = jest.fn(); console.log("Correctly mocked spawn"); return this; });
解决方案
方法1:使用jest.mock直接模拟模块的命名导出
这种方法是最规范的,直接替换整个模块的spawn导出,能覆盖ESM命名导入的引用:
// test/factory.test.ts import { describe, expect, test, jest } from "@jest/globals"; import { spawn } from "child_process"; import ComposeFactory from "../src/factory"; // 模拟child_process模块,保留其他原有导出,仅替换spawn jest.mock("child_process", () => ({ ...jest.requireActual("child_process"), // 保留未被模拟的其他API spawn: jest.fn(function(this: any, command: string, args: readonly string[], options: any) { this.unref = jest.fn(); console.log("Correctly mocked spawn"); return this; }) })); describe("ComposeFactory", () => { test("create方法应正确调用spawn", async () => { const factory = new ComposeFactory("test"); await factory.create("test-container"); // 验证spawn的调用参数 expect(spawn).toHaveBeenCalledWith( "/usr/bin/docker", [ "compose", "-f", `${process.env.DOCKER_COMPOSE_PATH}/docker-compose.test.yaml`, "create" ], { stdio: "ignore", detached: true } ); // 验证unref方法被调用 const mockSpawnResult = spawn.mock.results[0].value; expect(mockSpawnResult.unref).toHaveBeenCalled(); }); });
方法2:通过Object.defineProperty绕过只读限制(不推荐,仅作备选)
如果不想模拟整个模块,可以通过重新定义属性的方式绕过只读限制,再进行spy:
import { describe, expect, test, jest } from "@jest/globals"; import * as child_process from "child_process"; import ComposeFactory from "../src/factory"; describe("ComposeFactory", () => { let spawnSpy: jest.SpyInstance; beforeEach(() => { // 重新定义spawn属性,设置为可写 Object.defineProperty(child_process, "spawn", { writable: true, value: jest.fn(function(this: any) { this.unref = jest.fn(); console.log("Correctly mocked spawn"); return this; }) }); spawnSpy = child_process.spawn as jest.SpyInstance; }); afterEach(() => { // 测试后恢复所有mock jest.restoreAllMocks(); }); test("create方法应触发mock的spawn", async () => { const factory = new ComposeFactory("test"); await factory.create("test-container"); expect(spawnSpy).toHaveBeenCalled(); }); });
问题原因
ESM的命名导入和模块对象(import * as child_process)上的属性在TS转译或Node.js的ESM处理机制下,可能并非同一引用。因此直接jest.spyOn(child_process, "spawn")无法影响命名导入的spawn,而jest.mock是直接替换模块的导出内容,能确保命名导入的是mock后的函数。
内容的提问来源于stack exchange,提问作者ChrisAB
相关产品推荐
相关产品推荐

