Skip to content

Commit

Permalink
Adds t.mock() API
Browse files Browse the repository at this point in the history
This brings into **tap** the standard mocking system we have been using
across the ecosystem of packages from the npm cli which consists into
hijacking the `require` calls from a given module and defining whatever
mock we want via something as simple as a key/value object.

It's a very conscious decision to make it a very opinionated API, as
stated in https://github.com/tapjs/node-tap#tutti-i-gusti-sono-gusti -
focusing only on the pattern that have been the standard way we handle
mocks.

It builds on the initial draft work from @nlf (ref:
https://gist.github.com/nlf/52ca6adab49e5b3939ba37c7f0fc51c6) and
initial brainstorming of such an API with @mikemimik - thanks ❤️

Example:

```js
t.test('testing something, t => {
  const myModule = t.mock('../my-module.js', {
    fs: { readFileSync: () => 'foo' }
  })

  t.equal(myModule.bar(), 'foo', 'should receive expected content')
})
```

Credit: @ruyadorno, @nlf
Reviewed-by: @isaacs
PR-URL: tapjs/tapjs#698
Closes: tapjs/tapjs#698
  • Loading branch information
ruyadorno authored and isaacs committed Feb 16, 2021
1 parent 278fe50 commit 1e9ff26
Show file tree
Hide file tree
Showing 4 changed files with 589 additions and 0 deletions.
84 changes: 84 additions & 0 deletions lib/mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const Module = require('module')
const { isAbsolute } = require('path')

const isPlainObject = obj => obj
&& typeof obj === 'object'
&& (Object.getPrototypeOf(obj) === null
|| Object.getPrototypeOf(obj) === Object.prototype)

class Mock {
constructor(parentFilename, filename, mocks = {}) {
this.filename = filename
this.mocks = new Map()

if (!parentFilename || typeof parentFilename !== 'string') {
throw new TypeError('A parentFilename is required to resolve Mocks paths')
}

if (!filename || typeof filename !== 'string') {
throw new TypeError('t.mock() first argument should be a string')
}

if (!isPlainObject(mocks)) {
throw new TypeError(
'mocks should be a a key/value object in which keys ' +
`are the same used in ${filename} require calls`
)
}

const self = this
const callerTestRef = Module._cache[parentFilename]
const filePath = Module._resolveFilename(filename, callerTestRef)

// populate mocks Map from resolved filenames
for (const key of Object.keys(mocks)) {
const mockFilePath = Module._resolveFilename(key, callerTestRef)
this.mocks.set(mockFilePath, mocks[key])
}

// keep a cache system for non-mocked files
const seen = new Map()

class MockedModule extends Module {
require (id) {
const requiredFilePath = Module._resolveFilename(id, this)

// if it's a mocked file, just serve that instead
if (self.mocks.has(requiredFilePath))
return self.mocks.get(requiredFilePath)

// builtin, not-mocked modules need to be loaded via regular require fn
const isWindows = process.platform === 'win32';
/* istanbul ignore next - platform dependent code path */
const isRelative = id.startsWith('./') ||
id.startsWith('../') ||
((isWindows && id.startsWith('.\\')) ||
id.startsWith('..\\'))
if (!isRelative && !isAbsolute(id))
return super.require(id)

// if non-mocked file that we've seen, return that instead
// this enable cicle-required deps to work
if (seen.has(requiredFilePath))
return seen.get(requiredFilePath).exports

// load any not-mocked module via our MockedModule class
// also sets them in our t.mock realm cache to avoid cicles
const unmockedModule = new MockedModule(requiredFilePath, this)
seen.set(requiredFilePath, unmockedModule)
unmockedModule.load(requiredFilePath)
return unmockedModule.exports
}
}

this.module = new MockedModule(filePath, callerTestRef)
this.module.load(filePath)
}

static get(parentFilename, filename, mocks) {
const mock = new Mock(parentFilename, filename, mocks)
return mock.module.exports
}
}

module.exports = Mock
7 changes: 7 additions & 0 deletions lib/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const Stdin = require('./stdin.js')
const TestPoint = require('./point.js')
const parseTestArgs = require('./parse-test-args.js')
const Fixture = require('./fixture.js')
const Mock = require('./mock.js')
const cleanYamlObject = require('./clean-yaml-object.js')
const extraFromError = require('./extra-from-error.js')
const stack = require('./stack.js')
Expand Down Expand Up @@ -1189,6 +1190,12 @@ class Test extends Base {
return new Fixture(type, content)
}

mock (module, mocks) {
const {file} = stack.at(Test.prototype.mock)
const resolved = path.resolve(file)
return Mock.get(resolved, module, mocks)
}

matchSnapshot (found, message, extra) {
[message, extra] = normalizeMessageExtra('must match snapshot', message, extra)
this.currentAssert = Test.prototype.matchSnapshot
Expand Down
175 changes: 175 additions & 0 deletions test/mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
const { resolve } = require('path')
const t = require('../')
const Mock = require('../lib/mock.js')
const settings = require('../settings.js')

if (settings.rimrafNeeded) {
settings.rmdirRecursiveSync = dir => require('rimraf').sync(dir, {glob: false})
settings.rmdirRecursive = (dir, cb) => require('rimraf')(dir, {glob: false}, cb)
}

t.throws(
() => Mock.get(),
'A parentFilename is required to resolve Mocks paths',
'should throw on missing parentFilename',
)

t.throws(
() => Mock.get(__filename),
/first argument should be a string/,
'should throw on invalid filename',
)

t.throws(
() => Mock.get(__filename, './foo.js', ''),
/mocks should be a a key\/value object in which keys/,
'should throw on invalid mock-defining object',
)

t.throws(
() => Mock.get(__filename, './foo.js', [1]),
/mocks should be a a key\/value object in which keys/,
'should throw on invalid mock-defining object',
)

t.throws(
() => Mock.get(__filename, './foo.js', null),
/mocks should be a a key\/value object in which keys/,
'should throw on invalid mock-defining object',
)

t.throws(
() => Mock.get(__filename, './foo.js', /foo/),
/mocks should be a a key\/value object in which keys/,
'should throw on invalid mock-defining object',
)

t.throws(
() => Mock.get(__filename, './foo.js', 1),
/mocks should be a a key\/value object in which keys/,
'should throw on invalid mock-defining object',
)

t.throws(
() => Mock.get(__filename, './foo.js', new Map()),
/mocks should be a a key\/value object in which keys/,
'should throw on invalid mock-defining object',
)

t.test('mock', t => {
const path = t.testdir({
node_modules: {
lorem: {
'package.json': JSON.stringify({ name: 'lorem' }),
'index.js': `module.exports = function () { return 'lorem' }`,
},
},
lib: {
'a.js': `
const { inspect } = require('util');
const lorem = require('lorem');
const b = require('./b.js');
const c = require('./utils/c');
const d = require('../helpers/d.js');
const f = require('../f.cjs');
module.exports = function() {
return [inspect, lorem, b, c, d, f, require('../g.js')]
.map(i => i({})).join(' ')
};
`,
'b.js': `module.exports = function () { return 'b' }`,
utils: {
'c.js': `module.exports = function () { return 'c' }`
},
},
helpers: {
'd.js': `
const e = require('./e.js');
module.exports = function () { return ['d', e()].join(' ') }`,
'e.js': `module.exports = function () { return 'e' }`,
},
'f.cjs': `module.exports = function () { return 'f' }`,
'g.js': `module.exports = function () { return 'g' }`,
'h.js': `module.exports = require.resolve('./g.js')`,
'i.js': `module.exports = require('./j.js') + require('./k.js')`,
'j.js': `module.exports = require('./k.js')`,
'k.js': `module.exports = 'k'`,
})

t.equal(
Mock.get(__filename, resolve(path, 'lib/a.js'), {
[resolve(path, 'lib/b.js')]: () => 'foo',
})(),
'{} lorem foo c d e f g',
'should use injected version of a mock',
)

t.equal(
require(resolve(path, 'lib/a.js'))(),
'{} lorem b c d e f g',
'should be able to use original module post-mocking',
)

t.equal(
Mock.get(__filename, resolve(path, 'lib/a.js'), {
[resolve(path, 'helpers/d.js')]: () => 'bar',
})(),
'{} lorem b c bar f g',
'should mock module not located under the same parent folder',
)

t.equal(
Mock.get(__filename, resolve(path, 'lib/a.js'), {
[resolve(path, 'f.cjs')]: () => 'bar',
})(),
'{} lorem b c d e bar g',
'should mock module using cjs extension',
)

t.equal(
Mock.get(__filename, resolve(path, 'lib/a.js'), {
[resolve(path, 'lib/b.js')]: () => 'foo',
[resolve(path, 'lib/utils/c')]: () => 'bar',
})(),
'{} lorem foo bar d e f g',
'should mock nested module',
)

t.equal(
Mock.get(__filename, resolve(path, 'lib/a.js'), {
util: { inspect: obj => obj.constructor.prototype },
})(),
'[object Object] lorem b c d e f g',
'should mock builtin module',
)

t.equal(
require(resolve(path, 'lib/a.js'))(),
'{} lorem b c d e f g',
'should preserve original module after mocking',
)

t.equal(
Mock.get(__filename, resolve(path, 'h.js')),
resolve(path, 'g.js'),
'should preserve require properties and methods',
)

t.equal(
Mock.get(__filename, resolve(path, 'i.js')),
'kk',
'should read non-mocked cached modules from t.mock realm',
)

// lorem is an unknown module id in the context of the current script,
// trying to mock it will result in an error while trying to resolve
// the filename for generating the mocks map
t.throws(
() => Mock.get(__filename, resolve(path, 'lib/a.js'), {
lorem: () => '***',
})(),
{ code: 'MODULE_NOT_FOUND' },
'can only mock known installed modules',
)
t.end()
})

0 comments on commit 1e9ff26

Please sign in to comment.