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 5 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
47 changes: 47 additions & 0 deletions docs/src/content/docs/api/mocks/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: Testing with Mocks
section:
---
# Testing with Mocks

Mocking/Stubbing parts of the codebase 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 artificial replacement you might need in the current tests.

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

```js
// in tests you would usually require a module to be tested, like:
// const myModule = require('../my-module')
//
// instead you use t.mock() to require that module and pick
// which of its required modules to replace on the fly:
const myModule = t.mock('../my-module', {
'fs': {
readFileSync: () => throw new Error('oh no')
},
'./util/my-helper.js': {
foo: () => 'bar'
}
})

// run tests!
```

## Do not mess with my require.cache

`t.mock()` focus in a single-pattern that consists in hijacking the internal
`require` calls and injecting any mock you provide through that. It will not
try to replace modules from Node's internal `require.cache` or really do any
extra work other than that.

## Alternatives

In case you find yourself needing a more robust solution, here are some of
the mocking libraries that inspired this API:

- [`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
48 changes: 48 additions & 0 deletions lib/mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const Module = require('module')

class Mock {
constructor(filename, mocks) {
this.filename = filename
this.mocks = mocks

if (!filename || typeof filename !== 'string') {
throw new TypeError('filename should be a string')
}

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

const filePath = Module._resolveFilename(filename, module.parent)
ruyadorno marked this conversation as resolved.
Show resolved Hide resolved
if (!Module._cache[filePath]) {
Module._load(filename, module.parent, false)
}

this.original = Module._cache[filePath]
const originalRequire = this.original.require

const mod = Object.assign(
Object.create(Module.prototype),
this.original
)

mod.require = function requireMock(id) {
if (Object.prototype.hasOwnProperty.call(mocks, id))
return mocks[id]

return originalRequire.call(this, id)
}

mod.loaded = false
mod.load(mod.filename)
this.module = mod.exports
}

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

module.exports = Mock
5 changes: 5 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,10 @@ class Test extends Base {
return new Fixture(type, content)
}

mock (filename, mocks) {
return Mock.get(filename, mocks)
}

matchSnapshot (found, message, extra) {
this.currentAssert = Test.prototype.matchSnapshot

Expand Down
88 changes: 88 additions & 0 deletions test/mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const { resolve } = require('path')
const t = require('../')
const Mock = require('../lib/mock.js')

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

t.throws(
() => Mock.get('./foo.js'),
/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');
module.exports = function() {
return [inspect, lorem, b, c].map(i => i({})).join(' ')
};
`,
'b.js': `module.exports = function () { return 'b' }`,
utils: {
'c.js': `module.exports = function () { return 'c' }`
},
}
})

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

t.equal(
require(resolve(path, 'lib/a.js'))(),
'{} lorem b c',
'should use original module when requiring prior to mocking'
)

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

t.equal(
Mock.get(resolve(path, 'lib/a.js'), {
'lorem': () => '***',
})(),
'{} *** b c',
'should mock node_modules package'
)

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

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

t.end()
})
14 changes: 14 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1222,3 +1222,17 @@ t.test('save a fixture', t => {
t.ok(fs.statSync(leaveDir).isDirectory(), 'left dir behind')
t.end()
})

t.test('require defining mocks', t => {
const f = t.testdir({
node_modules: {
foo: 'module.exports = { bar: () => "bar" }'
},
'index.js': 'module.exports = require("foo").bar()'
})
const myModule = t.mock(path.resolve(f, 'index.js'), {
foo: { bar: () => 'lorem' }
})
t.equal(myModule, 'lorem', 'should mock internally required modules')
t.end()
})