Skip to content

Commit edc0734

Browse files
Implement throws and mimicks behaviour fix (#80)
* upgrade lockfile * fix deepEqual different classes should not be equal * implement throws and fix mimicks ignoring arguments * add unit test for throws * add documentation for throwing exceptions
1 parent af5ca27 commit edc0734

File tree

8 files changed

+155
-47
lines changed

8 files changed

+155
-47
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,24 @@ console.log(fakeCalculator.divide(10, 5)); //prints 5
140140
console.log(fakeCalculator.divide(9, 5)); //prints 1338
141141
```
142142

143+
## Throwing exceptions
144+
Exceptions can be thrown on properties or methods. You can add different exceptions for different arguments
145+
146+
```typescript
147+
import { Substitute, Arg } from '@fluffy-spoon/substitute';
148+
149+
interface Calculator {
150+
add(a: number, b: number): number;
151+
subtract(a: number, b: number): number;
152+
divide(a: number, b: number): number;
153+
isEnabled: boolean;
154+
}
155+
156+
const calculator = Substitute.for<Calculator>();
157+
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'));
158+
calculator.divide(1, 0); // throws the exception Error: Cannot divide by 0
159+
```
160+
143161
# Benefits over other mocking libraries
144162
- Easier-to-understand fluent syntax.
145163
- No need to cast to `any` in certain places (for instance, when overriding read-only properties) due to the `myProperty.returns(...)` syntax.

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/throws.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import test from 'ava'
2+
3+
import { Substitute, Arg } from '../src/index'
4+
5+
interface Calculator {
6+
add(a: number, b: number): number
7+
divide(a: number, b: number): number
8+
mode: boolean
9+
fakeSetting: boolean
10+
}
11+
12+
test('throws on a method with arguments', t => {
13+
const calculator = Substitute.for<Calculator>()
14+
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'))
15+
16+
t.throws(() => calculator.divide(1, 0), { instanceOf: Error, message: 'Cannot divide by 0' })
17+
})
18+
19+
test('throws on a property being called', t => {
20+
const calculator = Substitute.for<Calculator>()
21+
calculator.mode.throws(new Error('Property not set'))
22+
23+
t.throws(() => calculator.mode, { instanceOf: Error, message: 'Property not set' })
24+
})
25+
26+
test('does not throw on methods that do not match arguments', t => {
27+
const calculator = Substitute.for<Calculator>()
28+
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'))
29+
calculator.divide(4, 2).returns(2)
30+
31+
t.is(2, calculator.divide(4, 2))
32+
t.throws(() => calculator.divide(1, 0), { instanceOf: Error, message: 'Cannot divide by 0' })
33+
})
34+
35+
test('can set multiple throws for same method with different arguments', t => {
36+
const calculator = Substitute.for<Calculator>()
37+
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'))
38+
calculator.divide(Arg.any(), Arg.is(number => !Number.isInteger(number))).throws(new Error('Only integers supported'))
39+
40+
t.throws(() => calculator.divide(1, 1.135), { instanceOf: Error, message: 'Only integers supported' })
41+
t.throws(() => calculator.divide(1, 0), { instanceOf: Error, message: 'Cannot divide by 0' })
42+
})

src/Transformations.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { AllArguments } from "./Arguments";
22

3-
export type NoArgumentFunctionSubstitute<TReturnType> =
3+
export type NoArgumentFunctionSubstitute<TReturnType> =
44
(() => (TReturnType & NoArgumentMockObjectMixin<TReturnType>))
55

6-
export type FunctionSubstitute<TArguments extends any[], TReturnType> =
7-
((...args: TArguments) => (TReturnType & MockObjectMixin<TArguments, TReturnType>)) &
6+
export type FunctionSubstitute<TArguments extends any[], TReturnType> =
7+
((...args: TArguments) => (TReturnType & MockObjectMixin<TArguments, TReturnType>)) &
88
((allArguments: AllArguments) => (TReturnType & MockObjectMixin<TArguments, TReturnType>))
99

1010
export type PropertySubstitute<TReturnType> = (TReturnType & Partial<NoArgumentMockObjectMixin<TReturnType>>);
1111

1212
type BaseMockObjectMixin<TReturnType> = {
1313
returns: (...args: TReturnType[]) => void;
14+
throws: (exception: any) => void;
1415
}
1516

1617
type NoArgumentMockObjectMixin<TReturnType> = BaseMockObjectMixin<TReturnType> & {
@@ -30,7 +31,7 @@ export type ObjectSubstitute<T extends Object, K extends Object = T> = ObjectSub
3031
type TerminatingObject<T> = {
3132
[P in keyof T]:
3233
T[P] extends () => infer R ? () => void :
33-
T[P] extends (...args: infer F) => infer R ? (...args: F) => void :
34+
T[P] extends (...args: infer F) => infer R ? (...args: F) => void :
3435
T[P];
3536
}
3637

@@ -43,6 +44,6 @@ type ObjectSubstituteTransformation<T extends Object> = {
4344

4445
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
4546

46-
export type OmitProxyMethods<T extends any> = Omit<T, 'mimick'|'received'|'didNotReceive'>;
47+
export type OmitProxyMethods<T extends any> = Omit<T, 'mimick' | 'received' | 'didNotReceive'>;
4748

4849
export type DisabledSubstituteObject<T> = T extends ObjectSubstitute<OmitProxyMethods<infer K>, infer K> ? K : never;

src/Utilities.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export enum Type {
1111
property = 'property'
1212
}
1313

14+
export const Nothing = Symbol();
15+
export type Nothing = typeof Nothing
16+
1417
export function stringifyArguments(args: any[]) {
1518
args = args.map(x => util.inspect(x));
1619
return args && args.length > 0 ? 'arguments [' + args.join(', ') + ']' : 'no arguments';
@@ -62,16 +65,15 @@ export function areArgumentsEqual(a: any, b: any) {
6265

6366
function deepEqual(a: any, b: any): boolean {
6467
if (Array.isArray(a)) {
65-
if (!Array.isArray(b) || a.length !== b.length)
66-
return false;
68+
if (!Array.isArray(b) || a.length !== b.length) return false;
6769
for (let i = 0; i < a.length; i++) {
68-
if (!deepEqual(a[i], b[i]))
69-
return false;
70+
if (!deepEqual(a[i], b[i])) return false;
7071
}
7172
return true;
7273
}
7374
if (typeof a === 'object' && a !== null && b !== null) {
7475
if (!(typeof b === 'object')) return false;
76+
if (a.constructor !== b.constructor) return false;
7577
const keys = Object.keys(a);
7678
if (keys.length !== Object.keys(b).length) return false;
7779
for (const key in a) {

src/states/FunctionState.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,24 @@ import { Context } from "src/Context";
33
import { areArgumentArraysEqual, Call, Type } from "../Utilities";
44
import { GetPropertyState } from "./GetPropertyState";
55

6-
const Nothing = Symbol()
7-
86
interface ReturnMock {
97
args: Call
108
returnValues: any[] | Symbol // why symbol, what
119
returnIndex: 0
1210
}
11+
interface MimickMock {
12+
args: Call
13+
mimickFunction: Function
14+
}
15+
interface ThrowMock {
16+
args: Call
17+
throwFunction: any
18+
}
1319

1420
export class FunctionState implements ContextState {
1521
private returns: ReturnMock[];
16-
private mimicks: Function|null;
22+
private mimicks: MimickMock[];
23+
private throws: ThrowMock[];
1724

1825
private _calls: Call[]; // list of lists of arguments this was called with
1926
private _lastArgs?: Call // bit of a hack
@@ -32,8 +39,9 @@ export class FunctionState implements ContextState {
3239

3340
constructor(private _getPropertyState: GetPropertyState) {
3441
this.returns = [];
35-
this.mimicks = null;
42+
this.mimicks = [];
3643
this._calls = [];
44+
this.throws = [];
3745
}
3846

3947
private getCallCount(args: Call): number {
@@ -51,15 +59,22 @@ export class FunctionState implements ContextState {
5159
this.property,
5260
args);
5361

54-
if(!hasExpectations) {
62+
if (!hasExpectations) {
5563
this._calls.push(args)
5664
}
5765

5866
if (!hasExpectations) {
59-
if(this.mimicks)
60-
return this.mimicks.apply(this.mimicks, args);
67+
if (this.mimicks.length > 0) {
68+
const mimicks = this.mimicks.find(mimick => areArgumentArraysEqual(mimick.args, args))
69+
if (mimicks !== void 0) return mimicks.mimickFunction.apply(mimicks.mimickFunction, args);
70+
}
71+
72+
if (this.throws.length > 0) {
73+
const possibleThrow = this.throws.find(throws => areArgumentArraysEqual(throws.args, args))
74+
if (possibleThrow !== void 0) throw possibleThrow.throwFunction;
75+
}
6176

62-
if(!this.returns.length)
77+
if (!this.returns.length)
6378
return context.proxy;
6479
const returns = this.returns.find(r => areArgumentArraysEqual(r.args, args))
6580

@@ -85,16 +100,36 @@ export class FunctionState implements ContextState {
85100
if (property === 'then')
86101
return void 0;
87102

88-
if(property === 'mimicks') {
103+
if (property === 'mimicks') {
89104
return (input: Function) => {
90-
this.mimicks = input;
105+
if (!this._lastArgs) {
106+
throw new Error('Eh, there\'s a bug, no args recorded for this mimicks :/')
107+
}
108+
this.mimicks.push({
109+
args: this._lastArgs,
110+
mimickFunction: input
111+
})
91112
this._calls.pop()
92113

93114
context.state = context.initialState;
94115
}
95116
}
96117

97-
if(property === 'returns') {
118+
if (property === 'throws') {
119+
return (input: Error | Function) => {
120+
if (!this._lastArgs) {
121+
throw new Error('Eh, there\'s a bug, no args recorded for this throw :/')
122+
}
123+
this.throws.push({
124+
args: this._lastArgs,
125+
throwFunction: input
126+
});
127+
this._calls.pop();
128+
context.state = context.initialState;
129+
}
130+
}
131+
132+
if (property === 'returns') {
98133
return (...returns: any[]) => {
99134
if (!this._lastArgs) {
100135
throw new Error('Eh, there\'s a bug, no args recorded for this return :/')
@@ -106,7 +141,7 @@ export class FunctionState implements ContextState {
106141
})
107142
this._calls.pop()
108143

109-
if(this.callCount === 0) {
144+
if (this.callCount === 0) {
110145
// var indexOfSelf = this
111146
// ._getPropertyState
112147
// .recordedFunctionStates

src/states/GetPropertyState.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { ContextState, PropertyKey } from "./ContextState";
22
import { Context } from "src/Context";
33
import { FunctionState } from "./FunctionState";
4-
import { Type } from "../Utilities";
5-
6-
const Nothing = Symbol();
4+
import { Type, Nothing } from "../Utilities";
75

86
export class GetPropertyState implements ContextState {
9-
private returns: any[]|Symbol;
10-
private mimicks: Function|null;
7+
private returns: any[] | Nothing;
8+
private mimicks: Function | Nothing;
9+
private throws: any;
1110

1211
private _callCount: number;
1312
private _functionState?: FunctionState;
@@ -30,7 +29,8 @@ export class GetPropertyState implements ContextState {
3029

3130
constructor(private _property: PropertyKey) {
3231
this.returns = Nothing;
33-
this.mimicks = null;
32+
this.mimicks = Nothing;
33+
this.throws = Nothing;
3434
this._callCount = 0;
3535
}
3636

@@ -58,10 +58,10 @@ export class GetPropertyState implements ContextState {
5858
if (property === 'then')
5959
return void 0;
6060

61-
if(this.isFunction)
61+
if (this.isFunction)
6262
return context.proxy;
6363

64-
if(property === 'mimicks') {
64+
if (property === 'mimicks') {
6565
return (input: Function) => {
6666
this.mimicks = input;
6767
this._callCount--;
@@ -70,8 +70,8 @@ export class GetPropertyState implements ContextState {
7070
}
7171
}
7272

73-
if(property === 'returns') {
74-
if(this.returns !== Nothing)
73+
if (property === 'returns') {
74+
if (this.returns !== Nothing)
7575
throw new Error('The return value for the property ' + this._property.toString() + ' has already been set to ' + this.returns);
7676

7777
return (...returns: any[]) => {
@@ -82,21 +82,33 @@ export class GetPropertyState implements ContextState {
8282
};
8383
}
8484

85-
if(!hasExpectations) {
86-
this._callCount++;
87-
88-
if(this.mimicks)
89-
return this.mimicks.apply(this.mimicks);
85+
if (property === 'throws') {
86+
return (callback: Function) => {
87+
this.throws = callback;
88+
this._callCount--;
9089

91-
if(this.returns !== Nothing) {
92-
var returnsArray = this.returns as any[];
93-
if(returnsArray.length === 1)
94-
return returnsArray[0];
95-
96-
return returnsArray[this._callCount-1];
90+
context.state = context.initialState;
9791
}
9892
}
9993

94+
if (!hasExpectations) {
95+
this._callCount++;
96+
97+
if (this.mimicks !== Nothing)
98+
return this.mimicks.apply(this.mimicks);
99+
100+
if (this.throws !== Nothing)
101+
throw this.throws
102+
103+
if (this.returns !== Nothing) {
104+
var returnsArray = this.returns as any[];
105+
if (returnsArray.length === 1)
106+
return returnsArray[0];
107+
108+
return returnsArray[this._callCount - 1];
109+
}
110+
}
111+
100112
context.initialState.assertCallCountMatchesExpectations(
101113
[[]], // I'm not sure what this was supposed to mean
102114
this.callCount,

src/states/SetPropertyState.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { ContextState, PropertyKey } from "./ContextState";
22
import { Context } from "src/Context";
33
import { areArgumentsEqual, Type } from "../Utilities";
44

5-
const Nothing = Symbol();
6-
75
export class SetPropertyState implements ContextState {
86
private _callCount: number;
97
private _arguments: any[];

0 commit comments

Comments
 (0)