vitest icon indicating copy to clipboard operation
vitest copied to clipboard

Add `vi.createMockFromModule` (Like `jest.createMockFromModule`)

Open segevfiner opened this issue 1 year ago β€’ 14 comments

Clear and concise description of the problem

I want to create a manual mock (__mocks__), but only specialize parts of the default mock that is automatically created by vitest, in Jest I could do:

import { jest } from '@jest/globals';

const mod = jest.createMockFromModule<typeof import('../foo')>('../foo');
mod.bar = jest.fn(() => 'qux');
export = mod;

But createMockFromModule is not available in Vitest, and importMock is not the same, as when used inside a manual mock, it just ends up importing the manual mock itself cyclicly. createMockFromModule always returns the automatically created mock object even if there is a manual mock.

Suggested solution

Add vi.createMockFromModule or under a different name that better fits it's async nature in vitest, that acts like vi.importMock but always return an automatically created mock even if there is a manual mock.

Alternative

Manually mock everything in the manual mock, which is cumbersome and harder to maintain.

Additional context

Encountered while migrating some tests from Jest to Vitest due to Jest's ESM issues.

https://github.com/vitest-dev/vitest/discussions/3718

Validations

segevfiner avatar Apr 03 '24 14:04 segevfiner

But createMockFromModule is not available in Vitest, and importMock is not the same, as when used inside a manual mock, it just ends up importing the manual mock itself cyclicly.

Is this maybe a bug? Or if it's causing some infinite loop, that needs to be fixed at least?

hi-ogawa avatar Apr 04 '24 00:04 hi-ogawa

Not an infinite loop, you are just getting your own module namespace object as far as I can tell. And I think importMock is supposed to import manual mocks so not a bug as far as I can tell.

segevfiner avatar Apr 04 '24 05:04 segevfiner

And I think importMock is supposed to import manual mocks so not a bug as far as I can tell.

It's called importMock, which sounds like an opposite of importActual https://vitest.dev/api/vi.html#vi-importactual but I thought the intent is simply "import actual + auto mocking" and could behave like Jest's createMockFromModule https://jestjs.io/docs/jest-object#jestcreatemockfrommodulemodulename.

https://github.com/vitest-dev/vitest/blob/e4e939ba2ab48c67ee14b82ec957fc9a8a52756c/packages/vitest/src/runtime/mocker.ts#L437-L455

Btw, can you setup a small reproduction to illustrate what you want to do with a concrete code? That would be helpful anyway as a test case if it's implemented. Also it would also help for us to suggest a workaround with what's currently possible.

One idea just came to my mind is to still use vi.mock and overwrite some exports like this:

// use `setupFiles` to setup mock for all test files

import { vi, beforeEach } from "vitest"
import * as fooLib from "./foo";

// setup auto mocking for a whole module
vi.mock("./foo");

beforeEach(() => {
  // then customize some named export
  vi.mocked(fooLib).bar.mockImplementation(() => 'qux');
});

hi-ogawa avatar Apr 04 '24 08:04 hi-ogawa

@hi-ogawa Here is an attempt to show what I'm trying to do, though a bit of a contrived example, I hope it shows what I'm trying to do: https://github.com/segevfiner/vitest-partial-manual-mock

segevfiner avatar Jun 10 '24 11:06 segevfiner

I don't think we can support any dynamic imports for this use case because ESM needs to know every named import during parsing, but maybe we can support something like this:

// __mocks__/foo.js

export * from '../foo.js' with { mock: 'auto' }
export const bar = vi.fn(() => 'qux')

sheremet-va avatar Jun 10 '24 12:06 sheremet-va

I don't think we can support any dynamic imports for this use case because ESM needs to know every named import during parsing, but maybe we can support something like this:

// __mocks__/foo.js

export * from '../foo.js' with { mock: 'auto' }
export const bar = vi.fn(() => 'qux')

AFAIK you can do dynamic imports in ESM, aka import(), though it is async so you will need top level await, what's not supported is dynamic export, e.g. replace the entire exports of this module with the given object, which is why I had to use export = which kinda fakes it by transpiling to CJS (module.exports = ...), but a valid way of doing this without relying on some special handling of that syntax by Vitest might be needed as you suggested.

segevfiner avatar Jun 10 '24 14:06 segevfiner

AFAIK you can do dynamic imports in ESM, aka import(), though it is async so you will need top level await, what's not supported is dynamic export

Yes, this is what I meant. The JS engine cannot parse the file to see all exported variables if you use a dynamic import because there is no mechanism to dynamically export variables.

e.g. replace the entire exports of this module with the given object, which is why I had to use export = which kinda fakes it by transpiling to CJS (module.exports = ...), but a valid way of doing this without relying on some special handling of that syntax by Vitest might be needed as you suggested.

This only works in Node.js runner because Vitest supports loading source code as CJS, this will not work in the browser (and module mocking is supported in the browser mode in the latest beta). This will also not work in future versions of Vitest because we cannot process files that were imported using require (you can currently import this file, but you cannot import other things in this file without leaving Vitest module system because all imports are transformed into require if it uses the CJS transform).

on some special handling of that syntax by Vitest might be needed as you suggested.

Also, export = is not an ESM syntax and it requires special handling to process already. My proposal already works with the ESM syntax, and it only requires the processing of the foo file, not the __mocks__/foo file.

sheremet-va avatar Jun 10 '24 14:06 sheremet-va

To achieve the desired behavior, I used vi.mock within the __mocks__ file:

// __mocks__/foo.js

vi.mock('../foo.js', async (importOriginal) => {
  const original = await importOriginal();
  return {
    ...original,
    bar: vi.fn(() => 'qux')
  }
});

In the test file, I included:

vi.mock('./foo.js');

Initially, I wasn’t certain this logic would work, given that vi.mock is running within the __mocks__ directory. While it achieves the same behavior as jest.createMockFromModule, this approach may be impacted by future updates in Vitest, so it’s something to monitor.

rezam7596 avatar Oct 31 '24 09:10 rezam7596

Last time I tried, I got an infinite loop from vi.mock as it imported back the same manual mock module I'm currently defining.

segevfiner avatar Nov 04 '24 17:11 segevfiner

For me, with the latest version (2.1.4), it worked

rezam7596 avatar Nov 05 '24 14:11 rezam7596

There was a related discussion with a similar use case https://github.com/vitest-dev/vitest/discussions/7548 and now I found that there might be an createMockFromModule alternative by directly using automocking utility from @vitest/mocker. Here is an example to mock all exports except one. cc @GerroDen

https://stackblitz.com/github/hi-ogawa/reproductions/tree/main/vitest-5482-createMockFromModule?file=foo.spec.ts

vi.mock(import("./foo"), async (importOriginal) => {
  const original = await importOriginal();
  // use automocking utility provided by @vitest/mocker
  const { vi } = await import("vitest");
  const { mockObject } = await import("@vitest/mocker");
  const mocked = mockObject(
    {
      type: "automock",
      spyOn: vi.spyOn,
      globalConstructors: {
        Object,
        Function,
        RegExp,
        Array,
        Map,
      },
    },
    original,
  );
  return {
    ...mocked,               // mock all exports
    addOne: original.addOne, // expect this one
  };
});

Perhaps we can support this vi.importActual + mockObject combo as vi utility. (Or should vi.importMock already work like that?)

hi-ogawa avatar Feb 27 '25 02:02 hi-ogawa

For OP's case https://github.com/vitest-dev/vitest/issues/5482#issuecomment-2158144013, vi.importActual + mockObject should work though I think the main concern is about simulating export = mod without writing each named export in __mocks__ file.

Example: __mocks__/foo.ts
// __mocks__/foo.ts
import { vi } from 'vitest';
import { mockObject } from '@vitest/mocker';

const original = await vi.importActual('../foo');
const mocked = mockObject(
  {
    type: "automock",
    spyOn: vi.spyOn,
    globalConstructors: {
      Object,
      Function,
      RegExp,
      Array,
      Map,
    },
  },
  original,
);
export const foo = mocked.foo;
export const bar = mocked.bar;
export const hello = vi.fn(() => 'TEST');

hi-ogawa avatar Feb 27 '25 02:02 hi-ogawa

It would be hard to understand the difference between a createMockFromModule and importMock just from the naming. Thus I was just guessing a similar behaviour but the different naming suggested it worked differently to jest's implementation. My point is, having both functions at the same time could be confusing.

GerroDen avatar Feb 27 '25 10:02 GerroDen

❌ Problem: importMock() Inside vi.mock() = πŸ’₯ Infinite Recursion

Calling importMock() inside a vi.mock() block is a trap: It tries to load the very module you're currently mocking β†’ triggers vi.mock() again β†’ infinite loop β†’ boom.


βœ… Correct Approach: Use importOriginal

Never use importMock() inside vi.mock(). Instead, Vitest provides importOriginal exactly for this purpose:

vi.mock('some-module', async (importOriginal) => {
  const original = await importOriginal<typeof import('some-module')>()
  const { mockObject } = await import('vitest/mocker')
  return mockObject({ type: 'automock', spyOn: vi.spyOn }, original)
})

βœ… Clean Solution: Mock Factory Pattern

Here’s a robust and reusable pattern using a hoisted mock factory. Example: mocking the @pinecone-database/pinecone module.

import { describe, it, expect, vi, beforeEach, type MockedObject } from 'vitest'
import { mockObject } from 'vitest/mocker'

type PineconeModule = typeof import('@pinecone-database/pinecone')
type MockedPineconeModule = MockedObject<PineconeModule>

const mockFactory = vi.hoisted(() => {
  let mockedModule: MockedPineconeModule

  const createAndStoreMockedModule = async (): Promise<MockedPineconeModule> => {
    const original = await vi.importActual<PineconeModule>('@pinecone-database/pinecone')
    const module = mockObject(
      {
        type: 'automock',
        spyOn: vi.spyOn,
        globalConstructors: { Object, Function, RegExp, Array, Map }
      },
      original
    ) as MockedPineconeModule

    mockedModule = module
    return module
  }

  return {
    getMockedPineconeModule: (): MockedPineconeModule => mockedModule,
    createAndStoreMockedModule
  }
})

vi.mock('@pinecone-database/pinecone', async () => {
  return mockFactory.createAndStoreMockedModule()
})

And then in your tests:

describe('PineconeService', () => {
  let service: PineconeService
  let mockedPinecone: MockedPineconeModule

  beforeEach(() => {
    mockedPinecone = mockFactory.getMockedPineconeModule()
    service = createStandardPineconeService()
  })

  it('βœ… should initialize with correct API key and namespace', () => {
    expect(mockedPinecone.Pinecone).toHaveBeenCalledWith({ apiKey: env.PINECONE_API_KEY })
    expect(Reflect.get(service, '_namespace')).toBe(env.PINECONE_RULES_NAMESPACE)
  })
})

🧠 Recap

Action Context Status
❌ importMock() Inside vi.mock() ❌ Never
βœ… importOriginal() + mockObject() Inside vi.mock() βœ… Correct
βœ… importMock() Outside vi.mock() βœ… Safe
βœ… Use mock factory wrapper Anywhere πŸ’ͺ Best practice

CyberT33N avatar May 31 '25 23:05 CyberT33N

I wonder if just vi.mockObject(vi.importOriginal('module')) is enough now for the first part? The docs describe module.exports = being usable in __mocks__ in .cjs files. I wonder if export = can work in .cts file and then essentially the original usage described in this issue description can simply work?

segevfiner avatar Aug 18 '25 16:08 segevfiner