oven-sh/bun

Implement module mocking

Closed this issue ยท 14 comments

Could we add support for jest.mock and jest.requireActual compatibility APIs, please? Module mocking is a pretty important part of jest compatibility.

Example:

import { foo } from 'some-module'

jest.mock('some-module', () => ({
  ...jest.requireActual<object>('some-module'),
  foo: jest.fn(() => 'foo'),
}))

The pattern here allows you to override just the foo method of some-module, while requireActual gives you the real implementation for the rest.

Jest also supports placing an override for some-module in a __mocks__/some-module.ts file, as another way to automatically module-mock, though imho that is less priority.

(Note: adding this as a sub-task related to #1825)

on the same thread, I would also include .importActual for Vitest and ESM

The __mocks__ method is also important imo...

Module mocking is important and I'm looking forward to see it in bun ๐Ÿ‘

Also, I believe that bun should take the opportunity to simplify a few things even though it would introduce breaking changes.

Remove hoisting

Jest uses a babel plugin to hoist certain calls to the jest object, including jest.mock. This is motivated by the need to be able to configure module mocking before any modules are loaded.
README.md

For the sake of experimentation, it's easy to break the pattern matching:

import { foo } from 'some-module' // the module loads before the mock is set up ..

const unhoistedJest = jest;
unhoistedJest.mock('some-module', () => ({ // .. because this is not hoisted
    // ...
}))

Apart from the inherent complexity in hoisting, it's probably also a bad idea for bun test to do pattern matching on jest specific statements. While Jest compatibility is a desirable aim for now, bun:test is the lasting API.

It's possible to avoid the need for hoisting by using CommonJS require and arranging the order of execution yourself:

jest.mock('some-module', () => ({
    // ...
}))

const { foo } = require("some-module") as typeof import("some-module");

Remove mocks auto loading

If a __mocks__ folder is defined then Jest will automatically load and set up mocks from this folder. The mechanism relies upon a number of conventions and it's not infallible, e.g. node built-in modules still require an explicit jest.mock in the code.

Jest documentation

As noted by @TroyAlford, we could do without the mock auto loading. I agree, and I'd argue that it would make things simpler.

Jest tests that currently depend upon auto loading mocks would have to be changed to explicitly load the mocks:

jest.mock("fs", () => require("./__mocks__/fs"));

Upon closer consideration, implementing jest.mock but with subtly altered behavior would probably lead to frustration, as I imagine that a lot of people will just be using jest without knowing about or caring about the hoisting stuff.

Also, I imagine that no matter what, the bun:test module will expose a new method for mocking modules.

In that case it might be best for bun to let jest.mock break at compile time and to write a migration guide covering:

  • replacing jest.mock with bun's module mocking
  • migrating to use CommonJS require instead of import and reordering the code
  • how to migrate from relying upon __mocks__ to mocking in code
  • what to do with the jest methods that aren't implemented as they don't make sense in bun's test runner (enableAutomock, disableAutomock, unmock, deepUnmock, doMock, dontMock, and maybe others)

EDIT: Actually, jest.doMock should be an alias for bun mock module as it's exactly that -- an unhoisted version of jest.mock. This would enable a safe migration path to a subset of jest module mocking that also works in bun.

I'd love to see this feature incorporated into Bun. Currently, it's not supported in Node.js either, and it could be a valuable addition to Bun, potentially attracting those who are considering moving away from Jest.

However, I have some concerns about relying on require("some-module") for this functionality. Wouldn't this mean that some-module has to be a CommonJS module (as specified in the docs)? While maintaining backward compatibility with CommonJS is important, mandating its use for this feature might be a step in the wrong direction.

I understand the issue related to load order and why this approach might not work with ESM. But if we were to consider an alternative approach using the __mocks__ directory and a custom Loader, wouldn't that also be a viable option?"

svi3c commented

I would love having this feature as well.
Maybe, the load order problem could be solvable with dynamic imports (and top-level await).

This approach might look similar to this:

import { describe, expect, it, mock, mockModule } from "bun:test";

const mockDependency = mock(() => "foo");

mockModule("./dependency", { dependency: mockDependency });

const { unitUnderTest } = await import("./module-under-test");

describe("unitUnderTest()", () => {
  it("should call dependency and forward return value", () => {
    const result = unitUnderTest();

    expect(mockDependency).toHaveBeenCalled();
    expect(result).toEqual("foo");
  });
});

Could we please re-open this ticket? @Jarred-Sumner's PR appears to have provided an alternative method for doing mock('moduleName'. { overrides }), but not requireActual() which is a critical part of making this work.

Many times in production code we need to make a block like:

jest.mock('./some/module', () => ({
  ...jest.requireActual('./some/module'),
  oneMethod: jest.fn(),
}))

Without the requireActual part, this only provides for mocking an entire module using this syntax, rather than a partial mock.

I'm happy to open a new issue if preferable, but I'd like to make sure this is tracked.

@TroyAlford
It works, you can use it

import { expect, test, mock } from "bun:test";
import { main, main2 } from "./module";

test("before", async () => {
  expect(main()).toBe(1);
  expect(main2()).toBe(2);
});

test("after", async () => {
  const mockDependency = () => "mocked";
  mock.module("./module.ts", () => ({ main: mockDependency }));
  expect(main()).toBe("mocked");
  expect(main2()).toBe(2);
});

For me, it doesn't work with multiple tests files: #6874

This does not work correctly when you have multiple test files referencing the same mock module. There will be collisions.

This does not work correctly when you have multiple test files referencing the same mock module. There will be collisions.

Could you please provide an example?

This does not work correctly when you have multiple test files referencing the same mock module. There will be collisions.

Any progress on this?
Running multiple test files with bun test does not work in my environment.

test1.test.ts

import { ModuleInitializer } from '@path/to/module'
import { mock } from 'bun:test';

mock.module('@path/to/module', () => {
    // mocking any function
    return { 
        ModuleInitializer: jest.fn().mockResolvedValue({
            return {
                func:  jest.fn().mockResolvedValue({
                ...
            }
         ....
})

describe('test1', () => {
    ...
    test('test1's test', () => {
        const ini = ModuleInitializer()
        ini.funk()
})

The above test file mocks a node module called Module.
Then, in another test file, we want to call the same module without mocking it.

test2.test.ts

import { ModuleInitializer } from '@path/to/module'

describe('test2', () => {
    ...
    test('test2's test', () => {
        const ini = ModuleInitializer()
        // Unexpected behavior: processing defined in test1
        ini.funk()
        // Error: 'is not function' if not defined in test1
        // anotherFunc is a function already defined in Module
        ini.anotherFunc()
})

I expected to see the unmocked mocule in test2, but in fact it behaved as mocked in test1.

I wish 'ini.anotherFunk()' could be called as a requirement to introduce bun,
I can't find a way to make it behave like 'jest.requireActual()' in jest... Is it currently difficult?

Not sure if related but bun mock is not working in the latest version and it's behaving differently from jest when the mocked module is being imported from a different file.

Detailed reproduction and more details here: #10428