Skip to content

Commit 1dc2304

Browse files
bajtosraymondfeng
authored andcommitted
feat(testlab): add StubbedInstanceWithSinonAccessor
Add two new sinon-related helpers to our testlab: - An interface `StubbedInstanceWithSinonAccessor` describing a stubbed instance that provides access to Sinon API for individual methos via a `stubs` property. - A wrapper function `createStubInstance` that calls `sinon.createStubInstance` under the hood and returns `StubbedInstanceWithSinonAccessor`. These two new helpers allow unit test to - use Sinon in a way that's easy to understand for TypeScript, - avoid unnecessary type casts and - stay compatible with the upcoming versions of sinon typings.
1 parent 69506a4 commit 1dc2304

File tree

7 files changed

+128
-58
lines changed

7 files changed

+128
-58
lines changed

examples/todo-list/test/unit/controllers/todo-list-todo.controller.unit.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,26 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {expect, sinon} from '@loopback/testlab';
7-
import {TodoListTodoController} from '../../../src/controllers';
8-
import {TodoList, Todo} from '../../../src/models';
9-
import {TodoListRepository} from '../../../src/repositories';
10-
import {givenTodoList, givenTodo} from '../../helpers';
116
import {
127
DefaultHasManyEntityCrudRepository,
138
HasManyRepository,
149
} from '@loopback/repository';
10+
import {
11+
createStubInstance,
12+
expect,
13+
sinon,
14+
StubbedInstanceWithSinonAccessor,
15+
} from '@loopback/testlab';
16+
import {TodoListTodoController} from '../../../src/controllers';
17+
import {Todo, TodoList} from '../../../src/models';
18+
import {TodoListRepository} from '../../../src/repositories';
19+
import {givenTodo, givenTodoList} from '../../helpers';
1520

1621
describe('TodoController', () => {
17-
let todoListRepo: TodoListRepository;
18-
let constrainedTodoRepo: HasManyRepository<Todo>;
22+
let todoListRepo: StubbedInstanceWithSinonAccessor<TodoListRepository>;
23+
let constrainedTodoRepo: StubbedInstanceWithSinonAccessor<
24+
HasManyRepository<Todo>
25+
>;
1926

2027
/*
2128
=============================================================================
@@ -113,8 +120,8 @@ describe('TodoController', () => {
113120
});
114121

115122
function resetRepositories() {
116-
todoListRepo = sinon.createStubInstance(TodoListRepository);
117-
constrainedTodoRepo = sinon.createStubInstance(
123+
todoListRepo = createStubInstance(TodoListRepository);
124+
constrainedTodoRepo = createStubInstance<HasManyRepository<Todo>>(
118125
DefaultHasManyEntityCrudRepository,
119126
);
120127

@@ -139,17 +146,14 @@ describe('TodoController', () => {
139146
title: aTodoToPatchTo.title,
140147
});
141148

142-
todoListRepo.todos = sinon
149+
todos = sinon
143150
.stub()
144151
.withArgs(aTodoListWithId.id!)
145152
.returns(constrainedTodoRepo);
146-
todos = todoListRepo.todos as sinon.SinonStub;
153+
todoListRepo.todos = todos;
147154

148155
// Setup CRUD fakes
149-
create = constrainedTodoRepo.create as sinon.SinonStub;
150-
find = constrainedTodoRepo.find as sinon.SinonStub;
151-
patch = constrainedTodoRepo.patch as sinon.SinonStub;
152-
del = constrainedTodoRepo.delete as sinon.SinonStub;
156+
({create, find, patch, delete: del} = constrainedTodoRepo.stubs);
153157

154158
controller = new TodoListTodoController(todoListRepo);
155159
}

examples/todo-list/test/unit/controllers/todo-list.controller.unit.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {expect, sinon} from '@loopback/testlab';
6+
import {
7+
createStubInstance,
8+
expect,
9+
sinon,
10+
StubbedInstanceWithSinonAccessor,
11+
} from '@loopback/testlab';
712
import {TodoListController} from '../../../src/controllers';
813
import {TodoList} from '../../../src/models';
914
import {TodoListRepository} from '../../../src/repositories';
1015
import {givenTodoList} from '../../helpers';
1116

1217
describe('TodoController', () => {
13-
let todoListRepo: TodoListRepository;
18+
let todoListRepo: StubbedInstanceWithSinonAccessor<TodoListRepository>;
1419

1520
/*
1621
=============================================================================
@@ -122,7 +127,7 @@ describe('TodoController', () => {
122127
});
123128

124129
function resetRepositories() {
125-
todoListRepo = sinon.createStubInstance(TodoListRepository);
130+
todoListRepo = createStubInstance(TodoListRepository);
126131
aTodoList = givenTodoList();
127132
aTodoListWithId = givenTodoList({
128133
id: 1,
@@ -143,13 +148,15 @@ describe('TodoController', () => {
143148
});
144149

145150
// Setup CRUD fakes
146-
create = todoListRepo.create as sinon.SinonStub;
147-
count = todoListRepo.count as sinon.SinonStub;
148-
find = todoListRepo.find as sinon.SinonStub;
149-
updateAll = todoListRepo.updateAll as sinon.SinonStub;
150-
findById = todoListRepo.findById as sinon.SinonStub;
151-
updateById = todoListRepo.updateById as sinon.SinonStub;
152-
deleteById = todoListRepo.deleteById as sinon.SinonStub;
151+
({
152+
create,
153+
count,
154+
find,
155+
updateAll,
156+
findById,
157+
updateById,
158+
deleteById,
159+
} = todoListRepo.stubs);
153160

154161
controller = new TodoListController(todoListRepo);
155162
}

examples/todo-list/test/unit/controllers/todo.controller.unit.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {expect, sinon} from '@loopback/testlab';
6+
import {
7+
createStubInstance,
8+
expect,
9+
sinon,
10+
StubbedInstanceWithSinonAccessor,
11+
} from '@loopback/testlab';
712
import {TodoController} from '../../../src/controllers';
813
import {Todo} from '../../../src/models';
914
import {TodoRepository} from '../../../src/repositories';
1015
import {givenTodo} from '../../helpers';
1116

1217
describe('TodoController', () => {
13-
let todoRepo: TodoRepository;
18+
let todoRepo: StubbedInstanceWithSinonAccessor<TodoRepository>;
1419

1520
/*
1621
=============================================================================
@@ -105,7 +110,7 @@ describe('TodoController', () => {
105110
});
106111

107112
function resetRepositories() {
108-
todoRepo = sinon.createStubInstance(TodoRepository);
113+
todoRepo = createStubInstance(TodoRepository);
109114
aTodo = givenTodo();
110115
aTodoWithId = givenTodo({
111116
id: 1,
@@ -123,12 +128,14 @@ describe('TodoController', () => {
123128
});
124129

125130
// Setup CRUD fakes
126-
create = todoRepo.create as sinon.SinonStub;
127-
findById = todoRepo.findById as sinon.SinonStub;
128-
find = todoRepo.find as sinon.SinonStub;
129-
updateById = todoRepo.updateById as sinon.SinonStub;
130-
replaceById = todoRepo.replaceById as sinon.SinonStub;
131-
deleteById = todoRepo.deleteById as sinon.SinonStub;
131+
({
132+
create,
133+
findById,
134+
find,
135+
updateById,
136+
replaceById,
137+
deleteById,
138+
} = todoRepo.stubs);
132139

133140
controller = new TodoController(todoRepo);
134141
}

examples/todo/test/unit/controllers/todo.controller.unit.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
import {Filter} from '@loopback/repository';
7-
import {expect, sinon} from '@loopback/testlab';
7+
import {
8+
createStubInstance,
9+
expect,
10+
sinon,
11+
StubbedInstanceWithSinonAccessor,
12+
} from '@loopback/testlab';
813
import {TodoController} from '../../../src/controllers';
914
import {Todo} from '../../../src/models/index';
1015
import {TodoRepository} from '../../../src/repositories';
1116
import {GeocoderService} from '../../../src/services';
1217
import {aLocation, givenTodo} from '../../helpers';
1318

1419
describe('TodoController', () => {
15-
let todoRepo: TodoRepository;
20+
let todoRepo: StubbedInstanceWithSinonAccessor<TodoRepository>;
1621
let geoService: GeocoderService;
1722

1823
/*
@@ -135,7 +140,7 @@ describe('TodoController', () => {
135140
});
136141

137142
function resetRepositories() {
138-
todoRepo = sinon.createStubInstance(TodoRepository);
143+
todoRepo = createStubInstance(TodoRepository);
139144
aTodo = givenTodo();
140145
aTodoWithId = givenTodo({
141146
id: 1,
@@ -153,12 +158,14 @@ describe('TodoController', () => {
153158
});
154159

155160
// Setup CRUD fakes
156-
create = todoRepo.create as sinon.SinonStub;
157-
findById = todoRepo.findById as sinon.SinonStub;
158-
find = todoRepo.find as sinon.SinonStub;
159-
updateById = todoRepo.updateById as sinon.SinonStub;
160-
replaceById = todoRepo.replaceById as sinon.SinonStub;
161-
deleteById = todoRepo.deleteById as sinon.SinonStub;
161+
({
162+
create,
163+
findById,
164+
find,
165+
updateById,
166+
replaceById,
167+
deleteById,
168+
} = todoRepo.stubs);
162169

163170
geoService = {geocode: sinon.stub()};
164171
geocode = geoService.geocode as sinon.SinonStub;

packages/repository/test/unit/repositories/relation.factory.unit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
import {Getter} from '@loopback/context';
7-
import {expect, sinon} from '@loopback/testlab';
7+
import {createStubInstance, expect} from '@loopback/testlab';
88
import {
99
createHasManyRepositoryFactory,
1010
DefaultCrudRepository,
@@ -98,7 +98,7 @@ describe('createHasManyRepositoryFactory', () => {
9898
}
9999

100100
function givenStubbedCustomerRepo() {
101-
customerRepo = sinon.createStubInstance(CustomerRepository);
101+
customerRepo = createStubInstance(CustomerRepository);
102102
}
103103

104104
function givenHasManyDefinition(

packages/repository/test/unit/repositories/relation.repository.unit.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
import {Getter} from '@loopback/context';
7-
import {expect, sinon} from '@loopback/testlab';
7+
import {
8+
createStubInstance,
9+
expect,
10+
sinon,
11+
StubbedInstanceWithSinonAccessor,
12+
} from '@loopback/testlab';
813
import {
914
AnyObject,
1015
Count,
@@ -21,6 +26,10 @@ import {
2126
} from '../../..';
2227

2328
describe('relation repository', () => {
29+
let customerRepo: StubbedInstanceWithSinonAccessor<CustomerRepository>;
30+
31+
beforeEach(setupStubbedCustomerRepository);
32+
2433
context('HasManyRepository interface', () => {
2534
/**
2635
* The class below is declared as test for the HasManyEntityCrudRepository
@@ -67,26 +76,29 @@ describe('relation repository', () => {
6776
const constraint: Partial<Customer> = {age: 25};
6877
const HasManyCrudInstance = givenDefaultHasManyCrudInstance(constraint);
6978
await HasManyCrudInstance.create({id: 1, name: 'Joe'});
70-
const createStub = repo.create as sinon.SinonStub;
71-
sinon.assert.calledWithMatch(createStub, {id: 1, name: 'Joe', age: 25});
79+
sinon.assert.calledWithMatch(customerRepo.stubs.create, {
80+
id: 1,
81+
name: 'Joe',
82+
age: 25,
83+
});
7284
});
7385

7486
it('can find related model instance', async () => {
7587
const constraint: Partial<Customer> = {name: 'Jane'};
7688
const HasManyCrudInstance = givenDefaultHasManyCrudInstance(constraint);
7789
await HasManyCrudInstance.find({where: {id: 3}});
78-
const findStub = repo.find as sinon.SinonStub;
79-
sinon.assert.calledWithMatch(findStub, {where: {id: 3, name: 'Jane'}});
90+
sinon.assert.calledWithMatch(customerRepo.stubs.find, {
91+
where: {id: 3, name: 'Jane'},
92+
});
8093
});
8194

8295
context('patch', async () => {
8396
it('can patch related model instance', async () => {
8497
const constraint: Partial<Customer> = {name: 'Jane'};
8598
const HasManyCrudInstance = givenDefaultHasManyCrudInstance(constraint);
8699
await HasManyCrudInstance.patch({country: 'US'}, {id: 3});
87-
const patchStub = repo.updateAll as sinon.SinonStub;
88100
sinon.assert.calledWith(
89-
patchStub,
101+
customerRepo.stubs.updateAll,
90102
{country: 'US', name: 'Jane'},
91103
{id: 3, name: 'Jane'},
92104
);
@@ -105,8 +117,10 @@ describe('relation repository', () => {
105117
const constraint: Partial<Customer> = {name: 'Jane'};
106118
const HasManyCrudInstance = givenDefaultHasManyCrudInstance(constraint);
107119
await HasManyCrudInstance.delete({id: 3});
108-
const deleteStub = repo.deleteAll as sinon.SinonStub;
109-
sinon.assert.calledWith(deleteStub, {id: 3, name: 'Jane'});
120+
sinon.assert.calledWith(customerRepo.stubs.deleteAll, {
121+
id: 3,
122+
name: 'Jane',
123+
});
110124
});
111125
});
112126

@@ -128,14 +142,15 @@ describe('relation repository', () => {
128142
}
129143
}
130144

131-
let repo: CustomerRepository;
145+
function setupStubbedCustomerRepository() {
146+
customerRepo = createStubInstance(CustomerRepository);
147+
}
132148

133149
function givenDefaultHasManyCrudInstance(constraint: DataObject<Customer>) {
134-
repo = sinon.createStubInstance(CustomerRepository);
135150
return new DefaultHasManyEntityCrudRepository<
136151
Customer,
137152
typeof Customer.prototype.id,
138153
CustomerRepository
139-
>(Getter.fromValue(repo), constraint);
154+
>(Getter.fromValue(customerRepo), constraint);
140155
}
141156
});

packages/testlab/src/sinon.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,37 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import sinon = require('sinon');
6+
import * as sinon from 'sinon';
77
import {SinonSpy} from 'sinon';
88

99
export {sinon, SinonSpy};
10+
11+
export type StubbedInstanceWithSinonAccessor<T> = T & {
12+
stubs: sinon.SinonStubbedInstance<T>;
13+
};
14+
15+
/**
16+
* Creates a new object with the given functions as the prototype and stubs all
17+
* implemented functions.
18+
*
19+
* Note: The given constructor function is not invoked. See also the stub API.
20+
*
21+
* This is a helper method replacing `sinon.createStubInstance` and working
22+
* around the limitations of TypeScript and Sinon, where Sinon is not able to
23+
* list private/protected members in the type definition of the stub instance
24+
* and therefore the stub instance cannot be assigned to places expecting TType.
25+
* See also
26+
* - https://github.com/Microsoft/TypeScript/issues/13543
27+
* - https://github.com/DefinitelyTyped/DefinitelyTyped/issues/14811
28+
*
29+
* @template TType Type being stubbed.
30+
* @param constructor Object or class to stub.
31+
* @returns A stubbed version of the constructor, with an extra property `stubs`
32+
* providing access to stub API for individual methods.
33+
*/
34+
export function createStubInstance<TType>(
35+
constructor: sinon.StubbableType<TType>,
36+
): StubbedInstanceWithSinonAccessor<TType> {
37+
const stub = sinon.createStubInstance(constructor);
38+
return Object.assign(stub as TType, {stubs: stub});
39+
}

0 commit comments

Comments
 (0)