Skip to content
iambumblehead edited this page Sep 11, 2023 · 110 revisions
+                                                ██╗
+ ██████╗  ███████╗ █████═████╗  ██████╗  ██████╗██║   ██╗
+██╔═══██╗██╔═════╝██╔══██╔══██╗██╔═══██╗██╔════╝██║  ██╔╝
+████████║╚██████╗ ██║  ██║  ██║██║   ██║██║     ██████╔╝
+██╔═════╝ ╚════██╗██║  ██║  ██║██║   ██║██║     ██╔══██╗
+╚███████╗███████╔╝██║  ██║  ██║╚██████╔╝╚██████╗██║  ╚██╗
+ ╚══════╝╚══════╝ ╚═╝  ╚═╝  ╚═╝ ╚═════╝  ╚═════╝╚═╝   ╚═╝

esmock provides native ESM import mocking on a per-unit basis.

Install

$ npm install --save-dev esmock

Note: For versions of node prior to v20.6.0, "--loader" command line arguments must be used with esmock as demonstrated below. Current versions of node do not require "--loader".

{
  "name": "give-esmock-a-star",
  "type": "module",
  "scripts": {
    "test": "node --loader=esmock --test",
    "test-mocha": "mocha --loader=esmock",
    "test-tap": "NODE_OPTIONS=--loader=esmock tap",
    "test-ava": "NODE_OPTIONS=--loader=esmock ava",
    "test-uvu": "NODE_OPTIONS=--loader=esmock uvu spec",
    "test-tsm": "node --loader=tsm --loader=esmock --test *ts",
    "test-ts": "node --loader=ts-node/esm --loader=esmock --test *ts",
    "test-jest": "NODE_OPTIONS=--loader=esmock jest",
    "test-tsx": "⚠ https://github.com/esbuild-kit/tsx/issues/264"
  },
  "jest": {
    "runner": "jest-light-runner"
  }
}

Hello World

import test from 'node:test'
import assert from 'node:assert/strict'
import esmock from 'esmock'

test('should mock hello world', async () => {
  const hello = await esmock('../src/hello.js', {
    '../src/icons': { world: '🌎' }
  })
  
  assert.strictEqual(hello('world'), '🌎')
})

Decide Strict or Partial

Before using esmock, decide which mocking type suits your tests —"strict mocking" or "partial mocking".

Strict mocking replaces original module definitions with mock definitions

mockStrict = mockDefs

Partial mocking merges original module definitions with mock definitions

mockPartial = Object.assign(await import('/module.js'), mockDefs)

Strict mocking is recommended for safety and clarity. For each mocked-module, the test context must define every name imported to the mock tree. When imported names are renamed in source files, mocking test files must be updated with those names. Both requirements are seen as features by advocates of strict mocking.

Partial mocking is recommended for brevity and simplicity. It allows the test context to define only test-relevant mocked-module definitions. For some situations, it makes sense to import and export all original definitions, while only stubbing those specifically mocked definitions.

Neither strict nor partial mocking are specifically recommended by esmock's author. esmock supports both strict and partial mocking and uses partial mocking by default,

  • to use "partial mocking" import and use esmock(...args)
  • to use "strict mocking" import { strict as esmock } from 'esmock' or use esmock.strict(...args)

Call esmock

esmock returns an imported module with an import tree composed of original and mock definitions,

await esmock(
  './to/module.js', // filepath to the source module being tested
  { ...childmocks }, // mock definitions imported by the source module, directly
  { ...globalmocks }) // mock definitions imported anywhere in the source import tree

Child and global "mocks" passed to esmock are both defined the same way,

const mocks = {
  // package
  eslint: { ESLint: cfg => cfg },

  // core module
  fs: { readFileSync: () => 'returns this globally' },

  // global variable
  import: { fetch: () => ({ res: 200 }) },

  // local file
  '../src/main.js': { start: 'go' },

  // subpath
  '#feature': { release: () => 'release time' },

  // default and/or named exports
  '../src/hello.js': {
    default: () => 'world',
    exportedFunction: () => 'foo'
  },

  // short-hand default exports
  '../src/init.js': () => 'init'
}

Call esmock, basic

This example mocks a local file and a package,

/src/main.js

import serializer from 'serializepkg'
import someDefaultExport from './someModule.js'

export default () => serializer(someDefaultExport())

/tests/main.js

test('should mock modules and local files', async t => {
  const main = await esmock('../src/main.js', {
    serializepkg: {
      default: obj => JSON.stringify(obj)
    },
    '../src/someModule.js' : {
      default: () => ({ foo: 'bar' })
    }
  })

  // Because `serializepkg` is mocked as a function calling JSON.stringify()
  // and `someDefaultExport` is mocked as a function returning { foo: 'bar' }
  t.is(main(), JSON.stringify({ foo: 'bar' }))
})

Call esmock, strict

Use esmock.strict or import { strict } from 'esmock' to use strict mocking that replaces (rather than merges) original module definitions with mock definitions. To demonstrate the difference, a target module and its usage with esmock,

logpath.js

import p from 'path'

console.log({
 dir: p.dirname('/dog.png'),
 base: p.basename('./dog.png')
})

logpath.test.js

import esmock, { strict } from 'esmock'

esmock('./logpath.js', { path: { basename: () => 'cat.png' } })
// "dir: /, base: cat.png"

strict('./logpath.js', { path: { basename: () => 'cat.png' } })
// Error "The requested module 'path' does not provide an export named 'dirname'"

esmock.strict('./logpath.js', { path: { basename: () => 'cat.png' } })
// Error "The requested module 'path' does not provide an export named 'dirname'"

The "strict mocking" behaviour uses the mock definition with "basename" only and "dirname" is never defined, resulting in a runtime error.

Call esmock, strictest

Use esmock.strictest or import { strictest } from 'esmock' to use the strictest version of esmock that requires every module imported to the target module to be mocked.

const strictestTree = await esmock.strictest(
  '../local/importsCoreLocalAndPackage.js', {
    path: { basename: () => 'core' },
    '../local/usesCoreModule.js': { readPath: () => 'local' },
    'form-urlencoded': () => 'package'
  }
)

assert.deepEqual(strictestTree.corePathBasename(), 'core')
assert.deepEqual(strictestTree.localReadSync(), 'local')
assert.deepEqual(strictestTree.packageFn(), 'package')

await assert.rejects(async () => esmock.strictest(
  '../local/importsCoreLocalAndPackage.js', {
    '../local/usesCoreModule.js': { readPath: () => 'local' },
    'form-urlencoded': () => 'package'
  }
), {
  name: 'Error',
  message: new RegExp(
    'un-mocked moduleId: "node:path" \\(used by .*importsCoreLocalAndPackage.js\\)')
})

Call esmock, globals

Add mock global definitions to an 'import' namespace. This example mocks the global variable 'fetch'. A new line is added to the top of the mocked file at runtime, which imports the mocked global definition like this; import { fetch } from 'file:///esmock.js'

req.js

export default async url => fetch(url + '?limit=10')

req.test.js

const mockedReq = await esmock('../src/req.js', {
  import: {
    fetch: () => '[["jim",1],["jen",2]]'
  }
})

await mockedReq() // '[["jim",1],["jen",2]]'

Call esmock, await import

Use esmock.p(...) to mock await import'd modules. When esmock.p is used, mock definitions are saved and returned later, when a module is imported dynamically. For example, let's use esmock.p() to test a different file that calls await import('eslint'),

loadConfig.js

export default async function loadConfig (config) {
  const eslint = await import('eslint')

  return new eslint.ESLint(config)
}

loadConfig.test.js

test('should mock module using `async import`', async t => {
  const loadConfig = await esmock.p('./loadConfig.js', {
    eslint: { ESLint: o => o }
  })

  t.is(await loadConfig('config'), 'config')

  // the test runner ends each test process to free memory
  // you probably don't need to call purge, demonstrated below,
  esmock.purge(loadConfig)
})

Call esmock, typescript

esmock can be chained with ts-node/esm or tsm to mock typescript files. See the examples here. Situations that use sourcemaps and transpiled sources may require tests to specify a parentUrl, usually import.meta.url, to define the relative location from which mocked modules are located.

The parentUrl can be defined as the second param, and esmock then uses a different call signature where third and fourth params define child and global mocks,

await esmock(
  './to/module.js', // filepath to the source module being tested
  import.meta.url, // parentUrl from which mocked modules should be resolved
  { ...childmocks }, // mock definitions imported by the source module, directly
  { ...globalmocks }) // mock definitions imported anywhere in the source import tree

Calling esmock with a parentUrl as the second parameter,

test('should locate modules from parentUrl, transpiled location', async () => {
  const indexModule = await esmock('../index.js', import.meta.url, {
    os: { hostname: () => 'local' }
  });
  
  assert.strictEqual(indexModule.getHostname(), 'local')
})

Call esmock, jest

jest-light-runner must be used to enable --loader with jest, as in the example test here. Set jest-light-runner as the jest runner and use NODE_OPTIONS like one of these,

Call esmock, absent module

Use the option param isModuleNotFoundError: false to mock modules not installed,

test('should mock vue package, even when package is not installed', async () => {
  const component = await esmock('../local/notinstalledVueComponent.js', {
    vue: {
      h: (...args) => args
    }
  }, {}, {
    isModuleNotFoundError: false
  })

  assert.strictEqual(component()[0], 'svg')
})

Call esmock, npm workspaces

The esmock resolver is unable to resolve modules in other workpaces. The below sample, which attempts to mock a module in another workspace, will not work,

import test from 'node:test'
import assert from 'node:assert/strict'
import esmock from 'esmock'

test('mock a module from another workspace', async () => {
  const sut = await esmock('./moduleInCurrentWorkspace.js', {
    moduleInOtherWorkspace: {
      foo: () => 'bar'
    }
  })

  assert.equal(sut(), 'bar')
})

To get around this limitation, module.createRequire can be used as seen below. Credit to @tpluscode for describing this approach.

import test from 'node:test'
import assert from 'node:assert/strict'
import esmock from 'esmock'
import module from 'module'

const require = module.createRequire(import.meta.url)

test('mock a module from another workspace', async () => {
  const sut = await esmock('./moduleInCurrentWorkspace.js', {
    [require.resolve('moduleInOtherWorkspace')]: {
      foo: () => 'bar'
    }
  })

  assert.equal(sut(), 'bar')
})

Problems or Edge Cases

Sometimes, when a module uses a complex export definition, esmock must return { default } rather than default. This is normal and sometimes necessary. Consider these different definitions returned by esmock,

const module1 = await esmock('./module1.js')
const { default: module2 } = await esmock('./module2.js')
const module3 = (await esmock('./module3.js')).default

Similarly, some mock definitions must define default explicitly as { default }. Consider these mock definitions,

const functionWithProperty = () => {}
functionWithProperty.namedValue = 'mock123'

const mocked = await esmock('./mymodule.js', {
  module1: () => 'the exported default value',
  module2: { default: () => 'the exported default value' },
  module3: { namedValue: 'mock123' },
  module4: { default: { namedValue: 'mock123' } },
  module5: { default: functionWithProperty }
})

Credit

Credit to Gil Tayar for showing a way to mock modules in his blog post here.

Clone this wiki locally