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
73 changes: 62 additions & 11 deletions spec/issues/36.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,70 @@ import test from 'ava';

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

interface IData { serverCheck: Date, data: { a: any[] } }
interface IFetch { getUpdates: (arg: Date | null) => Promise<IData> }
class Key {
private constructor(private _value: string) { }
static create() {
return new this('123');
}
get value(): string {
return this._value;
}
}
class IData {
private constructor(private _serverCheck: Date, private _data: number[]) { }

static create() {
return new this(new Date(), [1]);
}

set data(newData: number[]) {
this._data = newData;
}

get serverCheck(): Date {
return this._serverCheck;
}

get data(): number[] {
return this._data;
}
}
abstract class IFetch {
abstract getUpdates(arg: Key): Promise<IData>
abstract storeUpdates(arg: IData): Promise<void>
}
class Service {
constructor(private _database: IFetch) { }
public async handle(arg?: Key) {
const updateData = await this.getData(arg);
updateData.data = [100];
await this._database.storeUpdates(updateData);
}
private getData(arg?: Key) {
return this._database.getUpdates(arg);
}
}

test('issue 36 - promises returning object with properties', async t => {
const emptyFetch = Substitute.for<IFetch>();
const now = new Date();
emptyFetch.getUpdates(null).returns(Promise.resolve<IData>({
serverCheck: now,
data: { a: [1] }
}));
const result = await emptyFetch.getUpdates(null);
emptyFetch.getUpdates(Key.create()).returns(Promise.resolve<IData>(IData.create()));
const result = await emptyFetch.getUpdates(Key.create());
t.true(result.serverCheck instanceof Date, 'given date is instanceof Date');
t.is(result.serverCheck, now, 'dates are the same');
t.true(Array.isArray(result.data.a), 'deep array isArray');
t.deepEqual(result.data.a, [1], 'arrays are deep equal');
t.deepEqual(result.data, [1], 'arrays are deep equal')
});

test('using objects or classes as arguments should be able to match mock', async t => {
const db = Substitute.for<IFetch>();
const data = IData.create();
db.getUpdates(Key.create()).returns(Promise.resolve(data));
const service = new Service(db);

await service.handle(Key.create());

db.received(1).storeUpdates(Arg.is((arg: IData) =>
arg.serverCheck instanceof Date &&
arg instanceof IData &&
arg.data[0] === 100
));
t.pass();
});
2 changes: 1 addition & 1 deletion src/Arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class Argument<T> {
return this.description;
}

inspect() {
[Symbol.for('nodejs.util.inspect.custom')]() {
return this.description;
}
}
Expand Down
12 changes: 8 additions & 4 deletions src/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { HandlerKey } from "./Substitute";
import { Type } from "./Utilities";
import { SetPropertyState } from "./states/SetPropertyState";

class SubstituteJS { }

export class Context {
private _initialState: InitialState;

Expand All @@ -20,19 +22,21 @@ export class Context {
this._setState = this._initialState
this._getState = this._initialState;

this._proxy = new Proxy(() => { }, {
this._proxy = new Proxy(SubstituteJS, {
apply: (_target, _this, args) => this.apply(_target, _this, args),
set: (_target, property, value) => (this.set(_target, property, value), true),
get: (_target, property) => this.get(_target, property)
get: (_target, property) => this.get(_target, property),
getOwnPropertyDescriptor: (obj, prop) => prop === 'constructor' ?
{ value: obj, configurable: true } : Reflect.getOwnPropertyDescriptor(obj, prop)
});

this._rootProxy = new Proxy(() => { }, {
this._rootProxy = new Proxy(SubstituteJS, {
apply: (_target, _this, args) => this.initialState.apply(this, args),
set: (_target, property, value) => (this.initialState.set(this, property, value), true),
get: (_target, property) => this.initialState.get(this, property)
});

this._receivedProxy = new Proxy(() => { }, {
this._receivedProxy = new Proxy(SubstituteJS, {
apply: (_target, _this, args) => this._receivedState === void 0 ? void 0 : this._receivedState.apply(this, args),
set: (_target, property, value) => (this.set(_target, property, value), true),
get: (_target, property) => {
Expand Down
41 changes: 31 additions & 10 deletions src/Utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import util = require('util')
export type Call = any[] // list of args

export enum Type {
method = 'method',
property = 'property'
method = 'method',
property = 'property'
}

export function stringifyArguments(args: any[]) {
Expand All @@ -32,7 +32,7 @@ export function areArgumentArraysEqual(a: any[], b: any[]) {

export function stringifyCalls(calls: Call[]) {

if(calls.length === 0)
if (calls.length === 0)
return ' (no calls)';

let output = '';
Expand All @@ -44,23 +44,44 @@ export function stringifyCalls(calls: Call[]) {
};

export function areArgumentsEqual(a: any, b: any) {
if(a instanceof Argument && b instanceof Argument) {

if (a instanceof Argument && b instanceof Argument)
return false;
}

if(a instanceof AllArguments || b instanceof AllArguments)
if (a instanceof AllArguments || b instanceof AllArguments)
return true;

if(a instanceof Argument)
if (a instanceof Argument)
return a.matches(b);

if(b instanceof Argument)
if (b instanceof Argument)
return b.matches(a);

return a === b;
return deepEqual(a, b);
};

function deepEqual(a: any, b: any): boolean {
if (Array.isArray(a)) {
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;
}
return true;
}
if (typeof a === 'object' && a !== null && b !== null) {
if (!(typeof b === 'object')) return false;
const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) return false;
for (const key in a) {
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
return a === b;
}

export function Get(recorder: InitialState, context: Context, property: PropertyKey) {
const existingGetState = recorder.getPropertyStates.find(state => state.property === property);
if (existingGetState) {
Expand Down
37 changes: 19 additions & 18 deletions src/states/InitialState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { AreProxiesDisabledKey } from "../Substitute";
export class InitialState implements ContextState {
private recordedGetPropertyStates: Map<PropertyKey, GetPropertyState>;
private recordedSetPropertyStates: SetPropertyState[];
private _expectedCount: number|undefined|null;

private _expectedCount: number | undefined | null;
private _areProxiesDisabled: boolean;

public get expectedCount() {
Expand Down Expand Up @@ -47,28 +47,28 @@ export class InitialState implements ContextState {
}

assertCallCountMatchesExpectations(
calls: Call[], // list of arguments
callCount: number,
type: Type, // method or property
property: PropertyKey,
args: any[]
calls: Call[], // list of arguments
callCount: number,
type: Type, // method or property
property: PropertyKey,
args: any[]
) {
const expectedCount = this._expectedCount;

this.clearExpectations();
if(this.doesCallCountMatchExpectations(expectedCount, callCount))
if (this.doesCallCountMatchExpectations(expectedCount, callCount))
return;

throw new Error(
'Expected ' + (expectedCount === null ? '1 or more' : expectedCount) +
' call' + (expectedCount === 1 ? '' : 's') + ' to the ' + type + ' ' + property.toString() +
' with ' + stringifyArguments(args) + ', but received ' + (callCount === 0 ? 'none' : callCount) +
' of such call' + (callCount === 1 ? '' : 's') +
'Expected ' + (expectedCount === null ? '1 or more' : expectedCount) +
' call' + (expectedCount === 1 ? '' : 's') + ' to the ' + type + ' ' + property.toString() +
' with ' + stringifyArguments(args) + ', but received ' + (callCount === 0 ? 'none' : callCount) +
' of such call' + (callCount === 1 ? '' : 's') +
'.\nAll calls received to ' + type + ' ' + property.toString() + ':' + stringifyCalls(calls)
);
}

private doesCallCountMatchExpectations(expectedCount: number|undefined|null, actualCount: number) {
private doesCallCountMatchExpectations(expectedCount: number | undefined | null, actualCount: number) {
if (expectedCount === void 0)
return true;

Expand All @@ -82,7 +82,7 @@ export class InitialState implements ContextState {
}

set(context: Context, property: PropertyKey, value: any) {
if(property === AreProxiesDisabledKey) {
if (property === AreProxiesDisabledKey) {
this._areProxiesDisabled = value;
return;
}
Expand All @@ -102,19 +102,20 @@ export class InitialState implements ContextState {

get(context: Context, property: PropertyKey) {
if (typeof property === 'symbol') {
if(property === AreProxiesDisabledKey)
if (property === AreProxiesDisabledKey)
return this._areProxiesDisabled;

if (property === Symbol.toPrimitive)
return () => '{SubstituteJS fake}';

if (property.toString() === 'Symbol(util.inspect.custom)')
return () => '{SubstituteJS fake}';

if (property === Symbol.iterator)
return void 0;

if (property === Symbol.toStringTag)
return 'Substitute';
if(property.toString() === 'Symbol(util.inspect.custom)')
return void 0;
}

if (property === 'valueOf')
Expand Down