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
67 changes: 67 additions & 0 deletions src/__testUtils__/__tests__/spyOn-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';

import { spyOn, spyOnMethod } from '../spyOn.js';

describe('spyOn', () => {
it('tracks invocations while preserving original behavior', () => {
const spy = spyOn((a: number, b: number) => a + b);

expect(spy(2, 3)).to.equal(5);
expect(spy(4, 5)).to.equal(9);
expect(spy.callCount).to.equal(2);
});

it('preserves this binding', () => {
const obj = {
base: 10,
addToBase: spyOn(function addToBase(
this: { base: number },
value: number,
) {
return this.base + value;
}),
};

expect(obj.addToBase(5)).to.equal(15);
expect(obj.addToBase.callCount).to.equal(1);
});
});

describe('spyOnMethod', () => {
it('tracks method invocations while preserving original behavior', () => {
const calculator = {
add(a: number, b: number) {
return a + b;
},
};

const spy = spyOnMethod(calculator, 'add');

expect(calculator.add(2, 3)).to.equal(5);
expect(calculator.add(4, 5)).to.equal(9);
expect(spy.callCount).to.equal(2);
});

it('preserves method this binding', () => {
const accumulator = {
base: 10,
addToBase(value: number) {
return this.base + value;
},
};

const spy = spyOnMethod(accumulator, 'addToBase');

expect(accumulator.addToBase(5)).to.equal(15);
expect(spy.callCount).to.equal(1);
});

it('throws when target property is not a function', () => {
const obj: { maybeMethod?: (value: string) => string } = {};

expect(() => spyOnMethod(obj, 'maybeMethod')).to.throw(
"Cannot spy on 'maybeMethod' because it is not a function.",
);
});
});
42 changes: 42 additions & 0 deletions src/__testUtils__/spyOn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
type AnyFn = (...args: Array<any>) => any;

export interface MethodSpy {
readonly callCount: number;
}

export type SpyFn<T extends AnyFn> = T & MethodSpy;

export function spyOn<T extends AnyFn>(fn: T): SpyFn<T> {
let callCount = 0;

const spy = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
callCount += 1;
return fn.apply(this, args) as ReturnType<T>;
};

Object.defineProperty(spy, 'callCount', {
get() {
return callCount;
},
enumerable: true,
});

return spy as unknown as SpyFn<T>;
}

export function spyOnMethod<T extends object>(
target: T,
key: keyof T,
): MethodSpy {
const original = target[key];

if (typeof original !== 'function') {
throw new Error(
`Cannot spy on '${String(key)}' because it is not a function.`,
);
}

const spy = spyOn(original as unknown as AnyFn);
target[key] = spy as T[keyof T];
return spy;
}
25 changes: 11 additions & 14 deletions src/execution/__tests__/cancellation-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { expectEqualPromisesOrValues } from '../../__testUtils__/expectEqualProm
import { expectJSON } from '../../__testUtils__/expectJSON.js';
import { expectPromise } from '../../__testUtils__/expectPromise.js';
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
import { spyOnMethod } from '../../__testUtils__/spyOn.js';

import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js';
import { isPromise } from '../../jsutils/isPromise.js';
Expand Down Expand Up @@ -747,7 +748,6 @@ describe('Execute: Cancellation', () => {
promiseWithResolvers<{
value: () => string;
}>();
let lateValueCalls = 0;
const sideType = new GraphQLObjectType({
name: 'LateSide',
fields: {
Expand Down Expand Up @@ -794,15 +794,14 @@ describe('Execute: Cancellation', () => {
await resolveOnNextTick();
await resolveOnNextTick();
const result = await resultPromise;
resolveSide({
value() {
lateValueCalls += 1;
return 'late value';
},
});
const lateSide = {
value: () => 'late value',
};
const lateValueSpy = spyOnMethod(lateSide, 'value');
resolveSide(lateSide);
await resolveOnNextTick();
await resolveOnNextTick();
expect(lateValueCalls).to.equal(0);
expect(lateValueSpy.callCount).to.equal(0);

expectJSON(result).toDeepEqual({
data: {
Expand Down Expand Up @@ -1067,7 +1066,6 @@ describe('Execute: Cancellation', () => {
const { promise: nextStarted, resolve: resolveNextStarted } =
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
promiseWithResolvers<void>();
let returnCalled = false;
const asyncIterator = {
[Symbol.asyncIterator]() {
return this;
Expand All @@ -1077,10 +1075,10 @@ describe('Execute: Cancellation', () => {
return nextReturned;
},
return() {
returnCalled = true;
throw new Error('Return failed');
},
};
const returnSpy = spyOnMethod(asyncIterator, 'return');

const resultPromise = execute({
schema,
Expand All @@ -1099,7 +1097,7 @@ describe('Execute: Cancellation', () => {
await expectPromise(resultPromise).toRejectWith(
'This operation was aborted',
);
expect(returnCalled).to.equal(true);
expect(returnSpy.callCount).to.equal(1);
});

it('ignores async iterator return promise rejections after aborting list completion', async () => {
Expand All @@ -1116,7 +1114,6 @@ describe('Execute: Cancellation', () => {
const { promise: nextStarted, resolve: resolveNextStarted } =
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
promiseWithResolvers<void>();
let returnCalled = false;
const asyncIterator = {
[Symbol.asyncIterator]() {
return this;
Expand All @@ -1126,10 +1123,10 @@ describe('Execute: Cancellation', () => {
return nextReturned;
},
return() {
returnCalled = true;
return Promise.reject(new Error('Return failed'));
},
};
const returnSpy = spyOnMethod(asyncIterator, 'return');

const resultPromise = execute({
schema,
Expand All @@ -1148,6 +1145,6 @@ describe('Execute: Cancellation', () => {
await expectPromise(resultPromise).toRejectWith(
'This operation was aborted',
);
expect(returnCalled).to.equal(true);
expect(returnSpy.callCount).to.equal(1);
});
});
12 changes: 6 additions & 6 deletions src/execution/__tests__/hooks-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it } from 'mocha';

import { expectPromise } from '../../__testUtils__/expectPromise.js';
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
import { spyOn } from '../../__testUtils__/spyOn.js';

import { isPromise } from '../../jsutils/isPromise.js';
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
Expand Down Expand Up @@ -470,7 +471,9 @@ describe('Execute: Hooks', () => {
promiseWithResolvers<ReadonlyArray<string>>();
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
promiseWithResolvers<undefined>();
let hookCalls = 0;
const asyncWorkFinishedSpy = spyOn(() =>
resolveAsyncWorkFinished(undefined),
);

const result = await experimentalExecuteIncrementally({
schema: cancellationHookSchema,
Expand All @@ -486,10 +489,7 @@ describe('Execute: Hooks', () => {
`),
enableEarlyExecution: true,
hooks: {
asyncWorkFinished() {
hookCalls += 1;
resolveAsyncWorkFinished(undefined);
},
asyncWorkFinished: asyncWorkFinishedSpy,
},
rootValue: {
todo: {
Expand Down Expand Up @@ -532,6 +532,6 @@ describe('Execute: Hooks', () => {
expect(nextResult.value.hasNext).to.equal(false);
await asyncWorkFinished;
await asyncWorkObserved;
expect(hookCalls).to.equal(1);
expect(asyncWorkFinishedSpy.callCount).to.equal(1);
});
});
Loading
Loading