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 td.instance() #448

Merged
merged 3 commits into from
May 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ signup = td.replace(app, 'signup', {
})
```

### `td.func()`, `td.object()`, `td.constructor()`, and `td.imitate()` to create test doubles
### `td.func()`, `td.object()`, `td.constructor()`, `td.instance()` and `td.imitate()` to create test doubles

`td.replace()`'s imitation and injection convenience is great when your
project's build configuration allows for it, but in many cases you'll want or
Expand Down Expand Up @@ -332,6 +332,28 @@ const subject = function (SomeConstructor) {
}
```

#### `td.instance()`
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please help me refine this documentation section


If your code depends on ES classes or functions, then the `td.instance()` function
will create a mock constructor and return a new instance of that mock constructor.

The following code snippets are functionally equiavalent:

```js
const fakeObject = td.instance(RealConstructor)
td.when(fakeObject.doStuff()).thenReturn('just did it')

fakeObject.doStuff() // returns "just did it"
```

```js
const FakeConstructor = td.constructor(RealConstructor)
const fakeObject = new FakeConstructor()
td.when(fakeObject.doStuff()).thenReturn('just did it')

fakeObject.doStuff() // returns "just did it"
```

#### `td.imitate()`

**`td.imitate(realThing[, name])`**
Expand Down
7 changes: 7 additions & 0 deletions examples/node-ava/test/lib/instance-of-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,11 @@ test('instance-of', function (t) {
t.true(td.explain(eightiesGuy.hairspray).isTestDouble)
t.true(eightiesGuy instanceof EightiesGuy)
t.true(eightiesGuy instanceof Person)

const otherGuy = td.instance(EightiesGuy)

t.true(td.explain(otherGuy.age).isTestDouble)
t.true(td.explain(otherGuy.hairspray).isTestDouble)
t.true(otherGuy instanceof EightiesGuy)
t.true(otherGuy instanceof Person)
})
7 changes: 7 additions & 0 deletions examples/node/test/lib/instance-of-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,11 @@ module.exports = function () {
assert.ok(td.explain(eightiesGuy.hairspray).isTestDouble)
assert.ok(eightiesGuy instanceof EightiesGuy)
assert.ok(eightiesGuy instanceof Person)

const otherGuy = td.instance(EightiesGuy)

assert.ok(td.explain(otherGuy.age).isTestDouble)
assert.ok(td.explain(otherGuy.hairspray).isTestDouble)
assert.ok(otherGuy instanceof EightiesGuy)
assert.ok(otherGuy instanceof Person)
}
19 changes: 19 additions & 0 deletions examples/plain-html/test/math-problem-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,22 @@ describe('MathProblem', function () {
expect(result).toEqual('neat')
})
})

describe('MathProblem (td.instance)', function () {
var subject, createRandomProblem, submitProblem
beforeEach(function () {
createRandomProblem = td.function('createRandomProblem')
submitProblem = td.function('submitProblem')
subject = new MathProblem(createRandomProblem, td.instance(SavesProblem), submitProblem)
})

it('POSTs a random problem', function () {
td.when(createRandomProblem()).thenReturn('some problem')
td.when(subject.savesProblem.save('some problem')).thenReturn('saved problem')

var result = subject.generate()

td.verify(submitProblem('saved problem'))
expect(result).toEqual('neat')
})
})
21 changes: 20 additions & 1 deletion examples/webpack/test/math-problem-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ MathProblem.prototype.generate = function(){
}

var td = require('testdouble')
describe('MathProblem', function(){

describe('MathProblem', function() {
var subject, createRandomProblem, FakeSavesProblem, submitProblem;
beforeEach(function(){
createRandomProblem = td.function('createRandomProblem')
Expand All @@ -35,3 +36,21 @@ describe('MathProblem', function(){
expect(result).toEqual('neat')
})
})

describe('MathProblem (td.instance)', function() {
var subject, createRandomProblem, submitProblem;
beforeEach(function(){
createRandomProblem = td.function('createRandomProblem')
submitProblem = td.function('submitProblem')
subject = new MathProblem(createRandomProblem, td.instance(SavesProblem), submitProblem)
})
it('POSTs a random problem', function() {
td.when(createRandomProblem()).thenReturn('some problem')
td.when(subject.savesProblem.save('some problem')).thenReturn('saved problem')

var result = subject.generate()

td.verify(submitProblem('saved problem'))
expect(result).toEqual('neat')
})
})
25 changes: 21 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,23 @@ export function constructor<T>(
constructor: Constructor<T>
): TestDoubleConstructor<T>;

//
// fake: instance objects
//

/**
* Construct an instance of a faked class.
*
* @export
* @template T
* @param {{ new (...args: any[]): T }} constructor
* @returns {DoubledObject<typeof T>}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was not sure what the return type should be here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lgandecki as resident typescript typings maintainer could you please help with this / add a suggestion for what it should be?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing. Looks correct. I've also verified it by cloning the branch and playing around with it.
Thanks for your work @woldie .
I guess you could possibly get similar results using td.object(Class), but that uses proxy.. and is a bit less safe if used without the additional help of typescript

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @lgandecki! I agree it would be more convenient to have td.object(Class) just do it all, but I think it's probably important to cordon off this particular convenience method since it is only a wrapper for a call td.constructor, but then drops the constructor on the floor when it is done with it, intentionally hiding the ability to mock static class methods.

Looking at this particular change again, I think my use of typeof T is in the wrong spot for what I want here. I use JSDoc to specify types in my home project and I cribbed from it, but I think I cribbed without checking my work. typeof T describes a constructor of type T when used in conjunction with T by itself which is meant to indicate an instance of T. When I do a good job with my JSDoc, it works just as well as TypeScript for most things in terms of the quality of IntelliSense and inspections within IntelliJ.

In my home project, I have the need to annotate a constructor, and I needed IntelliJ to understand that the return value from the annotation function was the constructor. Here's how I expressed that in JSDoc:

/**
 * @param {typeof T} ctor constructor function.
 * @param {...(number|string)} args metadata about ctor.
 * @returns {typeof T} annotated constructor function
 * @template T
 */
Injector.prototype.annotateConstructor = function (ctor, args) {
  ...
}

If the JSDoc part of a TypeScript declaration understands typeof T the same way, then the TypeScript type declaration should read:

/**
 * Construct an instance of a faked class.
 *
 * @export
 * @template T
 * @param {typeof T} constructor
 * @returns {DoubledObject<T>}
 */
export function instance<T>(
  constructor: Constructor<T>
): DoubledObject<T>;

I'll try this out later today and see how IntelliJ interprets it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I'm unfortunately not really familiar with JSDocs :-(

*/
export function instance<T>(
constructor: Constructor<T>
): DoubledObject<T>;


//
// fake: functions
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -252,7 +269,7 @@ export function imitate<T>(original: T, name?: string): TestDouble<T>;
export function callback(...args: any[]): void;

/**
* Swap out real dependenencies with fake one. Intercept calls to `require`
* Swap out real dependencies with fake one. Intercept calls to `require`
* that dependency module and ensure your subject is handed a fake instead.
*
* @export
Expand All @@ -262,7 +279,7 @@ export function callback(...args: any[]): void;
*/
export function replace(path: string, f?: any): any;
/**
* Swap out real dependenencies with fake one. Intercept calls to `require`
* Swap out real dependencies with fake one. Intercept calls to `require`
* that dependency module and ensure your subject is handed a fake instead.
*
* @export
Expand All @@ -272,7 +289,7 @@ export function replace(path: string, f?: any): any;
*/
export function replaceCjs(path: string, f?: any): any;
/**
* Swap out real dependenencies with fake one. Intercept calls to `import`
* Swap out real dependencies with fake one. Intercept calls to `import`
* that dependency module and ensure your subject is handed a fake instead.
*
* @export
Expand All @@ -285,7 +302,7 @@ export function replaceEsm(path: string, namedExportStubs?: any, defaultExportSt
Promise<{default?: any, [namedExport: string]: any}>;

/**
* Swap out real dependenencies with fake one. Reference to the property will
* Swap out real dependencies with fake one. Reference to the property will
* be replace it during your test.
*
* @export
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import tdFunction from './function'
import object from './object'
import constructor from './constructor'
import instance from './instance'
import imitate from './imitate'
import when from './when'
import verify from './verify'
Expand All @@ -18,6 +19,7 @@ module.exports = {
func: tdFunction,
object,
constructor,
instance,
imitate,
when,
verify,
Expand Down
2 changes: 2 additions & 0 deletions src/index.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import function2 from './function.js'
import object2 from './object.js'
import constructor2 from './constructor.js'
import instance2 from './instance.js'
import imitate2 from './imitate/index.js'
import when2 from './when.js'
import verify2 from './verify.js'
Expand All @@ -14,6 +15,7 @@ import version2 from './version.js'
export const func = function2.default
export const object = object2.default
export const constructor = constructor2.default
export const instance = instance2.default
export const imitate = imitate2.default
export const when = when2.default
export const verify = verify2.default
Expand Down
5 changes: 5 additions & 0 deletions src/instance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import constructor from './constructor.js'

export default function instance (typeOrNames) {
return new (constructor(typeOrNames))()
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can redo this if you'd like to recommend a style that better matches the rest of the codebase. Can do the _.tap thing if you think it looks better.

2 changes: 1 addition & 1 deletion src/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ var createTestDoublesForFunctionNames = (names) =>
}, {})

var ensureFunctionIsNotPassed = () =>
log.error('td.object', 'Functions are not valid arguments to `td.object` (as of testdouble@2.0.0). Please use `td.function()` or `td.constructor()` instead for creating fake functions.')
log.error('td.object', 'Functions are not valid arguments to `td.object` (as of testdouble@2.0.0). Please use `td.function()`, `td.constructor()` or `td.instance()` instead for creating fake functions.')

var ensureOtherGarbageIsNotPassed = () =>
log.error('td.object', `\
Expand Down
55 changes: 55 additions & 0 deletions test/safe/instance.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
let Thing, SuperThing, fakeInstance
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pared down version of constructor.test.js with assertions I felt were safe to make


module.exports = {
'being given an mock object instance': {
beforeEach () {
SuperThing = function SuperThing () {}
SuperThing.prototype.biz = () => 1
Object.defineProperties(SuperThing.prototype, {
secretFunc: {
value () {},
enumerable: false,
writable: true
}
})

Thing = function Thing () {}
Thing.prototype = new SuperThing()
Thing.prototype.foo = () => 2
Thing.bar = () => 3
Thing.prototype.instanceAttr = 'baz'
Thing.staticAttr = 'qux'
Object.defineProperties(Thing, {
secretStaticFunc: {
value () {},
enumerable: false,
writable: true
}
})

fakeInstance = td.instance(Thing)
},
'instance methods can be stubbed' () {
td.when(fakeInstance.foo()).thenReturn(7)

assert._isEqual(fakeInstance.foo(), 7)
},
'super type methods can be stubbed, too' () {
td.when(fakeInstance.biz()).thenReturn(6)

assert._isEqual(fakeInstance.biz(), 6)
},
'things print OK' () {
assert._isEqual(fakeInstance.foo.toString(), '[test double for "Thing.prototype.foo"]')
},
'non-enumerables are covered' () {
assert._isEqual(td.explain(fakeInstance.secretFunc).isTestDouble, true)
},
'instanceof checks out' () {
assert._isEqual(fakeInstance instanceof Thing, true)
},
'original attributes are carried over' () {
assert._isEqual(fakeInstance.instanceAttr, 'baz')
}
}
}
2 changes: 1 addition & 1 deletion test/safe/object.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ module.exports = {
td.object(function () {})
assert.fail('This should have errored!')
} catch (e) {
assert.ok(/Please use `td\.function\(\)` or `td\.constructor\(\)` instead/.test(e.message))
assert.ok(/Please use `td\.function\(\)`, `td\.constructor\(\)` or `td\.instance\(\)` instead/.test(e.message))
}
},
'passing an Object.create()d thing' () {
Expand Down
1 change: 1 addition & 0 deletions test/safe/testdouble.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
assert._isEqual(td.imitate, require('../../src/imitate').default)
assert._isEqual(td.object, require('../../src/object').default)
assert._isEqual(td.constructor, require('../../src/constructor').default)
assert._isEqual(td.instance, require('../../src/instance').default)
assert._isEqual(td.matchers, require('../../src/matchers').default)
assert._isEqual(td.callback, require('../../src/callback').default)
assert._isEqual(td.explain, require('../../src/explain').default)
Expand Down
7 changes: 7 additions & 0 deletions test/safe/typescript-typings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export = {
td.when(bear.sleep()).thenReturn('zzzzzz')

assert.strictEqual(bear.sleep(), 'zzzzzz')

const instanceBear = td.instance<Bear>(Bear)
assert.strictEqual(instanceBear instanceof Bear, true)
assert.strictEqual(
td.explain(instanceBear.sleep).isTestDouble,
true
)
}

const testObject = {
Expand Down
7 changes: 7 additions & 0 deletions test/safe/verify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ module.exports = {
td.verify(testDoubleObj.prototype.baz())
}, /verification on test double `Foo.prototype.baz`/)
assert._isEqual(testDoubleObj.prototype.biz, 'not a function!')

const testDoubleInstance = td.instance(SomeType)

assert.throws(() => {
td.verify(testDoubleInstance.baz())
}, /verification on test double `Foo.prototype.baz`/)
assert._isEqual(testDoubleInstance.biz, 'not a function!')
},
'with a test double *as an arg* to another': {
'with an unnamed double function _as an arg_' () {
Expand Down
2 changes: 2 additions & 0 deletions test/unit/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module.exports = () => {
const func = td.replace('../../src/function').default
const object = td.replace('../../src/object').default
const constructor = td.replace('../../src/constructor').default
const instance = td.replace('../../src/instance').default
const { default: replace, replaceEsm } = td.replace('../../src/replace')
const imitate = td.replace('../../src/imitate').default
// Stubbing & Verifying
Expand All @@ -26,6 +27,7 @@ module.exports = () => {
function: func,
object: object,
constructor: constructor,
instance: instance,
replace: replace,
replaceEsm: replaceEsm,
imitate: imitate,
Expand Down