Skip to content
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,24 @@ console.log(fakeCalculator.divide(10, 5)); //prints 5
console.log(fakeCalculator.divide(9, 5)); //prints 1338
```

## Throwing exceptions
Exceptions can be thrown on properties or methods. You can add different exceptions for different arguments

```typescript
import { Substitute, Arg } from '@fluffy-spoon/substitute';

interface Calculator {
add(a: number, b: number): number;
subtract(a: number, b: number): number;
divide(a: number, b: number): number;
isEnabled: boolean;
}

const calculator = Substitute.for<Calculator>();
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'));
calculator.divide(1, 0); // throws the exception Error: Cannot divide by 0
```

# Benefits over other mocking libraries
- Easier-to-understand fluent syntax.
- No need to cast to `any` in certain places (for instance, when overriding read-only properties) due to the `myProperty.returns(...)` syntax.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions spec/throws.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import test from 'ava'

import { Substitute, Arg } from '../src/index'

interface Calculator {
add(a: number, b: number): number
divide(a: number, b: number): number
mode: boolean
fakeSetting: boolean
}

test('throws on a method with arguments', t => {
const calculator = Substitute.for<Calculator>()
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'))

t.throws(() => calculator.divide(1, 0), { instanceOf: Error, message: 'Cannot divide by 0' })
})

test('throws on a property being called', t => {
const calculator = Substitute.for<Calculator>()
calculator.mode.throws(new Error('Property not set'))

t.throws(() => calculator.mode, { instanceOf: Error, message: 'Property not set' })
})

test('does not throw on methods that do not match arguments', t => {
const calculator = Substitute.for<Calculator>()
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'))
calculator.divide(4, 2).returns(2)

t.is(2, calculator.divide(4, 2))
t.throws(() => calculator.divide(1, 0), { instanceOf: Error, message: 'Cannot divide by 0' })
})

test('can set multiple throws for same method with different arguments', t => {
const calculator = Substitute.for<Calculator>()
calculator.divide(Arg.any(), 0).throws(new Error('Cannot divide by 0'))
calculator.divide(Arg.any(), Arg.is(number => !Number.isInteger(number))).throws(new Error('Only integers supported'))

t.throws(() => calculator.divide(1, 1.135), { instanceOf: Error, message: 'Only integers supported' })
t.throws(() => calculator.divide(1, 0), { instanceOf: Error, message: 'Cannot divide by 0' })
})
11 changes: 6 additions & 5 deletions src/Transformations.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { AllArguments } from "./Arguments";

export type NoArgumentFunctionSubstitute<TReturnType> =
export type NoArgumentFunctionSubstitute<TReturnType> =
(() => (TReturnType & NoArgumentMockObjectMixin<TReturnType>))

export type FunctionSubstitute<TArguments extends any[], TReturnType> =
((...args: TArguments) => (TReturnType & MockObjectMixin<TArguments, TReturnType>)) &
export type FunctionSubstitute<TArguments extends any[], TReturnType> =
((...args: TArguments) => (TReturnType & MockObjectMixin<TArguments, TReturnType>)) &
((allArguments: AllArguments) => (TReturnType & MockObjectMixin<TArguments, TReturnType>))

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

type BaseMockObjectMixin<TReturnType> = {
returns: (...args: TReturnType[]) => void;
throws: (exception: any) => void;
}

type NoArgumentMockObjectMixin<TReturnType> = BaseMockObjectMixin<TReturnType> & {
Expand All @@ -30,7 +31,7 @@ export type ObjectSubstitute<T extends Object, K extends Object = T> = ObjectSub
type TerminatingObject<T> = {
[P in keyof T]:
T[P] extends () => infer R ? () => void :
T[P] extends (...args: infer F) => infer R ? (...args: F) => void :
T[P] extends (...args: infer F) => infer R ? (...args: F) => void :
T[P];
}

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

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

export type OmitProxyMethods<T extends any> = Omit<T, 'mimick'|'received'|'didNotReceive'>;
export type OmitProxyMethods<T extends any> = Omit<T, 'mimick' | 'received' | 'didNotReceive'>;

export type DisabledSubstituteObject<T> = T extends ObjectSubstitute<OmitProxyMethods<infer K>, infer K> ? K : never;
10 changes: 6 additions & 4 deletions src/Utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export enum Type {
property = 'property'
}

export const Nothing = Symbol();
export type Nothing = typeof Nothing

export function stringifyArguments(args: any[]) {
args = args.map(x => util.inspect(x));
return args && args.length > 0 ? 'arguments [' + args.join(', ') + ']' : 'no arguments';
Expand Down Expand Up @@ -62,16 +65,15 @@ export function areArgumentsEqual(a: any, b: any) {

function deepEqual(a: any, b: any): boolean {
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length)
return false;
if (!Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i]))
return false;
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (typeof a === 'object' && a !== null && b !== null) {
if (!(typeof b === 'object')) return false;
if (a.constructor !== b.constructor) return false;
const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) return false;
for (const key in a) {
Expand Down
59 changes: 47 additions & 12 deletions src/states/FunctionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ import { Context } from "src/Context";
import { areArgumentArraysEqual, Call, Type } from "../Utilities";
import { GetPropertyState } from "./GetPropertyState";

const Nothing = Symbol()

interface ReturnMock {
args: Call
returnValues: any[] | Symbol // why symbol, what
returnIndex: 0
}
interface MimickMock {
args: Call
mimickFunction: Function
}
interface ThrowMock {
args: Call
throwFunction: any
}

export class FunctionState implements ContextState {
private returns: ReturnMock[];
private mimicks: Function|null;
private mimicks: MimickMock[];
private throws: ThrowMock[];

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

constructor(private _getPropertyState: GetPropertyState) {
this.returns = [];
this.mimicks = null;
this.mimicks = [];
this._calls = [];
this.throws = [];
}

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

if(!hasExpectations) {
if (!hasExpectations) {
this._calls.push(args)
}

if (!hasExpectations) {
if(this.mimicks)
return this.mimicks.apply(this.mimicks, args);
if (this.mimicks.length > 0) {
const mimicks = this.mimicks.find(mimick => areArgumentArraysEqual(mimick.args, args))
if (mimicks !== void 0) return mimicks.mimickFunction.apply(mimicks.mimickFunction, args);
}

if (this.throws.length > 0) {
const possibleThrow = this.throws.find(throws => areArgumentArraysEqual(throws.args, args))
if (possibleThrow !== void 0) throw possibleThrow.throwFunction;
}

if(!this.returns.length)
if (!this.returns.length)
return context.proxy;
const returns = this.returns.find(r => areArgumentArraysEqual(r.args, args))

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

if(property === 'mimicks') {
if (property === 'mimicks') {
return (input: Function) => {
this.mimicks = input;
if (!this._lastArgs) {
throw new Error('Eh, there\'s a bug, no args recorded for this mimicks :/')
}
this.mimicks.push({
args: this._lastArgs,
mimickFunction: input
})
this._calls.pop()

context.state = context.initialState;
}
}

if(property === 'returns') {
if (property === 'throws') {
return (input: Error | Function) => {
if (!this._lastArgs) {
throw new Error('Eh, there\'s a bug, no args recorded for this throw :/')
}
this.throws.push({
args: this._lastArgs,
throwFunction: input
});
this._calls.pop();
context.state = context.initialState;
}
}

if (property === 'returns') {
return (...returns: any[]) => {
if (!this._lastArgs) {
throw new Error('Eh, there\'s a bug, no args recorded for this return :/')
Expand All @@ -106,7 +141,7 @@ export class FunctionState implements ContextState {
})
this._calls.pop()

if(this.callCount === 0) {
if (this.callCount === 0) {
// var indexOfSelf = this
// ._getPropertyState
// .recordedFunctionStates
Expand Down
54 changes: 33 additions & 21 deletions src/states/GetPropertyState.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { ContextState, PropertyKey } from "./ContextState";
import { Context } from "src/Context";
import { FunctionState } from "./FunctionState";
import { Type } from "../Utilities";

const Nothing = Symbol();
import { Type, Nothing } from "../Utilities";

export class GetPropertyState implements ContextState {
private returns: any[]|Symbol;
private mimicks: Function|null;
private returns: any[] | Nothing;
private mimicks: Function | Nothing;
private throws: any;

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

constructor(private _property: PropertyKey) {
this.returns = Nothing;
this.mimicks = null;
this.mimicks = Nothing;
this.throws = Nothing;
this._callCount = 0;
}

Expand Down Expand Up @@ -58,10 +58,10 @@ export class GetPropertyState implements ContextState {
if (property === 'then')
return void 0;

if(this.isFunction)
if (this.isFunction)
return context.proxy;

if(property === 'mimicks') {
if (property === 'mimicks') {
return (input: Function) => {
this.mimicks = input;
this._callCount--;
Expand All @@ -70,8 +70,8 @@ export class GetPropertyState implements ContextState {
}
}

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

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

if(!hasExpectations) {
this._callCount++;

if(this.mimicks)
return this.mimicks.apply(this.mimicks);
if (property === 'throws') {
return (callback: Function) => {
this.throws = callback;
this._callCount--;

if(this.returns !== Nothing) {
var returnsArray = this.returns as any[];
if(returnsArray.length === 1)
return returnsArray[0];

return returnsArray[this._callCount-1];
context.state = context.initialState;
}
}

if (!hasExpectations) {
this._callCount++;

if (this.mimicks !== Nothing)
return this.mimicks.apply(this.mimicks);

if (this.throws !== Nothing)
throw this.throws

if (this.returns !== Nothing) {
var returnsArray = this.returns as any[];
if (returnsArray.length === 1)
return returnsArray[0];

return returnsArray[this._callCount - 1];
}
}

context.initialState.assertCallCountMatchesExpectations(
[[]], // I'm not sure what this was supposed to mean
this.callCount,
Expand Down
2 changes: 0 additions & 2 deletions src/states/SetPropertyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { ContextState, PropertyKey } from "./ContextState";
import { Context } from "src/Context";
import { areArgumentsEqual, Type } from "../Utilities";

const Nothing = Symbol();

export class SetPropertyState implements ContextState {
private _callCount: number;
private _arguments: any[];
Expand Down