Skip to content

Commit af5ca27

Browse files
Fix for .returns object inequality (#79)
* fix objects inequality as arguments When a property / method is mocked with returns, and one of the arguments is an object (or a class or a function), the `areArgumentsEqual` function in utilities failed to verify. It executed the default behaviour `a === b`, which is never true for objects (only if a and b have the same object reference) * return substitute string for util.inspect * proxies based on SubstituteJS class * fix inspect deprecation warning * fix issue #36
1 parent b8f1d43 commit af5ca27

File tree

5 files changed

+121
-44
lines changed

5 files changed

+121
-44
lines changed

spec/issues/36.test.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,70 @@ import test from 'ava';
22

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

5-
interface IData { serverCheck: Date, data: { a: any[] } }
6-
interface IFetch { getUpdates: (arg: Date | null) => Promise<IData> }
5+
class Key {
6+
private constructor(private _value: string) { }
7+
static create() {
8+
return new this('123');
9+
}
10+
get value(): string {
11+
return this._value;
12+
}
13+
}
14+
class IData {
15+
private constructor(private _serverCheck: Date, private _data: number[]) { }
16+
17+
static create() {
18+
return new this(new Date(), [1]);
19+
}
20+
21+
set data(newData: number[]) {
22+
this._data = newData;
23+
}
24+
25+
get serverCheck(): Date {
26+
return this._serverCheck;
27+
}
28+
29+
get data(): number[] {
30+
return this._data;
31+
}
32+
}
33+
abstract class IFetch {
34+
abstract getUpdates(arg: Key): Promise<IData>
35+
abstract storeUpdates(arg: IData): Promise<void>
36+
}
37+
class Service {
38+
constructor(private _database: IFetch) { }
39+
public async handle(arg?: Key) {
40+
const updateData = await this.getData(arg);
41+
updateData.data = [100];
42+
await this._database.storeUpdates(updateData);
43+
}
44+
private getData(arg?: Key) {
45+
return this._database.getUpdates(arg);
46+
}
47+
}
748

849
test('issue 36 - promises returning object with properties', async t => {
950
const emptyFetch = Substitute.for<IFetch>();
10-
const now = new Date();
11-
emptyFetch.getUpdates(null).returns(Promise.resolve<IData>({
12-
serverCheck: now,
13-
data: { a: [1] }
14-
}));
15-
const result = await emptyFetch.getUpdates(null);
51+
emptyFetch.getUpdates(Key.create()).returns(Promise.resolve<IData>(IData.create()));
52+
const result = await emptyFetch.getUpdates(Key.create());
1653
t.true(result.serverCheck instanceof Date, 'given date is instanceof Date');
17-
t.is(result.serverCheck, now, 'dates are the same');
18-
t.true(Array.isArray(result.data.a), 'deep array isArray');
19-
t.deepEqual(result.data.a, [1], 'arrays are deep equal');
54+
t.deepEqual(result.data, [1], 'arrays are deep equal')
2055
});
56+
57+
test('using objects or classes as arguments should be able to match mock', async t => {
58+
const db = Substitute.for<IFetch>();
59+
const data = IData.create();
60+
db.getUpdates(Key.create()).returns(Promise.resolve(data));
61+
const service = new Service(db);
62+
63+
await service.handle(Key.create());
64+
65+
db.received(1).storeUpdates(Arg.is((arg: IData) =>
66+
arg.serverCheck instanceof Date &&
67+
arg instanceof IData &&
68+
arg.data[0] === 100
69+
));
70+
t.pass();
71+
});

src/Arguments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export class Argument<T> {
1313
return this.description;
1414
}
1515

16-
inspect() {
16+
[Symbol.for('nodejs.util.inspect.custom')]() {
1717
return this.description;
1818
}
1919
}

src/Context.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { HandlerKey } from "./Substitute";
44
import { Type } from "./Utilities";
55
import { SetPropertyState } from "./states/SetPropertyState";
66

7+
class SubstituteJS { }
8+
79
export class Context {
810
private _initialState: InitialState;
911

@@ -20,19 +22,21 @@ export class Context {
2022
this._setState = this._initialState
2123
this._getState = this._initialState;
2224

23-
this._proxy = new Proxy(() => { }, {
25+
this._proxy = new Proxy(SubstituteJS, {
2426
apply: (_target, _this, args) => this.apply(_target, _this, args),
2527
set: (_target, property, value) => (this.set(_target, property, value), true),
26-
get: (_target, property) => this.get(_target, property)
28+
get: (_target, property) => this.get(_target, property),
29+
getOwnPropertyDescriptor: (obj, prop) => prop === 'constructor' ?
30+
{ value: obj, configurable: true } : Reflect.getOwnPropertyDescriptor(obj, prop)
2731
});
2832

29-
this._rootProxy = new Proxy(() => { }, {
33+
this._rootProxy = new Proxy(SubstituteJS, {
3034
apply: (_target, _this, args) => this.initialState.apply(this, args),
3135
set: (_target, property, value) => (this.initialState.set(this, property, value), true),
3236
get: (_target, property) => this.initialState.get(this, property)
3337
});
3438

35-
this._receivedProxy = new Proxy(() => { }, {
39+
this._receivedProxy = new Proxy(SubstituteJS, {
3640
apply: (_target, _this, args) => this._receivedState === void 0 ? void 0 : this._receivedState.apply(this, args),
3741
set: (_target, property, value) => (this.set(_target, property, value), true),
3842
get: (_target, property) => {

src/Utilities.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import util = require('util')
77
export type Call = any[] // list of args
88

99
export enum Type {
10-
method = 'method',
11-
property = 'property'
10+
method = 'method',
11+
property = 'property'
1212
}
1313

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

3333
export function stringifyCalls(calls: Call[]) {
3434

35-
if(calls.length === 0)
35+
if (calls.length === 0)
3636
return ' (no calls)';
3737

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

4646
export function areArgumentsEqual(a: any, b: any) {
47-
48-
if(a instanceof Argument && b instanceof Argument) {
47+
48+
if (a instanceof Argument && b instanceof Argument)
4949
return false;
50-
}
5150

52-
if(a instanceof AllArguments || b instanceof AllArguments)
51+
if (a instanceof AllArguments || b instanceof AllArguments)
5352
return true;
5453

55-
if(a instanceof Argument)
54+
if (a instanceof Argument)
5655
return a.matches(b);
5756

58-
if(b instanceof Argument)
57+
if (b instanceof Argument)
5958
return b.matches(a);
6059

61-
return a === b;
60+
return deepEqual(a, b);
6261
};
6362

63+
function deepEqual(a: any, b: any): boolean {
64+
if (Array.isArray(a)) {
65+
if (!Array.isArray(b) || a.length !== b.length)
66+
return false;
67+
for (let i = 0; i < a.length; i++) {
68+
if (!deepEqual(a[i], b[i]))
69+
return false;
70+
}
71+
return true;
72+
}
73+
if (typeof a === 'object' && a !== null && b !== null) {
74+
if (!(typeof b === 'object')) return false;
75+
const keys = Object.keys(a);
76+
if (keys.length !== Object.keys(b).length) return false;
77+
for (const key in a) {
78+
if (!deepEqual(a[key], b[key])) return false;
79+
}
80+
return true;
81+
}
82+
return a === b;
83+
}
84+
6485
export function Get(recorder: InitialState, context: Context, property: PropertyKey) {
6586
const existingGetState = recorder.getPropertyStates.find(state => state.property === property);
6687
if (existingGetState) {

src/states/InitialState.ts

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { AreProxiesDisabledKey } from "../Substitute";
88
export class InitialState implements ContextState {
99
private recordedGetPropertyStates: Map<PropertyKey, GetPropertyState>;
1010
private recordedSetPropertyStates: SetPropertyState[];
11-
12-
private _expectedCount: number|undefined|null;
11+
12+
private _expectedCount: number | undefined | null;
1313
private _areProxiesDisabled: boolean;
1414

1515
public get expectedCount() {
@@ -47,28 +47,28 @@ export class InitialState implements ContextState {
4747
}
4848

4949
assertCallCountMatchesExpectations(
50-
calls: Call[], // list of arguments
51-
callCount: number,
52-
type: Type, // method or property
53-
property: PropertyKey,
54-
args: any[]
50+
calls: Call[], // list of arguments
51+
callCount: number,
52+
type: Type, // method or property
53+
property: PropertyKey,
54+
args: any[]
5555
) {
5656
const expectedCount = this._expectedCount;
5757

5858
this.clearExpectations();
59-
if(this.doesCallCountMatchExpectations(expectedCount, callCount))
59+
if (this.doesCallCountMatchExpectations(expectedCount, callCount))
6060
return;
61-
61+
6262
throw new Error(
63-
'Expected ' + (expectedCount === null ? '1 or more' : expectedCount) +
64-
' call' + (expectedCount === 1 ? '' : 's') + ' to the ' + type + ' ' + property.toString() +
65-
' with ' + stringifyArguments(args) + ', but received ' + (callCount === 0 ? 'none' : callCount) +
66-
' of such call' + (callCount === 1 ? '' : 's') +
63+
'Expected ' + (expectedCount === null ? '1 or more' : expectedCount) +
64+
' call' + (expectedCount === 1 ? '' : 's') + ' to the ' + type + ' ' + property.toString() +
65+
' with ' + stringifyArguments(args) + ', but received ' + (callCount === 0 ? 'none' : callCount) +
66+
' of such call' + (callCount === 1 ? '' : 's') +
6767
'.\nAll calls received to ' + type + ' ' + property.toString() + ':' + stringifyCalls(calls)
6868
);
6969
}
7070

71-
private doesCallCountMatchExpectations(expectedCount: number|undefined|null, actualCount: number) {
71+
private doesCallCountMatchExpectations(expectedCount: number | undefined | null, actualCount: number) {
7272
if (expectedCount === void 0)
7373
return true;
7474

@@ -82,7 +82,7 @@ export class InitialState implements ContextState {
8282
}
8383

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

103103
get(context: Context, property: PropertyKey) {
104104
if (typeof property === 'symbol') {
105-
if(property === AreProxiesDisabledKey)
105+
if (property === AreProxiesDisabledKey)
106106
return this._areProxiesDisabled;
107107

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

111+
if (property.toString() === 'Symbol(util.inspect.custom)')
112+
return () => '{SubstituteJS fake}';
113+
111114
if (property === Symbol.iterator)
112115
return void 0;
113116

114117
if (property === Symbol.toStringTag)
115118
return 'Substitute';
116-
if(property.toString() === 'Symbol(util.inspect.custom)')
117-
return void 0;
118119
}
119120

120121
if (property === 'valueOf')

0 commit comments

Comments
 (0)