Skip to content

Commit

Permalink
feat!: support stubbed properties as well as methods (#12)
Browse files Browse the repository at this point in the history
Changes the method map passed to `stubInterface` and friends to be
a `Partial` of the stubbed type.

This way we can pass values for properties and `Sinon.stub`s for
methods, which is usually better since we freqently want to stub
behaviour as well as return types which we can't do when we're just
passing values.

BREAKING CHANGE: method map values must now be wrapped in `Sinon.stub().returns()`
  • Loading branch information
achingbrain authored Nov 1, 2023
1 parent afb8acb commit 42413b2
Show file tree
Hide file tree
Showing 4 changed files with 61 additions and 108 deletions.
49 changes: 17 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ A fork of [ts-sinon](https://www.npmjs.com/package/ts-sinon) that lets you BYO s
Stub all object methods

```javascript
import Sinon from 'sinon'
import { stubObject } from 'ts-sinon'

class Test {
Expand All @@ -40,31 +41,17 @@ expect(testStub.method()).to.equal('stubbed')
Partial stub

```typescript
class Test {
public someProp: string = 'test'
methodA() { return 'A: original' }
methodB() { return 'B: original' }
}

const test = new Test()
// second argument must be existing class method name, in this case only 'methodA' or 'methodB' are accepted.
const testStub = stubObject<Test>(test, ['methodA'])

expect(testStub.methodA()).to.be.undefined
expect(testStub.methodB()).to.equal('B: original')
```

## Example

Stub with predefined return values (type-safe)
import Sinon from 'sinon'
import { stubObject } from 'ts-sinon'

```typescript
class Test {
method() { return 'original' }
}

const test = new Test()
const testStub = stubObject<Test>(test, { method: 'stubbed' })
const testStub = stubObject<Test>(test, {
method: Sinon.stub().returns('stubbed')
})

expect(testStub.method()).to.equal('stubbed')
```
Expand All @@ -74,6 +61,7 @@ expect(testStub.method()).to.equal('stubbed')
Interface stub (stub all methods)

```typescript
import Sinon from 'sinon'
import { stubInterface } from 'ts-sinon'

interface Test {
Expand All @@ -94,12 +82,17 @@ expect(testStub.method()).to.equal('stubbed')
Interface stub with predefined return values (type-safe)

```typescript
import Sinon from 'sinon'
import { stubInterface } from 'ts-sinon'

interface Test {
method(): string
}

// method property has to be the same type as method() return type
const testStub = stubInterface<Test>({ method: 'stubbed' })
const testStub = stubInterface<Test>({
method: Sinon.stub().returns('stubbed')
})

expect(testStub.method()).to.equal('stubbed')
```
Expand All @@ -111,6 +104,7 @@ Object constructor stub (stub all methods)
- without passing predefined args to the constructor:

```typescript
import Sinon from 'sinon'
import { stubConstructor } from 'ts-sinon'

class Test {
Expand Down Expand Up @@ -142,6 +136,9 @@ expect(testStub.someVar).to.equal(20)
Passing predefined args to the constructor

```typescript
import Sinon from 'sinon'
import { stubConstructor } from 'ts-sinon'

class Test {
constructor(public someVar: string, y: boolean) {}
// ...
Expand All @@ -153,18 +150,6 @@ const testStub = stubConstructor(Test, 'someValue', true)
expect(testStub.someVar).to.equal('someValue')
```

## Sinon methods

By importing 'ts-sinon' you have access to all sinon methods.

```typescript
import sinon, { stubInterface } from 'ts-sinon'

const functionStub = sinon.stub()
const spy = sinon.spy()
// ...
```

# Install

```console
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"scripts": {
"clean": "aegir clean",
"lint": "aegir lint",
"dep-check": "aegir dep-check",
"build": "aegir build",
"test": "aegir test",
"release": "aegir release",
Expand Down
78 changes: 29 additions & 49 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* @example Stub all object methods
*
* ```javascript
* import Sinon from 'sinon'
* import { stubObject } from 'ts-sinon'
*
* class Test {
Expand All @@ -32,36 +33,25 @@
* @example Partial stub
*
* ```typescript
* class Test {
* public someProp: string = 'test'
* methodA() { return 'A: original' }
* methodB() { return 'B: original' }
* }
*
* const test = new Test()
* // second argument must be existing class method name, in this case only 'methodA' or 'methodB' are accepted.
* const testStub = stubObject<Test>(test, ['methodA'])
*
* expect(testStub.methodA()).to.be.undefined
* expect(testStub.methodB()).to.equal('B: original')
* ```
*
* @example Stub with predefined return values (type-safe)
* import Sinon from 'sinon'
* import { stubObject } from 'ts-sinon'
*
* ```typescript
* class Test {
* method() { return 'original' }
* }
*
* const test = new Test()
* const testStub = stubObject<Test>(test, { method: 'stubbed' })
* const testStub = stubObject<Test>(test, {
* method: Sinon.stub().returns('stubbed')
* })
*
* expect(testStub.method()).to.equal('stubbed')
* ```
*
* @example Interface stub (stub all methods)
*
* ```typescript
* import Sinon from 'sinon'
* import { stubInterface } from 'ts-sinon'
*
* interface Test {
Expand All @@ -80,12 +70,17 @@
* @example Interface stub with predefined return values (type-safe)
*
* ```typescript
* import Sinon from 'sinon'
* import { stubInterface } from 'ts-sinon'
*
* interface Test {
* method(): string
* }
*
* // method property has to be the same type as method() return type
* const testStub = stubInterface<Test>({ method: 'stubbed' })
* const testStub = stubInterface<Test>({
* method: Sinon.stub().returns('stubbed')
* })
*
* expect(testStub.method()).to.equal('stubbed')
* ```
Expand All @@ -95,6 +90,7 @@
* - without passing predefined args to the constructor:
*
* ```typescript
* import Sinon from 'sinon'
* import { stubConstructor } from 'ts-sinon'
*
* class Test {
Expand Down Expand Up @@ -124,6 +120,9 @@
* @example Passing predefined args to the constructor
*
* ```typescript
* import Sinon from 'sinon'
* import { stubConstructor } from 'ts-sinon'
*
* class Test {
* constructor(public someVar: string, y: boolean) {}
* // ...
Expand All @@ -134,21 +133,9 @@
*
* expect(testStub.someVar).to.equal('someValue')
* ```
*
* ## Sinon methods
*
* By importing 'ts-sinon' you have access to all sinon methods.
*
* ```typescript
* import sinon, { stubInterface } from 'ts-sinon'
*
* const functionStub = sinon.stub()
* const spy = sinon.spy()
* // ...
* ```
*/

import sinon from 'sinon'
import Sinon from 'sinon'

export type StubbedInstance<T> = sinon.SinonStubbedInstance<T> & T

Expand All @@ -157,13 +144,7 @@ export type AllowedKeys<T, Condition> = {
T[Key] extends Condition ? Key : never
}[keyof T]

export type ObjectMethodsKeys<T> = Array<AllowedKeys<T, (...args: any[]) => any>>

export type ObjectMethodsMap<T> = {
[Key in keyof T]?: T[Key] extends (...args: any[]) => any ? ReturnType<T[Key]> : never
}

export function stubObject<T extends object> (object: T, methods?: ObjectMethodsKeys<T> | ObjectMethodsMap<T>): StubbedInstance<T> {
export function stubObject<T extends object> (object: T, partial?: Partial<T>): StubbedInstance<T> {
const stubObject: any = Object.assign({}, object)
const objectMethods = getObjectMethods(object)
const excludedMethods: string[] = [
Expand All @@ -185,20 +166,19 @@ export function stubObject<T extends object> (object: T, methods?: ObjectMethods
}
}

if (Array.isArray(methods)) {
for (const method of methods) {
stubObject[method] = sinon.stub()
}
} else if (typeof methods === 'object') {
for (const method in methods) {
stubObject[method] = sinon.stub()
stubObject[method].returns(methods[method])
if (partial != null) {
for (const key in partial) {
if (excludedMethods.includes(key)) {
continue
}

stubObject[key] = partial[key]
}
} else {
for (const method of objectMethods) {
// @ts-expect-error cannot index object by string
if (typeof object[method] === 'function' && method !== 'constructor') {
stubObject[method] = sinon.stub()
stubObject[method] = Sinon.stub()
}
}
}
Expand All @@ -213,13 +193,13 @@ export function stubConstructor<T extends new (...args: any[]) => any> (
return stubObject(new constructor(...constructorArgs))
}

export function stubInterface<T extends object> (methods: ObjectMethodsMap<T> = {}): StubbedInstance<T> {
export function stubInterface<T extends object> (methods: Partial<T> = {}): StubbedInstance<T> {
const object: any = stubObject<any>({}, methods)

return new Proxy(object, {
get: (target, name) => {
if (!Object.prototype.hasOwnProperty.call(target, name) && name !== 'then') {
target[name] = sinon.stub()
target[name] = Sinon.stub()
}

return target[name]
Expand Down
41 changes: 14 additions & 27 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { chai } from 'aegir/chai'
import Sinon from 'sinon'
import sinonChai from 'sinon-chai'
import { stubObject, stubInterface, stubConstructor } from '../src/index.js'

Expand Down Expand Up @@ -92,30 +93,6 @@ describe('ts-sinon', () => {
})
})

it('returns partial stub object with only "test" method stubbed when array with "test" has been given', () => {
const object = new class {
private readonly r: string
constructor () {
this.r = 'run'
}

test (): number {
return 123
}

run (): string {
return this.r
}
}()

const objectStub = stubObject(object, ['test'])

expect(objectStub.test()).to.be.undefined()
expect(objectStub.run()).to.equal('run')

expect(objectStub.test).to.have.been.called()
})

it('returns partial stub object with "run" method stubbed and returning "some val" value when key value map { run: "some val" } has been given', () => {
const object = new class {
test (): number {
Expand All @@ -127,7 +104,9 @@ describe('ts-sinon', () => {
}
}()

const objectStub = stubObject(object, { run: 'some val' })
const objectStub = stubObject(object, {
run: Sinon.stub().returns('some val')
})

expect(objectStub.run()).to.equal('some val')
expect(objectStub.test()).to.equal(123)
Expand All @@ -153,12 +132,20 @@ describe('ts-sinon', () => {
expect(stub.prop1).to.equal('x')
})

it('sets an "x" value on "prop1" property from constructor', () => {
const stub = stubInterface<ITest>({
prop1: 'x'
})

expect(stub.prop1).to.equal('x')
})

it('returns stub object created from interface with all methods stubbed with "method2" predefined to return value of "abc" and "method1" which is testable with expect that has been called', () => {
const expectedMethod2Arg: number = 2
const expectedMethod2ReturnValue = 'abc'

const interfaceStub: ITest = stubInterface<ITest>({
method2: expectedMethod2ReturnValue
method2: Sinon.stub().returns(expectedMethod2ReturnValue)
})

const object = new class {
Expand All @@ -180,7 +167,7 @@ describe('ts-sinon', () => {

it('returns stub object created from interface with all methods stubbed including "method2" predefined to return "x" when method map to value { method: x } has been given', () => {
const interfaceStub: ITest = stubInterface<ITest>({
method2: 'test'
method2: Sinon.stub().returns('test')
})

const object = new class {
Expand Down

0 comments on commit 42413b2

Please sign in to comment.