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

feat!: support stubbed properties as well as methods #12

Merged
merged 2 commits into from
Nov 1, 2023
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
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