-
-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
4 changed files
with
583 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
const { resolve } = require('path') | ||
const t = require('../') | ||
const Mock = require('../lib/mock.js') | ||
|
||
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() | ||
}) |
Oops, something went wrong.