Skip to content

Commit 2988492

Browse files
authored
polish: introduce spy test helper (#4696)
extracted from #4670 (originally as `interceptMethod`)
1 parent e1cb754 commit 2988492

14 files changed

Lines changed: 642 additions & 581 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { spyOn, spyOnMethod } from '../spyOn.js';
5+
6+
describe('spyOn', () => {
7+
it('tracks invocations while preserving original behavior', () => {
8+
const spy = spyOn((a: number, b: number) => a + b);
9+
10+
expect(spy(2, 3)).to.equal(5);
11+
expect(spy(4, 5)).to.equal(9);
12+
expect(spy.callCount).to.equal(2);
13+
});
14+
15+
it('preserves this binding', () => {
16+
const obj = {
17+
base: 10,
18+
addToBase: spyOn(function addToBase(
19+
this: { base: number },
20+
value: number,
21+
) {
22+
return this.base + value;
23+
}),
24+
};
25+
26+
expect(obj.addToBase(5)).to.equal(15);
27+
expect(obj.addToBase.callCount).to.equal(1);
28+
});
29+
});
30+
31+
describe('spyOnMethod', () => {
32+
it('tracks method invocations while preserving original behavior', () => {
33+
const calculator = {
34+
add(a: number, b: number) {
35+
return a + b;
36+
},
37+
};
38+
39+
const spy = spyOnMethod(calculator, 'add');
40+
41+
expect(calculator.add(2, 3)).to.equal(5);
42+
expect(calculator.add(4, 5)).to.equal(9);
43+
expect(spy.callCount).to.equal(2);
44+
});
45+
46+
it('preserves method this binding', () => {
47+
const accumulator = {
48+
base: 10,
49+
addToBase(value: number) {
50+
return this.base + value;
51+
},
52+
};
53+
54+
const spy = spyOnMethod(accumulator, 'addToBase');
55+
56+
expect(accumulator.addToBase(5)).to.equal(15);
57+
expect(spy.callCount).to.equal(1);
58+
});
59+
60+
it('throws when target property is not a function', () => {
61+
const obj: { maybeMethod?: (value: string) => string } = {};
62+
63+
expect(() => spyOnMethod(obj, 'maybeMethod')).to.throw(
64+
"Cannot spy on 'maybeMethod' because it is not a function.",
65+
);
66+
});
67+
});

src/__testUtils__/spyOn.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
type AnyFn = (...args: Array<any>) => any;
2+
3+
export interface MethodSpy {
4+
readonly callCount: number;
5+
}
6+
7+
export type SpyFn<T extends AnyFn> = T & MethodSpy;
8+
9+
export function spyOn<T extends AnyFn>(fn: T): SpyFn<T> {
10+
let callCount = 0;
11+
12+
const spy = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
13+
callCount += 1;
14+
return fn.apply(this, args) as ReturnType<T>;
15+
};
16+
17+
Object.defineProperty(spy, 'callCount', {
18+
get() {
19+
return callCount;
20+
},
21+
enumerable: true,
22+
});
23+
24+
return spy as unknown as SpyFn<T>;
25+
}
26+
27+
export function spyOnMethod<T extends object>(
28+
target: T,
29+
key: keyof T,
30+
): MethodSpy {
31+
const original = target[key];
32+
33+
if (typeof original !== 'function') {
34+
throw new Error(
35+
`Cannot spy on '${String(key)}' because it is not a function.`,
36+
);
37+
}
38+
39+
const spy = spyOn(original as unknown as AnyFn);
40+
target[key] = spy as T[keyof T];
41+
return spy;
42+
}

src/execution/__tests__/cancellation-test.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { expectEqualPromisesOrValues } from '../../__testUtils__/expectEqualProm
55
import { expectJSON } from '../../__testUtils__/expectJSON.js';
66
import { expectPromise } from '../../__testUtils__/expectPromise.js';
77
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
8+
import { spyOnMethod } from '../../__testUtils__/spyOn.js';
89

910
import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js';
1011
import { isPromise } from '../../jsutils/isPromise.js';
@@ -747,7 +748,6 @@ describe('Execute: Cancellation', () => {
747748
promiseWithResolvers<{
748749
value: () => string;
749750
}>();
750-
let lateValueCalls = 0;
751751
const sideType = new GraphQLObjectType({
752752
name: 'LateSide',
753753
fields: {
@@ -794,15 +794,14 @@ describe('Execute: Cancellation', () => {
794794
await resolveOnNextTick();
795795
await resolveOnNextTick();
796796
const result = await resultPromise;
797-
resolveSide({
798-
value() {
799-
lateValueCalls += 1;
800-
return 'late value';
801-
},
802-
});
797+
const lateSide = {
798+
value: () => 'late value',
799+
};
800+
const lateValueSpy = spyOnMethod(lateSide, 'value');
801+
resolveSide(lateSide);
803802
await resolveOnNextTick();
804803
await resolveOnNextTick();
805-
expect(lateValueCalls).to.equal(0);
804+
expect(lateValueSpy.callCount).to.equal(0);
806805

807806
expectJSON(result).toDeepEqual({
808807
data: {
@@ -1067,7 +1066,6 @@ describe('Execute: Cancellation', () => {
10671066
const { promise: nextStarted, resolve: resolveNextStarted } =
10681067
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
10691068
promiseWithResolvers<void>();
1070-
let returnCalled = false;
10711069
const asyncIterator = {
10721070
[Symbol.asyncIterator]() {
10731071
return this;
@@ -1077,10 +1075,10 @@ describe('Execute: Cancellation', () => {
10771075
return nextReturned;
10781076
},
10791077
return() {
1080-
returnCalled = true;
10811078
throw new Error('Return failed');
10821079
},
10831080
};
1081+
const returnSpy = spyOnMethod(asyncIterator, 'return');
10841082

10851083
const resultPromise = execute({
10861084
schema,
@@ -1099,7 +1097,7 @@ describe('Execute: Cancellation', () => {
10991097
await expectPromise(resultPromise).toRejectWith(
11001098
'This operation was aborted',
11011099
);
1102-
expect(returnCalled).to.equal(true);
1100+
expect(returnSpy.callCount).to.equal(1);
11031101
});
11041102

11051103
it('ignores async iterator return promise rejections after aborting list completion', async () => {
@@ -1116,7 +1114,6 @@ describe('Execute: Cancellation', () => {
11161114
const { promise: nextStarted, resolve: resolveNextStarted } =
11171115
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
11181116
promiseWithResolvers<void>();
1119-
let returnCalled = false;
11201117
const asyncIterator = {
11211118
[Symbol.asyncIterator]() {
11221119
return this;
@@ -1126,10 +1123,10 @@ describe('Execute: Cancellation', () => {
11261123
return nextReturned;
11271124
},
11281125
return() {
1129-
returnCalled = true;
11301126
return Promise.reject(new Error('Return failed'));
11311127
},
11321128
};
1129+
const returnSpy = spyOnMethod(asyncIterator, 'return');
11331130

11341131
const resultPromise = execute({
11351132
schema,
@@ -1148,6 +1145,6 @@ describe('Execute: Cancellation', () => {
11481145
await expectPromise(resultPromise).toRejectWith(
11491146
'This operation was aborted',
11501147
);
1151-
expect(returnCalled).to.equal(true);
1148+
expect(returnSpy.callCount).to.equal(1);
11521149
});
11531150
});

src/execution/__tests__/hooks-test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it } from 'mocha';
33

44
import { expectPromise } from '../../__testUtils__/expectPromise.js';
55
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
6+
import { spyOn } from '../../__testUtils__/spyOn.js';
67

78
import { isPromise } from '../../jsutils/isPromise.js';
89
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
@@ -470,7 +471,9 @@ describe('Execute: Hooks', () => {
470471
promiseWithResolvers<ReadonlyArray<string>>();
471472
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
472473
promiseWithResolvers<undefined>();
473-
let hookCalls = 0;
474+
const asyncWorkFinishedSpy = spyOn(() =>
475+
resolveAsyncWorkFinished(undefined),
476+
);
474477

475478
const result = await experimentalExecuteIncrementally({
476479
schema: cancellationHookSchema,
@@ -486,10 +489,7 @@ describe('Execute: Hooks', () => {
486489
`),
487490
enableEarlyExecution: true,
488491
hooks: {
489-
asyncWorkFinished() {
490-
hookCalls += 1;
491-
resolveAsyncWorkFinished(undefined);
492-
},
492+
asyncWorkFinished: asyncWorkFinishedSpy,
493493
},
494494
rootValue: {
495495
todo: {
@@ -532,6 +532,6 @@ describe('Execute: Hooks', () => {
532532
expect(nextResult.value.hasNext).to.equal(false);
533533
await asyncWorkFinished;
534534
await asyncWorkObserved;
535-
expect(hookCalls).to.equal(1);
535+
expect(asyncWorkFinishedSpy.callCount).to.equal(1);
536536
});
537537
});

0 commit comments

Comments
 (0)