Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mock API #698

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/src/content/docs/api/mocks/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: Testing with Mocks
section:
---
# Testing with Mocks

Mocking modules is a great tool to help with increasing test coverage,
specially in parts of the code that are harder to reach with integration tests.

The Mock API is a helper that makes it easy to swap internally required
modules with any replacement you might need in the current tests.

Using `t.mock()` in practice is as simple as:

```js
// use t.mock() to require a module while replacing
// its internal required modules for mocked replacements:
const myModule = t.mock('../my-module', {
'fs': {
readFileSync: () => throw new Error('oh no')
},
'../util/my-helper.js': {
foo: () => 'bar'
}
})

// run tests, e.g:
t.equal(myModule.fnThatUsesMyHelper(), 'bar')
```

## Alternatives

In case you find yourself needing a more robust solution one that for example,
also handles CommonJS cache and more. Here are some of the mocking libraries
that inspired this API, you might want to give them a try:

- [`require-inject`](https://www.npmjs.com/package/require-inject)
- [`proxyquire`](https://www.npmjs.com/package/proxyquire)
12 changes: 11 additions & 1 deletion docs/src/content/docs/structure/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,9 @@ way to perform the same action in two different ways, but yielding the same
result. In a case like this, you can define both of them as children of a
shared parent subtest for the feature. In this example, we're using a
[fixture](/docs/api/fixtures/) which will get automatically removed after
the subtest block is completed.
the subtest block is completed and requiring our module defining
[mocks](/docs/api/mocks/) which is only going to be available in this scope.


```js
t.test('reads symbolic links properly', t => {
Expand All @@ -290,6 +292,14 @@ t.test('reads symbolic links properly', t => {
link: t.fixture('symlink', 'file'),
})

// requires a module while mocking
// one of its internally required module
const myModule = t.mock('../my-module.js', {
fs: {
readFileSync: () => 'file'
}
})

// test both synchronously and asynchronously.
// in this case we know there are 2 subtests coming,
// but we could also have called t.end() at the bottom
Expand Down
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 @@ -31,6 +31,7 @@ const path = require('path')
const fs = require('fs')
const rimraf = require('rimraf')
const Fixture = require('./fixture.js')
const Mock = require('./mock.js')
const cleanYamlObject = require('./clean-yaml-object.js')

const extraFromError = require('./extra-from-error.js')
Expand Down Expand Up @@ -1281,6 +1282,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) {
this.currentAssert = Test.prototype.matchSnapshot

Expand Down
169 changes: 169 additions & 0 deletions test/mock.js
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()
})