diff --git a/expect/_to_have_returned_with.ts b/expect/_to_have_returned_with.ts deleted file mode 100644 index 34fcc43f881a..000000000000 --- a/expect/_to_have_returned_with.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. - -import type { MatcherContext, MatchResult } from "./_types.ts"; -import { AssertionError } from "@std/assert/assertion-error"; -import { equal } from "@std/assert/equal"; -import { getMockCalls } from "./_mock_util.ts"; -import { inspectArg } from "./_inspect_args.ts"; - -export function toHaveReturnedWith( - context: MatcherContext, - expected: unknown, -): MatchResult { - const calls = getMockCalls(context.value); - const returned = calls.filter((call) => call.returns); - const returnedWithExpected = returned.some((call) => - equal(call.returned, expected) - ); - - if (context.isNot) { - if (returnedWithExpected) { - throw new AssertionError( - `Expected the mock function to not have returned with ${ - inspectArg(expected) - }, but it did`, - ); - } - } else { - if (!returnedWithExpected) { - throw new AssertionError( - `Expected the mock function to have returned with ${ - inspectArg(expected) - }, but it did not`, - ); - } - } -} diff --git a/expect/_unstable_asserts_compability_test.ts b/expect/_unstable_asserts_compability_test.ts new file mode 100644 index 000000000000..1f407499f47c --- /dev/null +++ b/expect/_unstable_asserts_compability_test.ts @@ -0,0 +1,51 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { + assertSpyCall, + assertSpyCallArg, + assertSpyCallArgs, + assertSpyCallAsync, + assertSpyCalls, + spy, +} from "@std/testing/unstable-mock"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("@std/expect/fn should be compatible with @std/testing/mock asserts", async () => { + const mockFn = fn((a: number, b: number) => a + b); + mockFn(1, 1); + mockFn(1, 2); + + assertSpyCalls(mockFn, 2); + assertSpyCall(mockFn, 0, { args: [1, 1], returned: 2 }); + assertSpyCallArgs(mockFn, 1, [1, 2]); + assertSpyCallArg(mockFn, 0, 0, 1); + + const mockAsyncFn = fn((a: number, b: number) => Promise.resolve(a + b)); + await mockAsyncFn(1, 1); + await assertSpyCallAsync(mockAsyncFn, 0, { + args: [1, 1], + returned: 2, + }); +}); + +Deno.test("@std/testing/mock should be compatible with @std/expect", () => { + const sum = (a: number, b: number) => a + b; + + const value = { sum }; + const methodFn = spy(value, "sum"); + value.sum(1, 1); + expect(methodFn).toHaveBeenCalledWith(1, 1); + expect(methodFn).toHaveReturnedWith(2); + + const spyFn = spy(sum); + spyFn(1, 1); + spyFn(1, 2); + expect(spyFn).toHaveBeenCalledTimes(2); + expect(spyFn).toHaveBeenLastCalledWith(1, 2); + + class A {} + const constructorFn = spy(A); + expect(new constructorFn()).toBeInstanceOf(A); + expect(constructorFn).toHaveReturnedWith(expect.any(A)); +}); diff --git a/expect/_unstable_matchers.ts b/expect/_unstable_matchers.ts new file mode 100644 index 000000000000..6d93fc3fa50a --- /dev/null +++ b/expect/_unstable_matchers.ts @@ -0,0 +1,355 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError } from "@std/assert/assertion-error"; + +import { equal } from "./_equal.ts"; +import { inspectArg, inspectArgs } from "./_inspect_args.ts"; +import type { MatcherContext, MatchResult } from "./_types.ts"; +import { getMockCalls } from "./_unstable_mock_utils.ts"; + +export { + toBe, + toBeCloseTo, + toBeDefined, + toBeFalsy, + toBeGreaterThan, + toBeGreaterThanOrEqual, + toBeInstanceOf, + toBeLessThan, + toBeLessThanOrEqual, + toBeNaN, + toBeNull, + toBeTruthy, + toBeUndefined, + toContain, + toContainEqual, + toEqual, + toHaveBeenCalled, + toHaveBeenCalledTimes, + toHaveLength, + toHaveProperty, + toMatch, + toMatchObject, + toStrictEqual, + // toHaveBeenCalledWith, + // toHaveBeenLastCalledWith, + // toHaveBeenNthCalledWith, + // toHaveReturned, + // toHaveReturnedTimes, + // toHaveReturnedWith, + // toHaveLastReturnedWith, + // toHaveNthReturnedWith, + toThrow, +} from "./_matchers.ts"; + +export function toHaveBeenCalledWith( + context: MatcherContext, + ...expected: unknown[] +): MatchResult { + const calls = getMockCalls(context.value); + const hasBeenCalled = calls.some((call) => equal(call.args, expected)); + + if (context.isNot) { + if (hasBeenCalled) { + const defaultMessage = `Expected mock function not to be called with ${ + inspectArgs(expected) + }, but it was`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (!hasBeenCalled) { + let otherCalls = ""; + if (calls.length > 0) { + otherCalls = `\n Other calls:\n ${ + calls.map((call) => inspectArgs(call.args)).join("\n ") + }`; + } + + const defaultMessage = `Expected mock function to be called with ${ + inspectArgs(expected) + }, but it was not.${otherCalls}`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } +} + +export function toHaveBeenLastCalledWith( + context: MatcherContext, + ...expected: unknown[] +): MatchResult { + const calls = getMockCalls(context.value); + const hasBeenCalled = calls.length > 0 && + equal(calls.at(-1)?.args, expected); + + if (context.isNot) { + if (hasBeenCalled) { + const defaultMessage = + `Expected mock function not to be last called with ${ + inspectArgs(expected) + }, but it was`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (!hasBeenCalled) { + const lastCall = calls.at(-1); + if (!lastCall) { + const defaultMessage = `Expected mock function to be last called with ${ + inspectArgs(expected) + }, but it was not`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } else { + const defaultMessage = `Expected mock function to be last called with ${ + inspectArgs(expected) + }, but it was last called with ${inspectArgs(lastCall.args)}`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } + } +} + +export function toHaveBeenNthCalledWith( + context: MatcherContext, + nth: number, + ...expected: unknown[] +): MatchResult { + if (nth < 1) { + throw new Error(`nth must be greater than 0: received ${nth}`); + } + + const calls = getMockCalls(context.value); + const callIndex = nth - 1; + const hasBeenCalled = calls.length > callIndex && + equal(calls[callIndex]?.args, expected); + + if (context.isNot) { + if (hasBeenCalled) { + const defaultMessage = + `Expected the n-th call (n=${nth}) of mock function is not with ${ + inspectArgs(expected) + }, but it was`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (!hasBeenCalled) { + const nthCall = calls[callIndex]; + if (!nthCall) { + const defaultMessage = + `Expected the n-th call (n=${nth}) of mock function is with ${ + inspectArgs(expected) + }, but the n-th call does not exist`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } else { + const defaultMessage = + `Expected the n-th call (n=${nth}) of mock function is with ${ + inspectArgs(expected) + }, but it was with ${inspectArgs(nthCall.args)}`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } + } +} + +export function toHaveReturned(context: MatcherContext): MatchResult { + const calls = getMockCalls(context.value); + const returned = calls.filter((call) => call.result === "returned"); + + if (context.isNot) { + if (returned.length > 0) { + const defaultMessage = + `Expected the mock function to not have returned, but it returned ${returned.length} times`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (returned.length === 0) { + const defaultMessage = + `Expected the mock function to have returned, but it did not return`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } +} + +export function toHaveReturnedTimes( + context: MatcherContext, + expected: number, +): MatchResult { + const calls = getMockCalls(context.value); + const returned = calls.filter((call) => call.result === "returned"); + + if (context.isNot) { + if (returned.length === expected) { + const defaultMessage = + `Expected the mock function to not have returned ${expected} times, but it returned ${returned.length} times`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (returned.length !== expected) { + const defaultMessage = + `Expected the mock function to have returned ${expected} times, but it returned ${returned.length} times`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } +} + +export function toHaveReturnedWith( + context: MatcherContext, + expected: unknown, +): MatchResult { + const calls = getMockCalls(context.value); + const returned = calls.filter((call) => call.result === "returned"); + const returnedWithExpected = returned.some((call) => + equal(call.returned, expected) + ); + + if (context.isNot) { + if (returnedWithExpected) { + const defaultMessage = + `Expected the mock function to not have returned with ${ + inspectArg(expected) + }, but it did`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (!returnedWithExpected) { + const defaultMessage = + `Expected the mock function to have returned with ${ + inspectArg(expected) + }, but it did not`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } +} + +export function toHaveLastReturnedWith( + context: MatcherContext, + expected: unknown, +): MatchResult { + const calls = getMockCalls(context.value); + const returned = calls.filter((call) => call.result === "returned"); + const lastReturnedWithExpected = returned.length > 0 && + equal(returned.at(-1)?.returned, expected); + + if (context.isNot) { + if (lastReturnedWithExpected) { + const defaultMessage = + `Expected the mock function to not have last returned with ${ + inspectArg(expected) + }, but it did`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (!lastReturnedWithExpected) { + const defaultMessage = + `Expected the mock function to have last returned with ${ + inspectArg(expected) + }, but it did not`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } +} + +export function toHaveNthReturnedWith( + context: MatcherContext, + nth: number, + expected: unknown, +): MatchResult { + if (nth < 1) { + throw new Error(`nth(${nth}) must be greater than 0`); + } + + const calls = getMockCalls(context.value); + const returned = calls.filter((call) => call.result === "returned"); + const returnIndex = nth - 1; + const maybeNthReturned = returned[returnIndex]; + const nthReturnedWithExpected = maybeNthReturned && + equal(maybeNthReturned.returned, expected); + + if (context.isNot) { + if (nthReturnedWithExpected) { + const defaultMessage = + `Expected the mock function to not have n-th (n=${nth}) returned with ${ + inspectArg(expected) + }, but it did`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } else { + if (!nthReturnedWithExpected) { + const defaultMessage = + `Expected the mock function to have n-th (n=${nth}) returned with ${ + inspectArg(expected) + }, but it did not`; + throw new AssertionError( + context.customMessage + ? `${context.customMessage}: ${defaultMessage}` + : defaultMessage, + ); + } + } +} diff --git a/expect/_unstable_mock_instance.ts b/expect/_unstable_mock_instance.ts new file mode 100644 index 000000000000..a41a358accf1 --- /dev/null +++ b/expect/_unstable_mock_instance.ts @@ -0,0 +1,155 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { defineMockInternals } from "@std/internal/unstable_mock"; +import type { + ExpectMockCall, + ExpectMockInstance, +} from "./_unstable_mock_utils.ts"; + +function defineMethod( + value: Value, + key: Key, + method: Value[Key] extends ( + this: Value, + ...args: infer Args extends unknown[] + ) => infer Return ? (this: Value, ...args: Args) => Return + : never, +) { + Object.defineProperty(value, key, { + value: method, + writable: true, + enumerable: false, + configurable: true, + }); +} + +export type Functor = (...args: Args) => Return; +export type StubState = { + current: Functor | undefined; + once: Functor[]; +}; + +export function createMockInstance< + Args extends unknown[], + Return, + Fn extends Functor, +>( + original: Fn | undefined, + initialStubs: Functor[], + functor: (wrap: Functor, context: { + implementation(): Functor | undefined; + calls: ExpectMockCall[]; + state: StubState; + }) => Functor, +): Fn & ExpectMockInstance { + const originalStub: Functor | undefined = original + ? (...args) => original(...args) + : undefined; + const stubState: StubState = { + current: originalStub, + once: initialStubs, + }; + const calls: ExpectMockCall[] = []; + const nextImplementation = () => { + return stubState.once.pop() ?? stubState.current; + }; + const wrap: Functor = (...args) => { + try { + const returned = nextImplementation()?.(...args); + calls.push({ + args, + timestamp: Date.now(), + result: "returned", + returned: returned as never, + }); + return returned; + } catch (error) { + calls.push({ args, timestamp: Date.now(), result: "thrown", error }); + throw error; + } + }; + const instance: ExpectMockInstance = defineMockInternals( + functor(wrap, { + state: stubState, + implementation: nextImplementation, + calls, + }), + { calls }, + ) as never; + defineMethod(instance, "mockImplementation", (stub) => { + stubState.current = stub; + return instance; + }); + defineMethod(instance, "mockImplementationOnce", (stub) => { + stubState.once.push(stub); + return instance; + }); + defineMethod( + instance, + "mockReturnValue", + (value) => instance.mockImplementation(() => value), + ); + defineMethod( + instance, + "mockReturnValueOnce", + (value) => instance.mockImplementationOnce(() => value), + ); + defineMethod( + instance, + "mockResolvedValue", + (value) => + instance.mockImplementation(() => Promise.resolve(value) as never), + ); + defineMethod( + instance, + "mockResolvedValueOnce", + (value) => + instance.mockImplementationOnce(() => Promise.resolve(value) as never), + ); + defineMethod( + instance, + "mockRejectedValue", + (reason) => + instance.mockImplementation(() => Promise.reject(reason) as never), + ); + defineMethod( + instance, + "mockRejectedValueOnce", + (reason) => + instance.mockImplementationOnce(() => Promise.reject(reason) as never), + ); + defineMethod(instance, "mockRestore", () => { + stubState.current = originalStub; + stubState.once = []; + return instance; + }); + defineMethod( + instance, + "withImplementation", + (stub: Functor, scope?: () => ScopeResult) => { + const prevState = { ...stubState }; + stubState.current = stub; + stubState.once = []; + const resource: Disposable = { + [Symbol.dispose]() { + stubState.current = prevState.current; + stubState.once = prevState.once; + }, + }; + if (!scope) return resource; + try { + const result = scope(); + if (result instanceof Promise) { + return result.finally(() => resource[Symbol.dispose]()); + } + resource[Symbol.dispose](); + return result; + } catch (error) { + resource[Symbol.dispose](); + throw error; + } + }, + ); + + return instance as never; +} diff --git a/expect/_unstable_mock_instance_test.ts b/expect/_unstable_mock_instance_test.ts new file mode 100644 index 000000000000..24789300157d --- /dev/null +++ b/expect/_unstable_mock_instance_test.ts @@ -0,0 +1,220 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { assert } from "@std/assert"; +import { createMockInstance } from "./_unstable_mock_instance.ts"; +import { MOCK_SYMBOL } from "./_unstable_mock_utils.ts"; +import { expect } from "./unstable_expect.ts"; + +Deno.test("createMockInstance()", async ({ step }) => { + const createMock = (stubs: Array<(a: number, b: number) => number>) => + createMockInstance< + [number, number], + number, + (a: number, b: number) => number + >( + (a, b) => a + b, + stubs, + (wrap) => wrap, + ); + const createAsyncMock = ( + stubs: Array<(a: number, b: number) => Promise>, + ) => + createMockInstance< + [number, number], + Promise, + (a: number, b: number) => Promise + >( + (a, b) => Promise.resolve(a + b), + stubs, + (wrap) => wrap, + ); + + await step("should define mock instance methods", () => { + const mock = createMock([]); + + expect(mock).toBeInstanceOf(Function); + assert( + MOCK_SYMBOL in mock && typeof mock[MOCK_SYMBOL] === "object" && + mock[MOCK_SYMBOL] !== null && "calls" in mock[MOCK_SYMBOL] && + Array.isArray(mock[MOCK_SYMBOL].calls), + "mock instance should have mock internals", + ); + expect(mock).toHaveProperty("mockImplementation", expect.any(Function)); + expect(mock).toHaveProperty("mockImplementation", expect.any(Function)); + expect(mock).toHaveProperty("mockImplementationOnce", expect.any(Function)); + expect(mock).toHaveProperty("mockReturnValue", expect.any(Function)); + expect(mock).toHaveProperty("mockReturnValueOnce", expect.any(Function)); + expect(mock).toHaveProperty("mockResolvedValue", expect.any(Function)); + expect(mock).toHaveProperty("mockResolvedValueOnce", expect.any(Function)); + expect(mock).toHaveProperty("mockRejectedValue", expect.any(Function)); + expect(mock).toHaveProperty("mockRejectedValueOnce", expect.any(Function)); + expect(mock).toHaveProperty("withImplementation", expect.any(Function)); + expect(mock).toHaveProperty("withImplementation", expect.any(Function)); + expect(mock).toHaveProperty("mockRestore", expect.any(Function)); + }); + + await step("should use stubs and switch to current after", () => { + const mock = createMock([() => 5, () => { + throw new Error("test error"); + }]); + + expect(() => mock(1, 1)).toThrow("test error"); + expect(mock(1, 2)).toBe(5); + expect(mock(1, 3)).toBe(4); + + expect(mock[MOCK_SYMBOL]).toStrictEqual({ + calls: [{ + args: [1, 1], + result: "thrown", + error: new Error("test error"), + timestamp: expect.any(Number), + }, { + args: [1, 2], + result: "returned", + returned: 5, + timestamp: expect.any(Number), + }, { + args: [1, 3], + result: "returned", + returned: 4, + timestamp: expect.any(Number), + }], + }); + }); + + await step("should handle undefined original", () => { + const mock = createMockInstance< + [number, number], + number, + (a: number, b: number) => number + >( + undefined, + [], + (wrap) => wrap, + ); + expect(mock(1, 2)).toBeUndefined(); + }); + + await step("should be recognized by expect and assert", () => { + const mock = createMock([]); + mock(1, 1); + mock(1, 2); + expect(mock).toHaveBeenCalledTimes(2); + expect(mock).toHaveBeenNthCalledWith(1, 1, 1); + expect(mock).toHaveBeenLastCalledWith(1, 2); + expect(mock).toHaveReturnedWith(2); + expect(mock).toHaveLastReturnedWith(3); + }); + + await step(".mockImplementation()", () => { + const mock = createMock([]).mockImplementation((a, b) => a - b); + expect(mock(1, 1)).toBe(0); + expect(mock(1, 2)).toBe(-1); + }); + + await step(".mockImplementationOnce()", () => { + const mock = createMock([]).mockImplementationOnce((a, b) => a - b); + expect(mock(1, 1)).toBe(0); + expect(mock(1, 2)).toBe(3); + }); + + await step(".mockReturnValue()", () => { + const mock = createMock([]).mockReturnValue(5); + expect(mock(1, 1)).toBe(5); + expect(mock(1, 2)).toBe(5); + }); + + await step(".mockReturnValueOnce()", () => { + const mock = createMock([]).mockReturnValueOnce(5); + expect(mock(1, 1)).toBe(5); + expect(mock(1, 2)).toBe(3); + }); + + await step(".mockResolvedValue()", async () => { + const mock = createAsyncMock([]).mockResolvedValue(5); + await expect(mock(1, 1)).resolves.toBe(5); + await expect(mock(1, 2)).resolves.toBe(5); + }); + + await step(".mockResolvedValueOnce()", async () => { + const mock = createAsyncMock([]).mockResolvedValueOnce(5); + await expect(mock(1, 1)).resolves.toBe(5); + await expect(mock(1, 2)).resolves.toBe(3); + }); + + await step(".mockRejectedValue()", async () => { + const mock = createAsyncMock([]).mockRejectedValue(new Error("test error")); + await expect(mock(1, 1)).rejects.toThrow("test error"); + await expect(mock(1, 2)).rejects.toThrow("test error"); + }); + + await step(".mockRejectedValueOnce()", async () => { + const mock = createAsyncMock([]).mockRejectedValueOnce( + new Error("test error"), + ); + await expect(mock(1, 1)).rejects.toThrow("test error"); + await expect(mock(1, 2)).resolves.toBe(3); + }); + + await step(".withImplementation()", () => { + const mock = createMock([]); + { + using _withMock = mock.withImplementation((a, b) => a - b); + expect(mock(1, 1)).toBe(0); + } + expect(mock(1, 2)).toBe(3); + }); + + await step(".withImplementation() with sync scope", () => { + let counter = 0; + const mock = createMock([]); + + mock.withImplementation((a, b) => a - b, () => { + expect(mock(1, 1)).toBe(0); + counter += 1; + }); + + expect(mock(1, 2)).toBe(3); + expect(counter).toBe(1); + }); + + await step(".withImplementation() with sync scope that throws error", () => { + let counter = 0; + const mock = createMock([]); + + expect(() => + mock.withImplementation((a, b) => a - b, () => { + counter += 1; + throw new Error("scope error"); + }) + ).toThrow(new Error("scope error")); + + expect(counter).toBe(1); + }); + + await step(".withImplementation() with async scope", async () => { + let counter = 0; + const mock = createAsyncMock([]); + + const promise = mock.withImplementation( + (a, b) => Promise.resolve(a - b), + async () => { + counter += 1; + await expect(mock(1, 1)).resolves.toBe(0); + }, + ); + + await expect(mock(1, 2)).resolves.toBe(-1); + await promise; + await expect(mock(1, 3)).resolves.toBe(4); + + expect(counter).toBe(1); + }); + + await step(".mockRestore()", () => { + const mock = createMock([]); + mock.mockReturnValue(5).mockReturnValueOnce(1); + mock.mockRestore(); + expect(mock(1, 1)).toEqual(2); + expect(mock(1, 2)).toEqual(3); + }); +}); diff --git a/expect/_unstable_mock_utils.ts b/expect/_unstable_mock_utils.ts new file mode 100644 index 000000000000..e56dd2ffea41 --- /dev/null +++ b/expect/_unstable_mock_utils.ts @@ -0,0 +1,235 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { MOCK_SYMBOL, type MockCall } from "@std/internal/unstable_mock"; + +export { isMockFunction, MOCK_SYMBOL } from "@std/internal/unstable_mock"; + +export type ExpectMockCall = + & MockCall + & { + timestamp: number; + }; +export interface ExpectMockInternals { + readonly calls: ExpectMockCall[]; +} +export interface ExpectMockInstance { + readonly [MOCK_SYMBOL]: ExpectMockInternals; + + /** + * Sets current implementation. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn<[a: number, b: number]>().mockImplementation((a, b) => a + b); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + mockImplementation(stub: (...args: Args) => Return): this; + + /** + * Adds one time stub implementation. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn((a: number, b: number) => a + b); + * mockFn.mockImplementationOnce((a, b) => a - b); + * expect(mockFn(1, 2)).toEqual(-1); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + mockImplementationOnce(stub: (...args: Args) => Return): this; + + /** + * Sets current implementation's return value. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn().mockReturnValue(5); + * expect(mockFn(1, 2)).toEqual(5); + * }); + * ``` + */ + mockReturnValue(value: Return): this; + + /** + * Adds one time stub implementation that returns provided value. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn((a: number, b: number) => a + b); + * mockFn.mockReturnValueOnce(5); + * expect(mockFn(1, 2)).toEqual(5); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + mockReturnValueOnce(value: Return): this; + + /** + * Sets current implementation's that returns promise resolved to value. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", async () => { + * const mockFn = fn(); + * mockFn.mockResolvedValue(5); + * await expect(mockFn(1, 2)).resolves.toEqual(5); + * expect(mockFn(3, 2)).toEqual(5); + * }); + * ``` + */ + mockResolvedValue(value: Awaited | Return): this; + + /** + * Adds one time stub implementation that returns promise resolved to value. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", async () => { + * const mockFn = fn((a: number, b: number) => a + b); + * mockFn.mockResolvedValueOnce(5); + * await expect(mockFn(1, 2)).resolves.toEqual(5); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + mockResolvedValueOnce(value: Awaited | Return): this; + + /** + * Sets current implementation's that returns promise rejects with reason. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", async () => { + * const mockFn = fn(); + * mockFn.mockRejectedValue(new Error("test error")); + * await expect(mockFn(1, 2)).rejects.toThrow("test error"); + * expect(mockFn(3, 2)).toEqual(5); + * }); + * ``` + */ + mockRejectedValue(reason?: unknown): this; + + /** + * Adds one time stub implementation that returns promise rejects with reason. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", async () => { + * const mockFn = fn((a: number, b: number) => a + b); + * mockFn.mockRejectedValueOnce(new Error("test error")); + * await expect(mockFn(1, 2)).rejects.toThrow("test error"); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + mockRejectedValueOnce(reason?: unknown): this; + + /** + * Changes current implementation to provided stub. + * Returns disposable resource that restores previous setup of stubs on dispose. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn((a: number, b: number) => a + b); + * mockFn.mockReturnValueOnce(5); + * { + * using withMock = mockFn.withImplementation((a, b) => a - b); + * expect(mockFn(1, 2)).toEqual(-1); + * } + * expect(mockFn(1, 2)).toEqual(5); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + withImplementation(stub: (...args: Args) => Return): Disposable; + + /** + * Changes current implementation to provided stub. + * Runs scope function and after it is final restores previous setup of stubs. + * Also detects if scope function returns a promise and waits for it to resolve. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", async () => { + * const mockFn = fn((a: number, b: number): number | Promise => a + b); + * mockFn.mockReturnValueOnce(5); + * await mockFn.withImplementation(async (a, b) => a - b, async () => { + * await expect(mockFn(1, 2)).resolves.toEqual(-1); + * }); + * expect(mockFn(1, 2)).toEqual(5); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + withImplementation( + stub: (...args: Args) => Return, + scope: () => ScopeResult, + ): ScopeResult; + + /** + * Restores original implementation and discards one time stubs. + * In case no original implementation was provided, the mock will be reset to an empty function. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn((a: number, b: number) => a + b).mockReturnValue(5).mockReturnValueOnce(1); + * mockFn.mockRestore(); + * expect(mockFn(1, 2)).toEqual(3); + * expect(mockFn(1, 2)).toEqual(3); + * }); + * ``` + */ + mockRestore(): void; +} + +// deno-lint-ignore no-explicit-any +export function getMockCalls(f: any): MockCall[] { + const mockInfo = f[MOCK_SYMBOL]; + if (!mockInfo) { + throw new Error("Received function must be a mock or spy function"); + } + + return [...mockInfo.calls]; +} diff --git a/expect/_unstable_to_have_been_called_with_test.ts b/expect/_unstable_to_have_been_called_with_test.ts new file mode 100644 index 000000000000..d423a561fe02 --- /dev/null +++ b/expect/_unstable_to_have_been_called_with_test.ts @@ -0,0 +1,34 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveBeenCalledWith()", () => { + const mockFn = fn(); + mockFn("hello", "deno"); + + expect(mockFn).toHaveBeenCalledWith("hello", "deno"); + + expect(mockFn).not.toHaveBeenCalledWith("hello", "DENO"); + + assertThrows(() => { + expect(mockFn).toHaveBeenCalledWith("hello", "DENO"); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).not.toHaveBeenCalledWith("hello", "deno"); + }); +}); + +Deno.test("expect().toHaveBeenCalledWith() with custom error message", () => { + const msg = "toHaveBeenCalledWith custom error message"; + const mockFn = fn(); + mockFn("hello", "deno"); + + expect(() => expect(mockFn, msg).toHaveBeenCalledWith("hello", "DENO")) + .toThrow(new RegExp(`^${msg}`)); + + expect(() => expect(mockFn, msg).not.toHaveBeenCalledWith("hello", "deno")) + .toThrow(new RegExp(`^${msg}`)); +}); diff --git a/expect/_unstable_to_have_been_last_called_with_test.ts b/expect/_unstable_to_have_been_last_called_with_test.ts new file mode 100644 index 000000000000..4409cb960455 --- /dev/null +++ b/expect/_unstable_to_have_been_last_called_with_test.ts @@ -0,0 +1,51 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveBeenLastCalledWith() checks the last call of mock function", () => { + const mockFn = fn(); + + mockFn(1, 2, 3); + mockFn(4, 5, 6); + + expect(mockFn).toHaveBeenLastCalledWith(4, 5, 6); + + expect(mockFn).not.toHaveBeenLastCalledWith(1, 2, 3); + + assertThrows(() => { + expect(mockFn).toHaveBeenLastCalledWith(1, 2, 3); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).not.toHaveBeenLastCalledWith(4, 5, 6); + }, AssertionError); +}); + +Deno.test("expect().toHaveBeenLastCalledWith() handles the case when the mock is not called", () => { + const mockFn = fn(); + + expect(mockFn).not.toHaveBeenLastCalledWith(1, 2, 3); + assertThrows( + () => expect(mockFn).toHaveBeenLastCalledWith(1, 2, 3), + AssertionError, + "Expected mock function to be last called with 1, 2, 3, but it was not", + ); +}); + +Deno.test("expect().toHaveBeenLastCalledWith() with custom error message", () => { + const msg = "toHaveBeenLastCalledWith custom error message"; + const mockFn = fn(); + + mockFn(1, 2, 3); + mockFn(4, 5, 6); + + expect(() => { + expect(mockFn, msg).toHaveBeenLastCalledWith(1, 2, 3); + }).toThrow(new RegExp(`^${msg}`)); + + expect(() => { + expect(mockFn, msg).not.toHaveBeenLastCalledWith(4, 5, 6); + }).toThrow(new RegExp(`^${msg}`)); +}); diff --git a/expect/_unstable_to_have_been_nth_called_with_test.ts b/expect/_unstable_to_have_been_nth_called_with_test.ts new file mode 100644 index 000000000000..3d5fc0cf8c16 --- /dev/null +++ b/expect/_unstable_to_have_been_nth_called_with_test.ts @@ -0,0 +1,71 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveBeenNthCalledWith()", () => { + const mockFn = fn(); + + mockFn(1, 2, 3); + mockFn(4, 5, 6); + mockFn(7, 8, 9); + + expect(mockFn).toHaveBeenNthCalledWith(2, 4, 5, 6); + + expect(mockFn).not.toHaveBeenNthCalledWith(2, 1, 2, 3); + expect(mockFn).not.toHaveBeenNthCalledWith(1, 4, 5, 6); + + assertThrows(() => { + expect(mockFn).toHaveBeenNthCalledWith(2, 1, 2, 3); + }, AssertionError); + assertThrows(() => { + expect(mockFn).toHaveBeenNthCalledWith(1, 4, 5, 6); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).not.toHaveBeenNthCalledWith(2, 4, 5, 6); + }); +}); + +Deno.test("expect().toHaveBeenNthCalledWith() should throw when mock call does not exist", () => { + const mockFn = fn(); + + mockFn("hello"); + + expect(mockFn).toHaveBeenNthCalledWith(1, "hello"); + assertThrows( + () => { + expect(mockFn).toHaveBeenNthCalledWith(2, "hello"); + }, + AssertionError, + 'Expected the n-th call (n=2) of mock function is with "hello", but the n-th call does not exist', + ); +}); + +Deno.test("expect().toHaveBeenNthCalledWith() throw when n is not a positive integer", () => { + const mockFn = fn(); + + assertThrows( + () => { + expect(mockFn).toHaveBeenNthCalledWith(0, "hello"); + }, + Error, + "nth must be greater than 0: received 0", + ); +}); + +Deno.test("expect().toHaveBeenNthCalledWith() with custom error message", () => { + const msg = "toHaveBeenNthCalledWith custom error message"; + const mockFn = fn(); + + mockFn(1, 2, 3); + mockFn(4, 5, 6); + mockFn(7, 8, 9); + + expect(() => expect(mockFn, msg).not.toHaveBeenNthCalledWith(1, 1, 2, 3)) + .toThrow(new RegExp(`^${msg}`)); + expect(() => expect(mockFn, msg).toHaveBeenNthCalledWith(1, 4, 5, 6)).toThrow( + new RegExp(`^${msg}`), + ); +}); diff --git a/expect/_unstable_to_have_last_returned_with_test.ts b/expect/_unstable_to_have_last_returned_with_test.ts new file mode 100644 index 000000000000..4687252b2fb2 --- /dev/null +++ b/expect/_unstable_to_have_last_returned_with_test.ts @@ -0,0 +1,40 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveLastReturnedWith()", () => { + const mockFn = fn((x: number) => x + 3); + + mockFn(1); + mockFn(4); + + expect(mockFn).toHaveLastReturnedWith(7); + + expect(mockFn).not.toHaveLastReturnedWith(4); + + assertThrows(() => { + expect(mockFn).toHaveLastReturnedWith(4); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).not.toHaveLastReturnedWith(7); + }, AssertionError); +}); + +Deno.test("expect().toHaveLastReturnedWith() with custom error message", () => { + const msg = "toHaveLastReturnedWith custom error message"; + const mockFn = fn((x: number) => x + 3); + + mockFn(1); + mockFn(4); + + expect(() => { + expect(mockFn, msg).toHaveLastReturnedWith(4); + }).toThrow(new RegExp(`^${msg}`)); + + expect(() => { + expect(mockFn, msg).not.toHaveLastReturnedWith(7); + }).toThrow(new RegExp(`^${msg}`)); +}); diff --git a/expect/_unstable_to_have_nth_returned_with_test.ts b/expect/_unstable_to_have_nth_returned_with_test.ts new file mode 100644 index 000000000000..2e6c42eeb74d --- /dev/null +++ b/expect/_unstable_to_have_nth_returned_with_test.ts @@ -0,0 +1,54 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveNthReturnedWith()", () => { + const mockFn = fn((x: number) => x + 7); + + mockFn(1); + mockFn(10); + mockFn(100); + mockFn(1000); + + expect(mockFn).toHaveNthReturnedWith(1, 8); + expect(mockFn).toHaveNthReturnedWith(2, 17); + expect(mockFn).toHaveNthReturnedWith(3, 107); + expect(mockFn).toHaveNthReturnedWith(4, 1007); + + expect(mockFn).not.toHaveNthReturnedWith(1, 1); + expect(mockFn).not.toHaveNthReturnedWith(2, 10); + expect(mockFn).not.toHaveNthReturnedWith(3, 100); + expect(mockFn).not.toHaveNthReturnedWith(4, 1000); + + assertThrows(() => { + expect(mockFn).toHaveNthReturnedWith(1, 1); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).not.toHaveNthReturnedWith(1, 8); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).toHaveNthReturnedWith(0, 0); + }, Error); +}); + +Deno.test("expect().toHaveNthReturnedWith() with custom error message", () => { + const msg = "toHaveNthReturnedWith custom error message"; + const mockFn = fn((x: number) => x + 7); + + mockFn(1); + mockFn(10); + mockFn(100); + mockFn(1000); + + expect(() => expect(mockFn, msg).toHaveNthReturnedWith(1, 1)).toThrow( + new RegExp(`^${msg}`), + ); + + expect(() => expect(mockFn, msg).not.toHaveNthReturnedWith(1, 8)).toThrow( + new RegExp(`^${msg}`), + ); +}); diff --git a/expect/_unstable_to_have_returned_test.ts b/expect/_unstable_to_have_returned_test.ts new file mode 100644 index 000000000000..26b63183a49d --- /dev/null +++ b/expect/_unstable_to_have_returned_test.ts @@ -0,0 +1,54 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveReturned()", () => { + const mockFn0 = fn(); + const mockFn1 = fn(() => { + throw new Error("foo"); + }); + + mockFn0(); + try { + mockFn1(); + } catch { + // ignore + } + + expect(mockFn0).toHaveReturned(); + + expect(mockFn1).not.toHaveReturned(); + + assertThrows(() => { + expect(mockFn1).toHaveReturned(); + }, AssertionError); + + assertThrows(() => { + expect(mockFn0).not.toHaveReturned(); + }, AssertionError); +}); + +Deno.test("expect().toHaveReturned() with custom error message", () => { + const msg = "toHaveReturned custom error message"; + const mockFn0 = fn(); + const mockFn1 = fn(() => { + throw new Error("foo"); + }); + + mockFn0(); + try { + mockFn1(); + } catch { + // ignore + } + + expect(() => expect(mockFn1, msg).toHaveReturned()).toThrow( + new RegExp(`${msg}`), + ); + + expect(() => expect(mockFn0, msg).not.toHaveReturned()).toThrow( + new RegExp(`${msg}`), + ); +}); diff --git a/expect/_unstable_to_have_returned_times_test.ts b/expect/_unstable_to_have_returned_times_test.ts new file mode 100644 index 000000000000..8ecd9becbfd8 --- /dev/null +++ b/expect/_unstable_to_have_returned_times_test.ts @@ -0,0 +1,40 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveReturnedTimes()", () => { + const mockFn = fn(); + + mockFn(); + mockFn(); + + expect(mockFn).toHaveReturnedTimes(2); + + expect(mockFn).not.toHaveReturnedTimes(1); + + assertThrows(() => { + expect(mockFn).toHaveReturnedTimes(1); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).not.toHaveReturnedTimes(2); + }, AssertionError); +}); + +Deno.test("expect().toHaveReturnedTimes() with custom error message", () => { + const msg = "toHaveReturnedTimes custom error message"; + const mockFn = fn(); + + mockFn(); + mockFn(); + + expect(() => expect(mockFn, msg).toHaveReturnedTimes(1)).toThrow( + new RegExp(`^${msg}`), + ); + + expect(() => expect(mockFn, msg).not.toHaveReturnedTimes(2)).toThrow( + new RegExp(`^${msg}`), + ); +}); diff --git a/expect/_unstable_to_have_returned_with_test.ts b/expect/_unstable_to_have_returned_with_test.ts new file mode 100644 index 000000000000..a528bc5e36fb --- /dev/null +++ b/expect/_unstable_to_have_returned_with_test.ts @@ -0,0 +1,40 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { AssertionError, assertThrows } from "@std/assert"; +import { expect } from "./unstable_expect.ts"; +import { fn } from "./unstable_fn.ts"; + +Deno.test("expect().toHaveReturnedWith()", () => { + const mockFn = fn((x: number) => ({ foo: x + 1 })); + + mockFn(5); + mockFn(6); + + expect(mockFn).toHaveReturnedWith({ foo: 7 }); + + expect(mockFn).not.toHaveReturnedWith({ foo: 5 }); + + assertThrows(() => { + expect(mockFn).toHaveReturnedWith({ foo: 5 }); + }, AssertionError); + + assertThrows(() => { + expect(mockFn).not.toHaveReturnedWith({ foo: 7 }); + }, AssertionError); +}); + +Deno.test("expect().toHaveReturnedWith() with custom error message", () => { + const msg = "toHaveReturnedWith custom error message"; + const mockFn = fn((x: number) => ({ foo: x + 1 })); + + mockFn(5); + mockFn(6); + + expect(() => expect(mockFn, msg).toHaveReturnedWith({ foo: 5 })).toThrow( + new RegExp(`^${msg}`), + ); + + expect(() => expect(mockFn, msg).not.toHaveReturnedWith({ foo: 7 })).toThrow( + new RegExp(`^${msg}`), + ); +}); diff --git a/expect/deno.json b/expect/deno.json index f3fb501e394d..90b50e058dcd 100644 --- a/expect/deno.json +++ b/expect/deno.json @@ -4,6 +4,8 @@ "exports": { ".": "./mod.ts", "./expect": "./expect.ts", - "./fn": "./fn.ts" + "./fn": "./fn.ts", + "./unstable-expect": "./unstable_expect.ts", + "./unstable-fn": "./unstable_fn.ts" } } diff --git a/expect/unstable_expect.ts b/expect/unstable_expect.ts new file mode 100644 index 000000000000..2d2754acf084 --- /dev/null +++ b/expect/unstable_expect.ts @@ -0,0 +1,621 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. +// Copyright 2019 Allain Lalonde. All rights reserved. ISC License. +// Copyright (c) Meta Platforms, Inc. and affiliates. +// The documentation is extracted from https://github.com/jestjs/jest/blob/main/website/versioned_docs/version-29.7/ExpectAPI.md +// and updated for the Deno ecosystem. + +import { AssertionError } from "@std/assert/assertion-error"; +import { + assertions, + emitAssertionTrigger, + hasAssertions, +} from "./_assertions.ts"; +import * as asymmetricMatchers from "./_asymmetric_matchers.ts"; +import { + addCustomEqualityTesters, + getCustomEqualityTesters, +} from "./_custom_equality_tester.ts"; +import { equal } from "./_equal.ts"; +import { getExtendMatchers, setExtendMatchers } from "./_extend.ts"; +import { addSerializer } from "./_serializer.ts"; +import type { + Expected, + ExtendMatchResult, + Matcher, + MatcherContext, + MatcherKey, + Matchers, + SnapshotPlugin, + Tester, +} from "./_types.ts"; +import { + toBe, + toBeCloseTo, + toBeDefined, + toBeFalsy, + toBeGreaterThan, + toBeGreaterThanOrEqual, + toBeInstanceOf, + toBeLessThan, + toBeLessThanOrEqual, + toBeNaN, + toBeNull, + toBeTruthy, + toBeUndefined, + toContain, + toContainEqual, + toEqual, + toHaveBeenCalled, + toHaveBeenCalledTimes, + toHaveBeenCalledWith, + toHaveBeenLastCalledWith, + toHaveBeenNthCalledWith, + toHaveLastReturnedWith, + toHaveLength, + toHaveNthReturnedWith, + toHaveProperty, + toHaveReturned, + toHaveReturnedTimes, + toHaveReturnedWith, + toMatch, + toMatchObject, + toStrictEqual, + toThrow, +} from "./_unstable_matchers.ts"; +import { isPromiseLike } from "./_utils.ts"; + +export type { AnyConstructor, Async, Expected } from "./_types.ts"; + +const matchers: Record = { + lastCalledWith: toHaveBeenLastCalledWith, + lastReturnedWith: toHaveLastReturnedWith, + nthCalledWith: toHaveBeenNthCalledWith, + nthReturnedWith: toHaveNthReturnedWith, + toBeCalled: toHaveBeenCalled, + toBeCalledTimes: toHaveBeenCalledTimes, + toBeCalledWith: toHaveBeenCalledWith, + toBeCloseTo, + toBeDefined, + toBeFalsy, + toBeGreaterThanOrEqual, + toBeGreaterThan, + toBeInstanceOf, + toBeLessThanOrEqual, + toBeLessThan, + toBeNaN, + toBeNull, + toBeTruthy, + toBeUndefined, + toBe, + toContainEqual, + toContain, + toEqual, + toHaveBeenCalledTimes, + toHaveBeenCalledWith, + toHaveBeenCalled, + toHaveBeenLastCalledWith, + toHaveBeenNthCalledWith, + toHaveLength, + toHaveLastReturnedWith, + toHaveNthReturnedWith, + toHaveProperty, + toHaveReturnedTimes, + toHaveReturnedWith, + toHaveReturned, + toMatchObject, + toMatch, + toReturn: toHaveReturned, + toReturnTimes: toHaveReturnedTimes, + toReturnWith: toHaveReturnedWith, + toStrictEqual, + toThrow, +}; + +/** + * **Note:** the documentation for this module is taken from [Jest](https://github.com/jestjs/jest/blob/main/website/versioned_docs/version-29.7/ExpectAPI.md) + * and the examples are updated for Deno. + * + * The `expect` function is used to test a value. You will use `expect` along with a + * "matcher" function to assert something about a value. + * + * @example Usage + * ```ts no-assert + * import { expect } from "@std/expect"; + * + * function bestLaCroixFlavor(): string { + * return "grapefruit"; + * } + * + * Deno.test("the best flavor is grapefruit", () => { + * expect(bestLaCroixFlavor()).toBe("grapefruit"); + * }); + * ``` + * + * In this case, `toBe` is the matcher function. There are a lot of different + * matcher functions, documented in the main module description. + * + * The argument to `expect` should be the value that your code produces, and any + * argument to the matcher should be the correct value. If you mix them up, your + * tests will still work, but the error messages on failing tests will look + * strange. + * + * @param value The value to perform assertions on. + * @param customMessage An optional custom message to include in the assertion error. + * @returns An expected object that can be used to chain matchers. + * + * @typeParam T The interface used for `expect`. This is usually needed only if you want to use `expect.extend` to create custom matchers. + */ +export function expect( + value: unknown, + customMessage?: string, +): T { + let isNot = false; + let isPromised = false; + const self: T = new Proxy( {}, { + get(_, name) { + if (name === "not") { + isNot = !isNot; + return self; + } + + if (name === "resolves") { + if (!isPromiseLike(value)) { + throw new AssertionError("Expected value must be PromiseLike"); + } + + isPromised = true; + return self; + } + + if (name === "rejects") { + if (!isPromiseLike(value)) { + throw new AssertionError("Expected value must be a PromiseLike"); + } + + value = value.then( + (value) => { + throw new AssertionError( + `Promise did not reject: resolved to ${value}`, + ); + }, + (err) => err, + ); + isPromised = true; + return self; + } + + const extendMatchers: Matchers = getExtendMatchers(); + const allMatchers = { + ...matchers, + ...extendMatchers, + }; + const matcher = allMatchers[name as MatcherKey] as Matcher; + if (!matcher) { + throw new TypeError( + typeof name === "string" + ? `matcher not found: ${name}` + : "matcher not found", + ); + } + + return (...args: unknown[]) => { + function applyMatcher(value: unknown, args: unknown[]) { + const context: MatcherContext = { + value, + equal, + isNot: false, + customMessage, + customTesters: getCustomEqualityTesters(), + }; + if (isNot) { + context.isNot = true; + } + if (name in extendMatchers) { + const result = matcher(context, ...args) as ExtendMatchResult; + if (context.isNot) { + if (result.pass) { + throw new AssertionError(result.message()); + } + } else if (!result.pass) { + throw new AssertionError(result.message()); + } + } else { + matcher(context, ...args); + } + + emitAssertionTrigger(); + } + + return isPromised + ? (value as Promise).then((value: unknown) => + applyMatcher(value, args) + ) + : applyMatcher(value, args); + }; + }, + }); + + return self; +} + +/** + * You can use `expect.addEqualityTesters` to add your own methods to test if two + * objects are equal. For example, let's say you have a class in your code that + * represents volume and can determine if two volumes using different units are + * equal. You may want `toEqual` (and other equality matchers) to use this custom + * equality method when comparing to Volume classes. You can add a custom equality + * tester to have `toEqual` detect and apply custom logic when comparing Volume + * classes: + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * class Volume { + * amount: number; + * unit: "L" | "mL"; + * + * constructor(amount: number, unit: "L" | "mL") { + * this.amount = amount; + * this.unit = unit; + * } + * + * toString() { + * return `[Volume ${this.amount}${this.unit}]`; + * } + * + * equals(other: Volume) { + * if (this.unit === other.unit) { + * return this.amount === other.amount; + * } else if (this.unit === "L" && other.unit === "mL") { + * return this.amount * 1000 === other.amount; + * } else { + * return this.amount === other.amount * 1000; + * } + * } + * } + * + * function areVolumesEqual(a: Volume, b: Volume) { + * const isAVolume = a instanceof Volume; + * const isBVolume = b instanceof Volume; + * if (isAVolume && isBVolume) { + * return a.equals(b); + * } else if (isAVolume === isBVolume) { + * return undefined; + * } else { + * return false; + * } + * } + * + * expect.addEqualityTesters([areVolumesEqual]); + * + * Deno.test("are equal with different units", () => { + * expect(new Volume(1, "L")).toEqual(new Volume(1000, "mL")); + * }); + * ``` + */ +expect.addEqualityTesters = addCustomEqualityTesters as ( + newTesters: Tester[], +) => void; +/** + * Extend `expect()` with custom provided matchers. + * + * To do so, you will need to extend the interface `Expected` to define the new signature of the `expect`. + * + * ```ts + * import type { Async, Expected } from "./expect.ts"; + * import { expect } from "./expect.ts"; + * + * // Extends the `Expected` interface with your new matchers signatures + * interface ExtendedExpected extends Expected { + * // Matcher that asserts value is a dinosaur + * toBeDinosaur: (options?: { includeTrexs?: boolean }) => unknown; + * + * // NOTE: You also need to overrides the following typings to allow modifiers to correctly infer typing + * not: IsAsync extends true ? Async> + * : ExtendedExpected; + * resolves: Async>; + * rejects: Async>; + * } + * + * // Call `expect.extend()` with your new matchers definitions + * expect.extend({ + * toBeDinosaur(context, options) { + * const dino = `${context.value}`; + * const allowed = ["🦕"]; + * if (options?.includeTrexs) { + * allowed.push("🦖"); + * } + * const pass = allowed.includes(dino); + * if (context.isNot) { + * // Note: when `context.isNot` is set, the test is considered successful when `pass` is false + * return { + * message: () => `Expected "${dino}" to NOT be a dinosaur`, + * pass, + * }; + * } + * return { message: () => `Expected "${dino}" to be a dinosaur`, pass }; + * }, + * }); + * + * // Alias expect to avoid having to pass the generic typing argument each time + * // This is probably what you want to export and reuse across your tests + * const myexpect = expect; + * + * // Perform some tests + * myexpect("🦕").toBeDinosaur(); + * myexpect("🦧").not.toBeDinosaur(); + * await myexpect(Promise.resolve("🦕")).resolves.toBeDinosaur(); + * await myexpect(Promise.resolve("🦧")).resolves.not.toBeDinosaur(); + * + * // Regular matchers will still be available + * myexpect("foo").not.toBeNull() + * myexpect.anything + * ``` + */ +expect.extend = setExtendMatchers as (newExtendMatchers: Matchers) => void; +/** + * `expect.anything()` matches anything but `null` or `undefined`. You can use it + * inside `toEqual` or `toHaveBeenCalledWith` instead of a literal value. + * + * @example + * ```ts + * import { expect, fn } from "@std/expect"; + * + * Deno.test("map calls its argument with a non-null argument", () => { + * const mock = fn(); + * [1].map((x) => mock(x)); + * expect(mock).toHaveBeenCalledWith(expect.anything()); + * }); +``` + */ +expect.anything = asymmetricMatchers.anything as () => ReturnType< + typeof asymmetricMatchers.anything +>; +/** + * `expect.any(constructor)` matches anything that was created with the given + * constructor or if it's a primitive that is of the passed type. You can use it + * inside `toEqual` or `toHaveBeenCalledWith` instead of a literal value. + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * class Cat {} + * Deno.test("expect.any()", () => { + * expect(new Cat()).toEqual(expect.any(Cat)); + * expect("Hello").toEqual(expect.any(String)); + * expect(1).toEqual(expect.any(Number)); + * expect(() => {}).toEqual(expect.any(Function)); + * expect(false).toEqual(expect.any(Boolean)); + * expect(BigInt(1)).toEqual(expect.any(BigInt)); + * expect(Symbol("sym")).toEqual(expect.any(Symbol)); + * }); + * ``` + */ +expect.any = asymmetricMatchers.any as ( + c: unknown, +) => ReturnType; +/** + * `expect.arrayContaining(array)` matches a received array which contains all of + * the elements in the expected array. That is, the expected array is a **subset** + * of the received array. Therefore, it matches a received array which contains + * elements that are **not** in the expected array. + * + * You can use it instead of a literal value: + * + * - in `toEqual` or `toHaveBeenCalledWith` + * - to match a property in `objectContaining` or `toMatchObject` + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * Deno.test("expect.arrayContaining() with array of numbers", () => { + * const arr = [1, 2, 3]; + * expect([1, 2, 3, 4]).toEqual(expect.arrayContaining(arr)); + * expect([4, 5, 6]).not.toEqual(expect.arrayContaining(arr)); + * expect([1, 2, 3]).toEqual(expect.arrayContaining(arr)); + * }); + * ``` + */ +expect.arrayContaining = asymmetricMatchers.arrayContaining as ( + // deno-lint-ignore no-explicit-any + c: any[], +) => ReturnType; +/** + * `expect.closeTo(number, numDigits?)` is useful when comparing floating point + * numbers in object properties or array item. If you need to compare a number, + * please use `.toBeCloseTo` instead. + * + * The optional `numDigits` argument limits the number of digits to check **after** + * the decimal point. For the default value `2`, the test criterion is + * `Math.abs(expected - received) < 0.005 (that is, 10 ** -2 / 2)`. + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * Deno.test("compare float in object properties", () => { + * expect({ + * title: "0.1 + 0.2", + * sum: 0.1 + 0.2, + * }).toEqual({ + * title: "0.1 + 0.2", + * sum: expect.closeTo(0.3, 5), + * }); + * }); + * ``` + */ +expect.closeTo = asymmetricMatchers.closeTo as ( + num: number, + numDigits?: number, +) => ReturnType; +/** + * `expect.stringContaining(string)` matches the received value if it is a string + * that contains the exact expected string. + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * Deno.test("expect.stringContaining() with strings", () => { + * expect("https://deno.com/").toEqual(expect.stringContaining("deno")); + * expect("function").toEqual(expect.stringContaining("func")); + * + * expect("Hello, World").not.toEqual(expect.stringContaining("hello")); + * expect("foobar").not.toEqual(expect.stringContaining("bazz")); + * }); + * ``` + */ +expect.stringContaining = asymmetricMatchers.stringContaining as ( + str: string, +) => ReturnType; +/** + * `expect.stringMatching(string | regexp)` matches the received value if it is a + * string that matches the expected string or regular expression. + * + * You can use it instead of a literal value: + * + * - in `toEqual` or `toHaveBeenCalledWith` + * - to match an element in `arrayContaining` + * - to match a property in `objectContaining` (not available yet) or `toMatchObject` + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * Deno.test("example", () => { + * expect("deno_std").toEqual(expect.stringMatching(/std/)); + * expect("0123456789").toEqual(expect.stringMatching(/\d+/)); + * expect("e").not.toEqual(expect.stringMatching(/\s/)); + * expect("queue").not.toEqual(expect.stringMatching(/en/)); + * }); + * ``` + */ +expect.stringMatching = asymmetricMatchers.stringMatching as ( + pattern: string | RegExp, +) => ReturnType; + +/** + * `expect.hasAssertions` verifies that at least one assertion is called during a test. + * + * Note: expect.hasAssertions only can use in bdd function test suite, such as `test` or `it`. + * + * @example + * ```ts + * + * import { test } from "@std/testing/bdd"; + * import { expect } from "@std/expect"; + * + * test("it works", () => { + * expect.hasAssertions(); + * expect("a").not.toBe("b"); + * }); + * ``` + */ +expect.hasAssertions = hasAssertions as () => void; + +/** + * `expect.assertions` verifies that a certain number of assertions are called during a test. + * + * Note: expect.assertions only can use in bdd function test suite, such as `test` or `it`. + * + * @example + * ```ts + * + * import { test } from "@std/testing/bdd"; + * import { expect } from "@std/expect"; + * + * test("it works", () => { + * expect.assertions(1); + * expect("a").not.toBe("b"); + * }); + * ``` + */ +expect.assertions = assertions as (num: number) => void; + +/** + * `expect.objectContaining(object)` matches any received object that recursively matches the expected properties. + * That is, the expected object is not a subset of the received object. Therefore, it matches a received object + * which contains properties that are not in the expected object. + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * Deno.test("example", () => { + * expect({ bar: 'baz' }).toEqual(expect.objectContaining({ bar: 'bar'})); + * expect({ bar: 'baz' }).not.toEqual(expect.objectContaining({ foo: 'bar'})); + * }); + * ``` + */ +expect.objectContaining = asymmetricMatchers.objectContaining as ( + obj: Record, +) => ReturnType; +/** + * `expect.not.arrayContaining` matches a received array which does not contain + * all of the elements in the expected array. That is, the expected array is not + * a subset of the received array. + * + * `expect.not.objectContaining` matches any received object that does not recursively + * match the expected properties. That is, the expected object is not a subset of the + * received object. Therefore, it matches a received object which contains properties + * that are not in the expected object. + * + * `expect.not.stringContaining` matches the received value if it is not a string + * or if it is a string that does not contain the exact expected string. + * + * `expect.not.stringMatching` matches the received value if it is not a string + * or if it is a string that does not match the expected string or regular expression. + * + * @example + * ```ts + * import { expect } from "@std/expect"; + * + * Deno.test("expect.not.arrayContaining", () => { + * const expected = ["Samantha"]; + * expect(["Alice", "Bob", "Eve"]).toEqual(expect.not.arrayContaining(expected)); + * }); + * + * Deno.test("expect.not.objectContaining", () => { + * const expected = { foo: "bar" }; + * expect({ bar: "baz" }).toEqual(expect.not.objectContaining(expected)); + * }); + * + * Deno.test("expect.not.stringContaining", () => { + * const expected = "Hello world!"; + * expect("How are you?").toEqual(expect.not.stringContaining(expected)); + * }); + * + * Deno.test("expect.not.stringMatching", () => { + * const expected = /Hello world!/; + * expect("How are you?").toEqual(expect.not.stringMatching(expected)); + * }); + * ``` + */ +expect.not = { + arrayContaining: asymmetricMatchers.arrayNotContaining, + objectContaining: asymmetricMatchers.objectNotContaining, + stringContaining: asymmetricMatchers.stringNotContaining, + stringMatching: asymmetricMatchers.stringNotMatching, +}; +/** + * `expect.addSnapshotSerializer` adds a module that formats application-specific data structures. + * + * For an individual test file, an added module precedes any modules from snapshotSerializers configuration, + * which precede the default snapshot serializers for built-in JavaScript types. + * The last module added is the first module tested. + * + * @example + * ```ts no-eval + * import { expect } from "@std/expect"; + * import serializerAnsi from "npm:jest-snapshot-serializer-ansi"; + * + * expect.addSnapshotSerializer(serializerAnsi); + * ``` + */ +expect.addSnapshotSerializer = addSerializer as ( + plugin: SnapshotPlugin, +) => void; diff --git a/expect/unstable_fn.ts b/expect/unstable_fn.ts new file mode 100644 index 000000000000..0e29682bcce5 --- /dev/null +++ b/expect/unstable_fn.ts @@ -0,0 +1,129 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. +// Copyright 2019 Allain Lalonde. All rights reserved. ISC License. + +import { createMockInstance, type Functor } from "./_unstable_mock_instance.ts"; +import type { ExpectMockInstance } from "./_unstable_mock_utils.ts"; + +/** + * This module contains jest compatible `fn()` utility to mock functions for testing and assertions. + * + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn((a: number, b: number) => a + b); + * mockFn.mockReturnValueOnce(4); + * expect(mockFn(1, 2)).toEqual(4); + * expect(mockFn(1, 2)).toEqual(3); + * expect(mockFn).toHaveBeenCalledWith(1, 2); + * expect(mockFn).toHaveBeenCalledTimes(2); + * }); + * + * Deno.test("example combine args stubs and dynamic addition", () => { + * const mockFn = fn( + * (a: number, b: number) => a + b, + * (a, b) => a - b, + * ); + * mockFn.mockImplementationOnce((a, b) => a * b).mockImplementationOnce((a, b) => a / b); + * expect(mockFn(1, 2)).toEqual(4); + * expect(mockFn(1, 2)).toEqual(-1); + * expect(mockFn(1, 2)).toEqual(2); + * expect(mockFn(1, 2)).toEqual(0.5); + * }); + * ``` + * + * @module + */ + +/** + * Creates a mock function that can be used for testing and assertions. + * Could accept arguments and return type generic arguments. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const mockFn = fn() + * expect(mockFn()).toBeUndefined(); + * expect(mockFn).toHaveBeenCalledWith(); + * expect(mockFn).toHaveBeenCalledTimes(1); + * }); + * ``` + */ +export function fn(): + & Functor< + Args, + Return + > + & ExpectMockInstance; + +/** + * Creates a mock function that can be used for testing and assertions. + * Accepts an original implementation and a list of stubs. + * After all stubs are used, the original implementation is restored. + * Infers the arguments and return type from the original function. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const op = fn( + * (a: number, b: number) => a + b, + * (a, b) => a - b, + * (a, b) => a * b, + * ); + * expect(op(1, 2)).toEqual(3); + * expect(op(1, 2)).toEqual(-1); + * expect(op(1, 2)).toEqual(2); + * expect(op(1, 2)).toEqual(3); + * }); + * ``` + */ +export function fn< + // deno-lint-ignore no-explicit-any + Fn extends Functor = Functor, +>( + original: Fn, + ...stubs: Functor>, ReturnType>>[] +): Fn & ExpectMockInstance, ReturnType>; + +/** + * Creates a mock function that can be used for testing and assertions. + * Accepts an original implementation and a list of stubs. + * After all stubs are used, the original implementation is restored. + * Version that uses manually provided arguments and return value types. + * + * @example Usage + * ```ts + * import { expect } from "@std/expect/unstable-expect"; + * import { fn } from "@std/expect/unstable-fn"; + * + * Deno.test("example", () => { + * const op = fn<[a: number, b: number], string>( + * (a, b) => String(a + b), + * ); + * expect(op(1, 2)).toEqual('3'); + * }); + * ``` + */ +export function fn( + original: Functor, NoInfer>, + ...stubs: Functor, NoInfer>[] +): Functor & ExpectMockInstance; + +export function fn( + original?: Functor, + ...stubs: Functor[] +): Functor & ExpectMockInstance { + return createMockInstance( + original, + stubs.toReversed(), + (wrap) => (...args: Args) => wrap(...args), + ); +} diff --git a/internal/deno.json b/internal/deno.json index e39cf0560139..6294c49d1c82 100644 --- a/internal/deno.json +++ b/internal/deno.json @@ -9,6 +9,7 @@ "./diff": "./diff.ts", "./format": "./format.ts", "./styles": "./styles.ts", - "./types": "./types.ts" + "./types": "./types.ts", + "./unstable_mock": "./unstable_mock.ts" } } diff --git a/internal/unstable_mock.ts b/internal/unstable_mock.ts new file mode 100644 index 000000000000..61ff8385f340 --- /dev/null +++ b/internal/unstable_mock.ts @@ -0,0 +1,55 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +export const MOCK_SYMBOL = Symbol.for("@MOCK"); + +export type MockCall = { + /** Arguments passed to a function when called. */ + args: Args; + /** Call result status. */ + result: "returned" | "thrown"; + /** The value that was returned by a function. */ + returned?: Return; + /** The error value that was thrown by a function. */ + error?: unknown; +}; + +export type MockInternals< + Args extends unknown[] = unknown[], + Return = unknown, +> = { + readonly calls: MockCall[]; +}; +export type Mock = { + readonly [MOCK_SYMBOL]: MockInternals; +}; + +export function isMockFunction unknown>( + func: Fn, +): func is Fn & Mock, ReturnType>; +export function isMockFunction( + func: (...args: Args) => Return, +): func is ((...args: Args) => Return) & Mock; +export function isMockFunction(func: (...args: unknown[]) => unknown) { + return MOCK_SYMBOL in func && func[MOCK_SYMBOL] != null; +} + +export function defineMockInternals unknown>( + func: Fn, + internals?: Partial, ReturnType>>, +): Fn & Mock, ReturnType>; +export function defineMockInternals( + func: (...args: Args) => Return, + internals?: Partial>, +): ((...args: Args) => Return) & Mock; +export function defineMockInternals( + func: (...args: unknown[]) => unknown, + internals?: Partial>, +) { + Object.defineProperty(func, MOCK_SYMBOL, { + value: { calls: [], ...internals }, + writable: false, + enumerable: false, + configurable: true, + }); + return func as never; +} diff --git a/internal/unstable_mock_test.ts b/internal/unstable_mock_test.ts new file mode 100644 index 000000000000..bb6c8584d914 --- /dev/null +++ b/internal/unstable_mock_test.ts @@ -0,0 +1,56 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { assert, assertFalse, assertThrows } from "@std/assert"; +import { + defineMockInternals, + isMockFunction, + MOCK_SYMBOL, +} from "./unstable_mock.ts"; + +Deno.test("defineMockInternals()", async ({ step }) => { + await step("should define readonly MOCK_SYMBOL property", () => { + const fn = () => {}; + defineMockInternals(fn); + + assert(MOCK_SYMBOL in fn, "MOCK_SYMBOL not in fn"); + assert( + typeof fn[MOCK_SYMBOL] === "object" && fn[MOCK_SYMBOL] !== null, + "fn[MOCK_SYMBOL] is not an object", + ); + assert( + "calls" in fn[MOCK_SYMBOL] && Array.isArray(fn[MOCK_SYMBOL].calls), + "fn[MOCK_SYMBOL].calls is not an array", + ); + assertThrows( + () => { + fn[MOCK_SYMBOL] = null; + }, + TypeError, + "", + "fn[MOCK_SYMBOL] is not readonly", + ); + }); + + await step("should accept custom internals object", () => { + const fn = defineMockInternals(() => {}, { test: true } as never); + assert( + "calls" in fn[MOCK_SYMBOL], + "empty calls array should still be in fn[MOCK_SYMBOL]", + ); + assert( + "test" in fn[MOCK_SYMBOL] && fn[MOCK_SYMBOL].test === true, + "test internals extension not found", + ); + }); +}); + +Deno.test("isMockFunction()", () => { + assertFalse( + isMockFunction(() => {}), + "plane function should not be considerate as mock function", + ); + assert( + isMockFunction(defineMockInternals(() => {})), + "function with internals should be considerate as mock function", + ); +}); diff --git a/testing/_unstable_mock_utils.ts b/testing/_unstable_mock_utils.ts new file mode 100644 index 000000000000..a67bb09e8b6f --- /dev/null +++ b/testing/_unstable_mock_utils.ts @@ -0,0 +1,89 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { defineMockInternals } from "@std/internal/unstable_mock"; +import type { Spy, SpyCall } from "./unstable_mock.ts"; + +export { + type Mock, + MOCK_SYMBOL, + type MockCall, + type MockInternals, +} from "@std/internal/unstable_mock"; + +export interface SpyInternals< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + // deno-lint-ignore no-explicit-any + Return = any, + Original = (this: Self, ...args: Args) => Return, +> { + readonly calls: SpyCall[]; + /** The function that is being spied on. */ + readonly original: Original; +} + +export function defineSpyInternals< + Fn extends (this: unknown, ...args: never) => unknown, +>( + func: Fn, + internals?: Partial< + SpyInternals, Parameters, ReturnType> + >, +): Fn & Spy, Parameters, ReturnType>; +export function defineSpyInternals( + func: (this: Self, ...args: Args) => Return, + internals?: Partial>, +): ((this: Self, ...args: Args) => Return) & Spy; +export function defineSpyInternals( + func: (...args: unknown[]) => unknown, + internals?: Partial>, +) { + return defineMockInternals(func, { + original: undefined, + ...internals, + } as SpyInternals); +} + +/** + * Checks if a function is a spy. + * + * @typeParam Self The self type of the function. + * @typeParam Args The arguments type of the function. + * @typeParam Return The return type of the function. + * @param func The function to check + * @return `true` if the function is a spy, `false` otherwise. + */ +export function isSpy( + func: ((this: Self, ...args: Args) => Return) | unknown, +): func is Spy { + const spy = func as Spy; + return ( + typeof spy === "function" && + typeof spy.original === "function" && + typeof spy.restored === "boolean" && + typeof spy.restore === "function" && + Array.isArray(spy.calls) + ); +} + +// deno-lint-ignore no-explicit-any +export const sessions: Set>[] = []; + +// deno-lint-ignore no-explicit-any +function getSession(): Set> { + if (sessions.length === 0) sessions.push(new Set()); + return sessions.at(-1)!; +} + +// deno-lint-ignore no-explicit-any +export function registerMock(spy: Spy) { + const session = getSession(); + session.add(spy); +} + +// deno-lint-ignore no-explicit-any +export function unregisterMock(spy: Spy) { + const session = getSession(); + session.delete(spy); +} diff --git a/testing/deno.json b/testing/deno.json index 1b4464baa62b..e2f8e7b18c95 100644 --- a/testing/deno.json +++ b/testing/deno.json @@ -9,6 +9,7 @@ "./types": "./types.ts", "./unstable-bdd": "./unstable_bdd.ts", "./unstable-stub": "./unstable_stub.ts", - "./unstable-types": "./unstable_types.ts" + "./unstable-types": "./unstable_types.ts", + "./unstable-mock": "./unstable_mock.ts" } } diff --git a/testing/unstable_mock.ts b/testing/unstable_mock.ts new file mode 100644 index 000000000000..28914f70dca3 --- /dev/null +++ b/testing/unstable_mock.ts @@ -0,0 +1,1775 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +/** A mocking and spying library. + * + * Test spies are function stand-ins that are used to assert if a function's + * internal behavior matches expectations. Test spies on methods keep the original + * behavior but allow you to test how the method is called and what it returns. + * Test stubs are an extension of test spies that also replaces the original + * methods behavior. + * + * ## Spying + * + * Say we have two functions, `square` and `multiply`, if we want to assert that + * the `multiply` function is called during execution of the `square` function we + * need a way to spy on the `multiply` function. There are a few ways to achieve + * this with Spies, one is to have the `square` function take the `multiply` + * multiply as a parameter. + * + * This way, we can call `square(multiply, value)` in the application code or wrap + * a spy function around the `multiply` function and call + * `square(multiplySpy, value)` in the testing code. + * + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * function multiply(a: number, b: number): number { + * return a * b; + * } + * + * function square( + * multiplyFn: (a: number, b: number) => number, + * value: number, + * ): number { + * return multiplyFn(value, value); + * } + * + * Deno.test("square calls multiply and returns results", () => { + * const multiplySpy = spy(multiply); + * + * assertEquals(square(multiplySpy, 5), 25); + * + * // asserts that multiplySpy was called at least once and details about the first call. + * assertSpyCall(multiplySpy, 0, { + * args: [5, 5], + * returned: 25, + * }); + * + * // asserts that multiplySpy was only called once. + * assertSpyCalls(multiplySpy, 1); + * }); + * ``` + * + * If you prefer not adding additional parameters for testing purposes only, you + * can use spy to wrap a method on an object instead. In the following example, the + * exported `_internals` object has the `multiply` function we want to call as a + * method and the `square` function calls `_internals.multiply` instead of + * `multiply`. + * + * This way, we can call `square(value)` in both the application code and testing + * code. Then spy on the `multiply` method on the `_internals` object in the + * testing code to be able to spy on how the `square` function calls the `multiply` + * function. + * + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * function multiply(a: number, b: number): number { + * return a * b; + * } + * + * function square(value: number): number { + * return _internals.multiply(value, value); + * } + * + * const _internals = { multiply }; + * + * Deno.test("square calls multiply and returns results", () => { + * const multiplySpy = spy(_internals, "multiply"); + * + * try { + * assertEquals(square(5), 25); + * } finally { + * // unwraps the multiply method on the _internals object + * multiplySpy.restore(); + * } + * + * // asserts that multiplySpy was called at least once and details about the first call. + * assertSpyCall(multiplySpy, 0, { + * args: [5, 5], + * returned: 25, + * }); + * + * // asserts that multiplySpy was only called once. + * assertSpyCalls(multiplySpy, 1); + * }); + * ``` + * + * One difference you may have noticed between these two examples is that in the + * second we call the `restore` method on `multiplySpy` function. That is needed to + * remove the spy wrapper from the `_internals` object's `multiply` method. The + * `restore` method is called in a finally block to ensure that it is restored + * whether or not the assertion in the try block is successful. The `restore` + * method didn't need to be called in the first example because the `multiply` + * function was not modified in any way like the `_internals` object was in the + * second example. + * + * Method spys are disposable, meaning that you can have them automatically restore + * themselves with the `using` keyword. Using this approach is cleaner because you + * do not need to wrap your assertions in a try statement to ensure you restore the + * methods before the tests finish. + * + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * function multiply(a: number, b: number): number { + * return a * b; + * } + * + * function square(value: number): number { + * return _internals.multiply(value, value); + * } + * + * const _internals = { multiply }; + * + * Deno.test("square calls multiply and returns results", () => { + * using multiplySpy = spy(_internals, "multiply"); + * + * assertEquals(square(5), 25); + * + * // asserts that multiplySpy was called at least once and details about the first call. + * assertSpyCall(multiplySpy, 0, { + * args: [5, 5], + * returned: 25, + * }); + * + * // asserts that multiplySpy was only called once. + * assertSpyCalls(multiplySpy, 1); + * }); + * ``` + * + * ## Stubbing + * + * Say we have two functions, `randomMultiple` and `randomInt`, if we want to + * assert that `randomInt` is called during execution of `randomMultiple` we need a + * way to spy on the `randomInt` function. That could be done with either of the + * spying techniques previously mentioned. To be able to verify that the + * `randomMultiple` function returns the value we expect it to for what `randomInt` + * returns, the easiest way would be to replace the `randomInt` function's behavior + * with more predictable behavior. + * + * You could use the first spying technique to do that but that would require + * adding a `randomInt` parameter to the `randomMultiple` function. + * + * You could also use the second spying technique to do that, but your assertions + * would not be as predictable due to the `randomInt` function returning random + * values. + * + * Say we want to verify it returns correct values for both negative and positive + * random integers. We could easily do that with stubbing. The below example is + * similar to the second spying technique example but instead of passing the call + * through to the original `randomInt` function, we are going to replace + * `randomInt` with a function that returns pre-defined values. + * + * The mock module includes some helper functions to make creating common stubs + * easy. The `returnsNext` function takes an array of values we want it to return + * on consecutive calls. + * + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * returnsNext, + * stub, + * } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * function randomInt(lowerBound: number, upperBound: number): number { + * return lowerBound + Math.floor(Math.random() * (upperBound - lowerBound)); + * } + * + * function randomMultiple(value: number): number { + * return value * _internals.randomInt(-10, 10); + * } + * + * const _internals = { randomInt }; + * + * Deno.test("randomMultiple uses randomInt to generate random multiples between -10 and 10 times the value", () => { + * const randomIntStub = stub(_internals, "randomInt", returnsNext([-3, 3])); + * + * try { + * assertEquals(randomMultiple(5), -15); + * assertEquals(randomMultiple(5), 15); + * } finally { + * // unwraps the randomInt method on the _internals object + * randomIntStub.restore(); + * } + * + * // asserts that randomIntStub was called at least once and details about the first call. + * assertSpyCall(randomIntStub, 0, { + * args: [-10, 10], + * returned: -3, + * }); + * // asserts that randomIntStub was called at least twice and details about the second call. + * assertSpyCall(randomIntStub, 1, { + * args: [-10, 10], + * returned: 3, + * }); + * + * // asserts that randomIntStub was only called twice. + * assertSpyCalls(randomIntStub, 2); + * }); + * ``` + * + * Like method spys, stubs are disposable, meaning that you can have them automatically + * restore themselves with the `using` keyword. Using this approach is cleaner because + * you do not need to wrap your assertions in a try statement to ensure you restore the + * methods before the tests finish. + * + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * returnsNext, + * stub, + * } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * function randomInt(lowerBound: number, upperBound: number): number { + * return lowerBound + Math.floor(Math.random() * (upperBound - lowerBound)); + * } + * + * function randomMultiple(value: number): number { + * return value * _internals.randomInt(-10, 10); + * } + * + * const _internals = { randomInt }; + * + * Deno.test("randomMultiple uses randomInt to generate random multiples between -10 and 10 times the value", () => { + * using randomIntStub = stub(_internals, "randomInt", returnsNext([-3, 3])); + * + * assertEquals(randomMultiple(5), -15); + * assertEquals(randomMultiple(5), 15); + * + * // asserts that randomIntStub was called at least once and details about the first call. + * assertSpyCall(randomIntStub, 0, { + * args: [-10, 10], + * returned: -3, + * }); + * // asserts that randomIntStub was called at least twice and details about the second call. + * assertSpyCall(randomIntStub, 1, { + * args: [-10, 10], + * returned: 3, + * }); + * + * // asserts that randomIntStub was only called twice. + * assertSpyCalls(randomIntStub, 2); + * }); + * ``` + * + * ## Faking time + * + * Say we have a function that has time based behavior that we would like to test. + * With real time, that could cause tests to take much longer than they should. If + * you fake time, you could simulate how your function would behave over time + * starting from any point in time. Below is an example where we want to test that + * the callback is called every second. + * + * With `FakeTime` we can do that. When the `FakeTime` instance is created, it + * splits from real time. The `Date`, `setTimeout`, `clearTimeout`, `setInterval` + * and `clearInterval` globals are replaced with versions that use the fake time + * until real time is restored. You can control how time ticks forward with the + * `tick` method on the `FakeTime` instance. + * + * ```ts + * import { + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * import { FakeTime } from "@std/testing/time"; + * + * function secondInterval(cb: () => void): number { + * return setInterval(cb, 1000); + * } + * + * Deno.test("secondInterval calls callback every second and stops after being cleared", () => { + * using time = new FakeTime(); + * + * const cb = spy(); + * const intervalId = secondInterval(cb); + * assertSpyCalls(cb, 0); + * time.tick(500); + * assertSpyCalls(cb, 0); + * time.tick(500); + * assertSpyCalls(cb, 1); + * time.tick(3500); + * assertSpyCalls(cb, 4); + * + * clearInterval(intervalId); + * time.tick(1000); + * assertSpyCalls(cb, 4); + * }); + * ``` + * + * @module + */ + +import { AssertionError } from "@std/assert/assertion-error"; +import { assertEquals } from "@std/assert/equals"; +import { assertIsError } from "@std/assert/is-error"; +import { assertRejects } from "@std/assert/rejects"; +import { + defineSpyInternals, + isSpy, + type Mock, + MOCK_SYMBOL, + type MockCall, + registerMock, + sessions, + type SpyInternals, + unregisterMock, +} from "./_unstable_mock_utils.ts"; + +/** + * An error related to spying on a function or instance method. + * + * @example Usage + * ```ts + * import { MockError, spy } from "@std/testing/mock"; + * import { assertThrows } from "@std/assert"; + * + * assertThrows(() => { + * spy({} as any, "no-such-method"); + * }, MockError); + * ``` + */ +export class MockError extends Error { + /** + * Construct MockError + * + * @param message The error message. + */ + constructor(message: string) { + super(message); + this.name = "MockError"; + } +} + +/** Call information recorded by a spy. */ +export interface SpyCall< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + // deno-lint-ignore no-explicit-any + Return = any, +> extends Omit, "error"> { + /** The error value that was thrown by a function. */ + error?: Error; + /** The instance that a method was called on. */ + self?: Self; +} + +/** A function or instance method wrapper that records all calls made to it. */ +export interface Spy< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + // deno-lint-ignore no-explicit-any + Return = any, +> { + (this: Self, ...args: Args): Return; + /** Mock internals */ + readonly [MOCK_SYMBOL]: SpyInternals; + /** The function that is being spied on. */ + original: (this: Self, ...args: Args) => Return; + /** Information about calls made to the function or instance method. */ + calls: SpyCall[]; + /** Whether or not the original instance method has been restored. */ + restored: boolean; + /** If spying on an instance method, this restores the original instance method. */ + restore(): void; +} + +/** An instance method wrapper that records all calls made to it. */ +export interface MethodSpy< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + // deno-lint-ignore no-explicit-any + Return = any, +> extends Spy, Disposable {} + +/** Wraps a function with a Spy. */ +function functionSpy( + func?: (this: Self, ...args: Args) => Return, +): Spy { + const original = func ?? + ((() => {}) as (this: Self, ...args: Args) => Return); + const calls: SpyCall[] = []; + const spy = function (this: Self, ...args: Args): Return { + const call: SpyCall = { result: "returned", args }; + if (this) call.self = this; + try { + call.returned = original.apply(this, args); + } catch (error) { + call.error = error as Error; + call.result = "thrown"; + calls.push(call); + throw error; + } + calls.push(call); + return call.returned; + } as Spy; + Object.defineProperties(spy, { + original: { + enumerable: true, + value: original, + }, + calls: { + enumerable: true, + value: calls, + }, + restored: { + enumerable: true, + get: () => false, + }, + restore: { + enumerable: true, + value: () => { + throw new MockError("Cannot restore: function cannot be restored"); + }, + }, + }); + defineSpyInternals(spy, { calls, original }); + return spy; +} + +/** + * Creates a session that tracks all mocks created before it's restored. + * If a callback is provided, it restores all mocks created within it. + * + * @example Usage + * ```ts + * import { mockSession, restore, stub } from "@std/testing/mock"; + * import { assertEquals, assertNotEquals } from "@std/assert"; + * + * const setTimeout = globalThis.setTimeout; + * const id = mockSession(); + * + * stub(globalThis, "setTimeout"); + * + * assertNotEquals(globalThis.setTimeout, setTimeout); + * + * restore(id); + * + * assertEquals(globalThis.setTimeout, setTimeout); + * ``` + * + * @returns The id of the created session. + */ +export function mockSession(): number; +/** + * Creates a session that tracks all mocks created before it's restored. + * If a callback is provided, it restores all mocks created within it. + * + * @example Usage + * ```ts + * import { mockSession, restore, stub } from "@std/testing/mock"; + * import { assertEquals, assertNotEquals } from "@std/assert"; + * + * const setTimeout = globalThis.setTimeout; + * const session = mockSession(() => { + * stub(globalThis, "setTimeout"); + * assertNotEquals(globalThis.setTimeout, setTimeout); + * }); + * + * session(); + * + * assertEquals(globalThis.setTimeout, setTimeout); // stub is restored + * ``` + * + * @typeParam Self The self type of the function. + * @typeParam Args The arguments type of the function. + * @typeParam Return The return type of the function. + * @param func The function to be used for the created session. + * @returns The function to execute the session. + */ +export function mockSession( + func: (this: Self, ...args: Args) => Return, +): (this: Self, ...args: Args) => Return; +export function mockSession( + func?: (this: Self, ...args: Args) => Return, +): number | ((this: Self, ...args: Args) => Return) { + if (func) { + return function (this: Self, ...args: Args): Return { + const id = sessions.length; + sessions.push(new Set()); + try { + return func.apply(this, args); + } finally { + restore(id); + } + }; + } else { + sessions.push(new Set()); + return sessions.length - 1; + } +} + +/** + * Creates an async session that tracks all mocks created before the promise resolves. + * + * @example Usage + * ```ts + * import { mockSessionAsync, restore, stub } from "@std/testing/mock"; + * import { assertEquals, assertNotEquals } from "@std/assert"; + * + * const setTimeout = globalThis.setTimeout; + * const session = mockSessionAsync(async () => { + * stub(globalThis, "setTimeout"); + * assertNotEquals(globalThis.setTimeout, setTimeout); + * }); + * + * await session(); + * + * assertEquals(globalThis.setTimeout, setTimeout); // stub is restored + * ``` + * @typeParam Self The self type of the function. + * @typeParam Args The arguments type of the function. + * @typeParam Return The return type of the function. + * @param func The function. + * @returns The return value of the function. + */ +export function mockSessionAsync( + func: (this: Self, ...args: Args) => Promise, +): (this: Self, ...args: Args) => Promise { + return async function (this: Self, ...args: Args): Promise { + const id = sessions.length; + sessions.push(new Set()); + try { + return await func.apply(this, args); + } finally { + restore(id); + } + }; +} + +/** + * Restores all mocks registered in the current session that have not already been restored. + * If an id is provided, it will restore all mocks registered in the session associed with that id that have not already been restored. + * + * @example Usage + * ```ts + * import { mockSession, restore, stub } from "@std/testing/mock"; + * import { assertEquals, assertNotEquals } from "@std/assert"; + * + * const setTimeout = globalThis.setTimeout; + * + * stub(globalThis, "setTimeout"); + * + * assertNotEquals(globalThis.setTimeout, setTimeout); + * + * restore(); + * + * assertEquals(globalThis.setTimeout, setTimeout); + * ``` + * + * @param id The id of the session to restore. If not provided, all mocks registered in the current session are restored. + */ +export function restore(id?: number) { + id ??= (sessions.length || 1) - 1; + while (id < sessions.length) { + const session = sessions.pop(); + if (session) { + for (const value of session) { + value.restore(); + } + } + } +} + +/** Wraps an instance method with a Spy. */ +function methodSpy( + self: Self, + property: keyof Self, +): MethodSpy { + if (typeof self[property] !== "function") { + throw new MockError("Cannot spy: property is not an instance method"); + } + if (isSpy(self[property])) { + throw new MockError("Cannot spy: already spying on instance method"); + } + + const propertyDescriptor = Object.getOwnPropertyDescriptor(self, property); + if (propertyDescriptor && !propertyDescriptor.configurable) { + throw new MockError("Cannot spy: non-configurable instance method"); + } + + const original = self[property] as unknown as ( + this: Self, + ...args: Args + ) => Return; + const calls: SpyCall[] = []; + let restored = false; + const spy = function (this: Self, ...args: Args): Return { + const call: SpyCall = { result: "returned", args }; + if (this) call.self = this; + try { + call.returned = original.apply(this, args); + } catch (error) { + call.error = error as Error; + call.result = "thrown"; + calls.push(call); + throw error; + } + calls.push(call); + return call.returned; + } as MethodSpy; + Object.defineProperties(spy, { + original: { + enumerable: true, + value: original, + }, + calls: { + enumerable: true, + value: calls, + }, + restored: { + enumerable: true, + get: () => restored, + }, + restore: { + enumerable: true, + value: () => { + if (restored) { + throw new MockError( + "Cannot restore: instance method already restored", + ); + } + if (propertyDescriptor) { + Object.defineProperty(self, property, propertyDescriptor); + } else { + delete self[property]; + } + restored = true; + unregisterMock(spy); + }, + }, + [Symbol.dispose]: { + value: () => { + spy.restore(); + }, + }, + }); + defineSpyInternals(spy, { calls, original }); + + Object.defineProperty(self, property, { + configurable: true, + enumerable: propertyDescriptor?.enumerable ?? false, + writable: propertyDescriptor?.writable ?? false, + value: spy, + }); + + registerMock(spy); + return spy; +} + +/** A constructor wrapper that records all calls made to it. */ +export interface ConstructorSpy< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], +> { + /** Construct an instance. */ + new (...args: Args): Self; + /** Mock internals */ + readonly [MOCK_SYMBOL]: SpyInternals< + Self, + Args, + Self, + new (...args: Args) => Self + >; + /** The function that is being spied on. */ + original: new (...args: Args) => Self; + /** Information about calls made to the function or instance method. */ + calls: SpyCall[]; + /** Whether or not the original instance method has been restored. */ + restored: boolean; + /** If spying on an instance method, this restores the original instance method. */ + restore(): void; +} + +/** Wraps a constructor with a Spy. */ +function constructorSpy( + constructor: new (...args: Args) => Self, +): ConstructorSpy { + const original = constructor; + const calls: SpyCall[] = []; + // @ts-ignore TS2509: Can't know the type of `original` statically. + const spy = class extends original { + // deno-lint-ignore constructor-super + constructor(...args: Args) { + const call: SpyCall = { result: "returned", args }; + try { + super(...args); + call.returned = this as unknown as Self; + } catch (error) { + call.error = error as Error; + call.result = "thrown"; + calls.push(call); + throw error; + } + calls.push(call); + } + static readonly name = original.name; + static readonly original = original; + static readonly calls = calls; + static readonly restored = false; + static restore() { + throw new MockError("Cannot restore: constructor cannot be restored"); + } + } as ConstructorSpy; + defineSpyInternals(spy as never, { calls, original: original as never }); + return spy; +} + +/** + * Utility for extracting the arguments type from a property + * + * @internal + */ +export type GetParametersFromProp< + Self, + Prop extends keyof Self, +> = Self[Prop] extends (...args: infer Args) => unknown ? Args : unknown[]; + +/** + * Utility for extracting the return type from a property + * + * @internal + */ +export type GetReturnFromProp< + Self, + Prop extends keyof Self, +> // deno-lint-ignore no-explicit-any + = Self[Prop] extends (...args: any[]) => infer Return ? Return : unknown; + +/** SpyLink object type. */ +export type SpyLike< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + // deno-lint-ignore no-explicit-any + Return = any, +> = Spy | ConstructorSpy; + +/** Creates a spy function. + * + * @example Usage + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * + * const func = spy(); + * + * func(); + * func(1); + * func(2, 3); + * + * assertSpyCalls(func, 3); + * + * // asserts each call made to the spy function. + * assertSpyCall(func, 0, { args: [] }); + * assertSpyCall(func, 1, { args: [1] }); + * assertSpyCall(func, 2, { args: [2, 3] }); + * ``` + * + * @typeParam Self The self type of the function. + * @typeParam Args The arguments type of the function. + * @typeParam Return The return type of the function. + * @returns The spy function. + */ +export function spy< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + Return = undefined, +>(): Spy; +/** + * Create a spy function with the given implementation. + * + * @example Usage + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * + * const func = spy((a: number, b: number) => a + b); + * + * func(3, 4); + * func(5, 6); + * + * assertSpyCalls(func, 2); + * + * // asserts each call made to the spy function. + * assertSpyCall(func, 0, { args: [3, 4], returned: 7 }); + * assertSpyCall(func, 1, { args: [5, 6], returned: 11 }); + * ``` + * + * @typeParam Self The self type of the function to wrap + * @typeParam Args The arguments type of the function to wrap + * @typeParam Return The return type of the function to wrap + * @param func The function to wrap + * @returns The wrapped function. + */ +export function spy( + func: (this: Self, ...args: Args) => Return, +): Spy; +/** + * Create a spy constructor. + * + * @example Usage + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * + * class Foo { + * constructor(value: string) {} + * }; + * + * const Constructor = spy(Foo); + * + * new Constructor("foo"); + * new Constructor("bar"); + * + * assertSpyCalls(Constructor, 2); + * + * // asserts each call made to the spy function. + * assertSpyCall(Constructor, 0, { args: ["foo"] }); + * assertSpyCall(Constructor, 1, { args: ["bar"] }); + * ``` + * + * @typeParam Self The type of the instance of the class. + * @typeParam Args The arguments type of the constructor + * @param constructor The constructor to spy. + * @returns The wrapped constructor. + */ +export function spy( + constructor: new (...args: Args) => Self, +): ConstructorSpy; +/** + * Wraps a instance method with a Spy. + * + * @example Usage + * ```ts + * import { + * assertSpyCall, + * assertSpyCalls, + * spy, + * } from "@std/testing/mock"; + * + * const obj = { + * method(a: number, b: number): number { + * return a + b; + * }, + * }; + * + * const methodSpy = spy(obj, "method"); + * + * obj.method(1, 2); + * obj.method(3, 4); + * + * assertSpyCalls(methodSpy, 2); + * + * // asserts each call made to the spy function. + * assertSpyCall(methodSpy, 0, { args: [1, 2], returned: 3 }); + * assertSpyCall(methodSpy, 1, { args: [3, 4], returned: 7 }); + * ``` + * + * @typeParam Self The type of the instance to spy the method of. + * @typeParam Prop The property to spy. + * @param self The instance to spy. + * @param property The property of the method to spy. + * @returns The spy function. + */ +export function spy( + self: Self, + property: Prop, +): MethodSpy< + Self, + GetParametersFromProp, + GetReturnFromProp +>; +export function spy( + funcOrConstOrSelf?: + | ((this: Self, ...args: Args) => Return) + | (new (...args: Args) => Self) + | Self, + property?: keyof Self, +): SpyLike { + if (!funcOrConstOrSelf) { + return functionSpy(); + } else if (property !== undefined) { + return methodSpy(funcOrConstOrSelf as Self, property); + } else if (funcOrConstOrSelf.toString().startsWith("class")) { + return constructorSpy( + funcOrConstOrSelf as new (...args: Args) => Self, + ); + } else { + return functionSpy( + funcOrConstOrSelf as (this: Self, ...args: Args) => Return, + ); + } +} + +/** An instance method replacement that records all calls made to it. */ +export interface Stub< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + // deno-lint-ignore no-explicit-any + Return = any, +> extends MethodSpy { + /** The function that is used instead of the original. */ + fake: (this: Self, ...args: Args) => Return; +} + +/** + * Replaces an instance method with a Stub with empty implementation. + * + * @example Usage + * ```ts + * import { stub, assertSpyCalls } from "@std/testing/mock"; + * + * const obj = { + * method() { + * // some inconventient feature for testing + * }, + * }; + * + * const methodStub = stub(obj, "method"); + * + * for (const _ of Array(5)) { + * obj.method(); + * } + * + * assertSpyCalls(methodStub, 5); + * ``` + + * + * @typeParam Self The self type of the instance to replace a method of. + * @typeParam Prop The property of the instance to replace. + * @param self The instance to replace a method of. + * @param property The property of the instance to replace. + * @returns The stub function which replaced the original. + */ +export function stub( + self: Self, + property: Prop, +): Stub, GetReturnFromProp>; +/** + * Replaces an instance method with a Stub with the given implementation. + * + * @example Usage + * ```ts + * import { stub } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * const obj = { + * method(): number { + * return Math.random(); + * }, + * }; + * + * const methodStub = stub(obj, "method", () => 0.5); + * + * assertEquals(obj.method(), 0.5); + * ``` + * + * @typeParam Self The self type of the instance to replace a method of. + * @typeParam Prop The property of the instance to replace. + * @param self The instance to replace a method of. + * @param property The property of the instance to replace. + * @param func The fake implementation of the function. + * @returns The stub function which replaced the original. + */ +export function stub( + self: Self, + property: Prop, + func: ( + this: Self, + ...args: GetParametersFromProp + ) => GetReturnFromProp, +): Stub, GetReturnFromProp>; +export function stub( + self: Self, + property: keyof Self, + func?: (this: Self, ...args: Args) => Return, +): Stub { + if (self[property] !== undefined && typeof self[property] !== "function") { + throw new MockError("Cannot stub: property is not an instance method"); + } + if (isSpy(self[property])) { + throw new MockError("Cannot stub: already spying on instance method"); + } + + const propertyDescriptor = Object.getOwnPropertyDescriptor(self, property); + if (propertyDescriptor && !propertyDescriptor.configurable) { + throw new MockError("Cannot stub: non-configurable instance method"); + } + + const fake = func ?? ((() => {}) as (this: Self, ...args: Args) => Return); + + const original = self[property] as unknown as ( + this: Self, + ...args: Args + ) => Return; + const calls: SpyCall[] = []; + let restored = false; + const stub = function (this: Self, ...args: Args): Return { + const call: SpyCall = { result: "returned", args }; + if (this) call.self = this; + try { + call.returned = fake.apply(this, args); + } catch (error) { + call.error = error as Error; + call.result = "thrown"; + calls.push(call); + throw error; + } + calls.push(call); + return call.returned; + } as Stub; + Object.defineProperties(stub, { + original: { + enumerable: true, + value: original, + }, + fake: { + enumerable: true, + value: fake, + }, + calls: { + enumerable: true, + value: calls, + }, + restored: { + enumerable: true, + get: () => restored, + }, + restore: { + enumerable: true, + value: () => { + if (restored) { + throw new MockError( + "Cannot restore: instance method already restored", + ); + } + if (propertyDescriptor) { + Object.defineProperty(self, property, propertyDescriptor); + } else { + delete self[property]; + } + restored = true; + unregisterMock(stub); + }, + }, + [Symbol.dispose]: { + value: () => { + stub.restore(); + }, + }, + }); + defineSpyInternals(stub, { calls, original: original }); + + Object.defineProperty(self, property, { + configurable: true, + enumerable: propertyDescriptor?.enumerable ?? false, + writable: propertyDescriptor?.writable ?? false, + value: stub, + }); + + registerMock(stub); + return stub; +} + +function getMockCalls( + mock: Mock, +): MockCall[]; +function getMockCalls( + spy: SpyLike, +): SpyCall[]; +function getMockCalls( + spyOrMock: SpyLike | Mock, +): SpyCall[] | MockCall[]; +function getMockCalls(mock: Mock | SpyLike): SpyCall[] | MockCall[] { + return mock[MOCK_SYMBOL].calls; +} + +/** + * Asserts that a spy is called as much as expected and no more. + * + * @example Usage + * ```ts + * import { assertSpyCalls, spy } from "@std/testing/mock"; + * + * const func = spy(); + * + * func(); + * func(); + * + * assertSpyCalls(func, 2); + * ``` + * + * @typeParam Self The self type of the spy function. + * @typeParam Args The arguments type of the spy function. + * @typeParam Return The return type of the spy function. + * @param spy The spy to check + * @param expectedCalls The number of the expected calls. + */ +export function assertSpyCalls( + spy: Mock | SpyLike, + expectedCalls: number, +) { + const calls = getMockCalls(spy); + try { + assertEquals(calls.length, expectedCalls); + } catch (e) { + assertIsError(e); + let message = calls.length < expectedCalls + ? "Spy not called as much as expected:\n" + : "Spy called more than expected:\n"; + message += e.message.split("\n").slice(1).join("\n"); + throw new AssertionError(message); + } +} + +/** Call information recorded by a spy. */ +export interface ExpectedSpyCall< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], + // deno-lint-ignore no-explicit-any + Return = any, +> { + /** Arguments passed to a function when called. */ + args?: [...Args, ...unknown[]]; + /** The instance that a method was called on. */ + self?: Self; + /** + * The value that was returned by a function. + * If you expect a promise to reject, expect error instead. + */ + returned?: Return; + /** The expected thrown error. */ + error?: { + /** The class for the error that was thrown by a function. */ + // deno-lint-ignore no-explicit-any + Class?: new (...args: any[]) => Error; + /** Part of the message for the error that was thrown by a function. */ + msgIncludes?: string; + }; +} + +function getMockCall( + mock: Mock, + callIndex: number, +): MockCall; +function getMockCall( + spy: SpyLike, + callIndex: number, +): SpyCall; +function getMockCall( + spyOrMock: Mock | SpyLike, + callIndex: number, +): SpyCall | MockCall; +function getMockCall( + mock: Mock | SpyLike, + callIndex: number, +): SpyCall | MockCall { + const calls = getMockCalls(mock); + if (calls.length < callIndex + 1) { + throw new AssertionError("Spy not called as much as expected"); + } + return calls[callIndex] as never; +} + +/** + * Asserts that a spy is called as expected. + * + * @example Usage + * ```ts + * import { assertSpyCall, spy } from "@std/testing/mock"; + * + * const func = spy((a: number, b: number) => a + b); + * + * func(3, 4); + * func(5, 6); + * + * // asserts each call made to the spy function. + * assertSpyCall(func, 0, { args: [3, 4], returned: 7 }); + * assertSpyCall(func, 1, { args: [5, 6], returned: 11 }); + * ``` + * + * @typeParam Self The self type of the spy function. + * @typeParam Args The arguments type of the spy function. + * @typeParam Return The return type of the spy function. + * @param spy The spy to check + * @param callIndex The index of the call to check + * @param expected The expected spy call. + */ +export function assertSpyCall( + spy: SpyLike | Mock, + callIndex: number, + expected?: ExpectedSpyCall, +) { + const call = getMockCall(spy, callIndex); + if (expected) { + if (expected.args) { + try { + assertEquals(call.args, expected.args); + } catch (e) { + assertIsError(e); + throw new AssertionError( + "Spy not called with expected args:\n" + + e.message.split("\n").slice(1).join("\n"), + ); + } + } + + if ("self" in expected) { + try { + assertEquals("self" in call ? call.self : undefined, expected.self); + } catch (e) { + assertIsError(e); + let message = expected.self + ? "Spy not called as method on expected self:\n" + : "Spy not expected to be called as method on object:\n"; + message += e.message.split("\n").slice(1).join("\n"); + throw new AssertionError(message); + } + } + + if ("returned" in expected) { + if ("error" in expected) { + throw new TypeError( + "Do not expect error and return, only one should be expected", + ); + } + if (call.error) { + throw new AssertionError( + "Spy call did not return expected value, an error was thrown.", + ); + } + try { + assertEquals(call.returned, expected.returned); + } catch (e) { + assertIsError(e); + throw new AssertionError( + "Spy call did not return expected value:\n" + + e.message.split("\n").slice(1).join("\n"), + ); + } + } + + if ("error" in expected) { + if ("returned" in call) { + throw new AssertionError( + "Spy call did not throw an error, a value was returned.", + ); + } + assertIsError( + call.error, + expected.error?.Class, + expected.error?.msgIncludes, + ); + } + } +} + +/** + * Asserts that an async spy is called as expected. + * + * @example Usage + * ```ts + * import { assertSpyCallAsync, spy } from "@std/testing/mock"; + * + * const func = spy((a: number, b: number) => new Promise((resolve) => { + * setTimeout(() => resolve(a + b), 100) + * })); + * + * await func(3, 4); + * await func(5, 6); + * + * // asserts each call made to the spy function. + * await assertSpyCallAsync(func, 0, { args: [3, 4], returned: 7 }); + * await assertSpyCallAsync(func, 1, { args: [5, 6], returned: 11 }); + * ``` + * + * @typeParam Self The self type of the spy function. + * @typeParam Args The arguments type of the spy function. + * @typeParam Return The return type of the spy function. + * @param spy The spy to check + * @param callIndex The index of the call to check + * @param expected The expected spy call. + */ +export async function assertSpyCallAsync( + spy: SpyLike> | Mock>, + callIndex: number, + expected?: ExpectedSpyCall | Return>, +) { + const expectedSync = expected && { ...expected }; + if (expectedSync) { + delete expectedSync.returned; + delete expectedSync.error; + } + assertSpyCall(spy, callIndex, expectedSync); + const call = getMockCall(spy, callIndex); + + if (call.error) { + throw new AssertionError( + "Spy call did not return a promise, an error was thrown.", + ); + } + if (call.returned !== Promise.resolve(call.returned)) { + throw new AssertionError( + "Spy call did not return a promise, a value was returned.", + ); + } + + if (expected) { + if ("returned" in expected) { + if ("error" in expected) { + throw new TypeError( + "Do not expect error and return, only one should be expected", + ); + } + let expectedResolved; + try { + expectedResolved = await expected.returned; + } catch { + throw new TypeError( + "Do not expect rejected promise, expect error instead", + ); + } + + let resolved; + try { + resolved = await call.returned; + } catch { + throw new AssertionError("Spy call returned promise was rejected"); + } + + try { + assertEquals(resolved, expectedResolved); + } catch (e) { + assertIsError(e); + throw new AssertionError( + "Spy call did not resolve to expected value:\n" + + e.message.split("\n").slice(1).join("\n"), + ); + } + } + + if ("error" in expected) { + await assertRejects( + () => Promise.resolve(call.returned), + expected.error?.Class ?? Error, + expected.error?.msgIncludes ?? "", + ); + } + } +} + +/** + * Asserts that a spy is called with a specific arg as expected. + * + * @example Usage + * ```ts + * import { assertSpyCallArg, spy } from "@std/testing/mock"; + * + * const func = spy((a: number, b: number) => a + b); + * + * func(3, 4); + * func(5, 6); + * + * // asserts each call made to the spy function. + * assertSpyCallArg(func, 0, 0, 3); + * assertSpyCallArg(func, 0, 1, 4); + * assertSpyCallArg(func, 1, 0, 5); + * assertSpyCallArg(func, 1, 1, 6); + * ``` + * + * @typeParam Self The self type of the spy function. + * @typeParam Args The arguments type of the spy function. + * @typeParam Return The return type of the spy function. + * @typeParam ExpectedArg The expected type of the argument for the spy to be called. + * @param spy The spy to check. + * @param callIndex The index of the call to check. + * @param argIndex The index of the arguments to check. + * @param expected The expected argument. + * @returns The actual argument. + */ +export function assertSpyCallArg< + Self, + Args extends unknown[], + Return, + ExpectedArg, +>( + spy: SpyLike | Mock, + callIndex: number, + argIndex: number, + expected: ExpectedArg, +): ExpectedArg { + const call = getMockCall(spy, callIndex); + const arg = call?.args[argIndex]; + assertEquals(arg, expected); + return arg as ExpectedArg; +} + +/** + * Asserts that an spy is called with a specific range of args as expected. + * If a start and end index is not provided, the expected will be compared against all args. + * If a start is provided without an end index, the expected will be compared against all args from the start index to the end. + * The end index is not included in the range of args that are compared. + * + * @example Usage + * ```ts + * import { assertSpyCallArgs, spy } from "@std/testing/mock"; + * + * const func = spy((a: number, b: number) => a + b); + * + * func(3, 4); + * func(5, 6); + * + * // asserts each call made to the spy function. + * assertSpyCallArgs(func, 0, [3, 4]); + * assertSpyCallArgs(func, 1, [5, 6]); + * ``` + * + * @typeParam Self The self type of the spy function. + * @typeParam Args The arguments type of the spy function. + * @typeParam Return The return type of the spy function. + * @typeParam ExpectedArgs The expected type of the arguments for the spy to be called. + * @param spy The spy to check. + * @param callIndex The index of the call to check. + * @param expected The expected arguments. + * @returns The actual arguments. + */ +export function assertSpyCallArgs< + Self, + Args extends unknown[], + Return, + ExpectedArgs extends unknown[], +>( + spy: SpyLike | Mock, + callIndex: number, + expected: ExpectedArgs, +): ExpectedArgs; +/** + * Asserts that an spy is called with a specific range of args as expected. + * If a start and end index is not provided, the expected will be compared against all args. + * If a start is provided without an end index, the expected will be compared against all args from the start index to the end. + * The end index is not included in the range of args that are compared. + * + * @example Usage + * ```ts + * import { assertSpyCallArgs, spy } from "@std/testing/mock"; + * + * const func = spy((...args) => {}); + * + * func(0, 1, 2, 3, 4, 5); + * + * assertSpyCallArgs(func, 0, 3, [3, 4, 5]); + * ``` + * + * @typeParam Self The self type of the spy function. + * @typeParam Args The arguments type of the spy function. + * @typeParam Return The return type of the spy function. + * @typeParam ExpectedArgs The expected type of the arguments for the spy to be called. + * @param spy The spy to check. + * @param callIndex The index of the call to check. + * @param argsStart The start index of the arguments to check. If not specified, it checks the arguments from the beignning. + * @param expected The expected arguments. + * @returns The actual arguments. + */ +export function assertSpyCallArgs< + Self, + Args extends unknown[], + Return, + ExpectedArgs extends unknown[], +>( + spy: SpyLike | Mock, + callIndex: number, + argsStart: number, + expected: ExpectedArgs, +): ExpectedArgs; +/** + * Asserts that an spy is called with a specific range of args as expected. + * If a start and end index is not provided, the expected will be compared against all args. + * If a start is provided without an end index, the expected will be compared against all args from the start index to the end. + * The end index is not included in the range of args that are compared. + * + * @example Usage + * ```ts + * import { assertSpyCallArgs, spy } from "@std/testing/mock"; + * + * const func = spy((...args) => {}); + * + * func(0, 1, 2, 3, 4, 5); + * + * assertSpyCallArgs(func, 0, 3, 4, [3]); + * ``` + * + * @typeParam Self The self type of the spy function. + * @typeParam Args The arguments type of the spy function. + * @typeParam Return The return type of the spy function. + * @typeParam ExpectedArgs The expected type of the arguments for the spy to be called. + * @param spy The spy to check + * @param callIndex The index of the call to check + * @param argsStart The start index of the arguments to check. If not specified, it checks the arguments from the beignning. + * @param argsEnd The end index of the arguments to check. If not specified, it checks the arguments until the end. + * @param expected The expected arguments. + * @returns The actual arguments + */ +export function assertSpyCallArgs< + Self, + Args extends unknown[], + Return, + ExpectedArgs extends unknown[], +>( + spy: SpyLike | Mock, + callIndex: number, + argsStart: number, + argsEnd: number, + expected: ExpectedArgs, +): ExpectedArgs; +export function assertSpyCallArgs< + ExpectedArgs extends unknown[], + Args extends unknown[], + Return, + Self, +>( + spy: SpyLike | Mock, + callIndex: number, + argsStart?: number | ExpectedArgs, + argsEnd?: number | ExpectedArgs, + expected?: ExpectedArgs, +): ExpectedArgs { + const call = getMockCall(spy, callIndex); + if (!expected) { + expected = argsEnd as ExpectedArgs; + argsEnd = undefined; + } + if (!expected) { + expected = argsStart as ExpectedArgs; + argsStart = undefined; + } + const args = typeof argsEnd === "number" + ? call.args.slice(argsStart as number, argsEnd) + : typeof argsStart === "number" + ? call.args.slice(argsStart) + : call.args; + assertEquals(args, expected); + return args as ExpectedArgs; +} + +/** + * Creates a function that returns the instance the method was called on. + * + * @example Usage + * ```ts + * import { returnsThis } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * const func = returnsThis(); + * const obj = { func }; + * assertEquals(obj.func(), obj); + * ``` + * + * @typeParam Self The self type of the returned function. + * @typeParam Args The arguments type of the returned function. + * @returns A function that returns the instance the method was called on. + */ +export function returnsThis< + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], +>(): (this: Self, ...args: Args) => Self { + return function (this: Self): Self { + return this; + }; +} + +/** + * Creates a function that returns one of its arguments. + * + * @example Usage + * ```ts + * import { returnsArg } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * const func = returnsArg(1); + * assertEquals(func(1, 2, 3), 2); + * ``` + * + * @typeParam Arg The type of returned argument. + * @typeParam Self The self type of the returned function. + * @param idx The index of the arguments to use. + * @returns A function that returns one of its arguments. + */ +export function returnsArg< + Arg, + // deno-lint-ignore no-explicit-any + Self = any, +>(idx: number): (this: Self, ...args: Arg[]) => Arg | undefined { + return function (...args: Arg[]): Arg | undefined { + return args[idx]; + }; +} + +/** + * Creates a function that returns its arguments or a subset of them. If end is specified, it will return arguments up to but not including the end. + * + * @example Usage + * ```ts + * import { returnsArgs } from "@std/testing/mock"; + * import { assertEquals } from "@std/assert"; + * + * const func = returnsArgs(); + * assertEquals(func(1, 2, 3), [1, 2, 3]); + * ``` + * + * @typeParam Args The arguments type of the returned function + * @typeParam Self The self type of the returned function + * @param start The start index of the arguments to return. Default is 0. + * @param end The end index of the arguments to return. + * @returns A function that returns its arguments or a subset of them. + */ +export function returnsArgs< + Args extends unknown[], + // deno-lint-ignore no-explicit-any + Self = any, +>(start = 0, end?: number): (this: Self, ...args: Args) => Args { + return function (this: Self, ...args: Args): Args { + return args.slice(start, end) as Args; + }; +} + +/** + * Creates a function that returns the iterable values. Any iterable values that are errors will be thrown. + * + * @example Usage + * ```ts + * import { returnsNext } from "@std/testing/mock"; + * import { assertEquals, assertThrows } from "@std/assert"; + * + * const func = returnsNext([1, 2, new Error("foo"), 3]); + * assertEquals(func(), 1); + * assertEquals(func(), 2); + * assertThrows(() => func(), Error, "foo"); + * assertEquals(func(), 3); + * ``` + * + * @typeParam Return The type of each item of the iterable + * @typeParam Self The self type of the returned function + * @typeParam Args The arguments type of the returned function + * @param values The iterable values + * @return A function that returns the iterable values + */ +export function returnsNext< + Return, + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], +>(values: Iterable): (this: Self, ...args: Args) => Return { + const gen = (function* returnsValue() { + yield* values; + })(); + let calls = 0; + return function () { + const next = gen.next(); + if (next.done) { + throw new MockError( + `Not expected to be called more than ${calls} time(s)`, + ); + } + calls++; + const { value } = next; + if (value instanceof Error) throw value; + return value; + }; +} + +/** + * Creates a function that resolves the awaited iterable values. Any awaited iterable values that are errors will be thrown. + * + * @example Usage + * ```ts + * import { resolvesNext } from "@std/testing/mock"; + * import { assertEquals, assertRejects } from "@std/assert"; + * + * const func = resolvesNext([1, 2, new Error("foo"), 3]); + * assertEquals(await func(), 1); + * assertEquals(await func(), 2); + * assertRejects(() => func(), Error, "foo"); + * assertEquals(await func(), 3); + * ``` + * + * @typeParam Return The type of each item of the iterable + * @typeParam Self The self type of the returned function + * @typeParam Args The type of arguments of the returned function + * @param iterable The iterable to use + * @returns A function that resolves the awaited iterable values + */ +export function resolvesNext< + Return, + // deno-lint-ignore no-explicit-any + Self = any, + // deno-lint-ignore no-explicit-any + Args extends unknown[] = any[], +>( + iterable: + | Iterable> + | AsyncIterable>, +): (this: Self, ...args: Args) => Promise { + const gen = (async function* returnsValue() { + yield* iterable; + })(); + let calls = 0; + return async function () { + const next = await gen.next(); + if (next.done) { + throw new MockError( + `Not expected to be called more than ${calls} time(s)`, + ); + } + calls++; + const { value } = next; + if (value instanceof Error) throw value; + return value; + }; +} diff --git a/testing/unstable_mock_test.ts b/testing/unstable_mock_test.ts new file mode 100644 index 000000000000..21292f53b9ae --- /dev/null +++ b/testing/unstable_mock_test.ts @@ -0,0 +1,2402 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { + assertEquals, + AssertionError, + assertNotEquals, + assertRejects, + assertThrows, +} from "@std/assert"; +import { delay } from "@std/async/delay"; +import { Point, type PointWithExtra, stringifyPoint } from "./_test_utils.ts"; +import { + assertSpyCall, + assertSpyCallArg, + assertSpyCallArgs, + assertSpyCallAsync, + assertSpyCalls, + type ExpectedSpyCall, + type MethodSpy, + MockError, + mockSession, + mockSessionAsync, + resolvesNext, + restore, + returnsArg, + returnsArgs, + returnsNext, + returnsThis, + type Spy, + spy, + type Stub, + stub, +} from "./unstable_mock.ts"; + +Deno.test("spy()", () => { + const func = spy(); + assertSpyCalls(func, 0); + + assertEquals(func(), undefined); + assertSpyCall(func, 0, { + self: undefined, + args: [], + returned: undefined, + }); + assertSpyCalls(func, 1); + + assertEquals(func("x"), undefined); + assertSpyCall(func, 1, { + self: undefined, + args: ["x"], + returned: undefined, + }); + assertSpyCalls(func, 2); + + assertEquals(func({ x: 3 }), undefined); + assertSpyCall(func, 2, { + self: undefined, + args: [{ x: 3 }], + returned: undefined, + }); + assertSpyCalls(func, 3); + + assertEquals(func(3, 5, 7), undefined); + assertSpyCall(func, 3, { + self: undefined, + args: [3, 5, 7], + returned: undefined, + }); + assertSpyCalls(func, 4); + + const point: Point = new Point(2, 3); + assertEquals(func(Point, stringifyPoint, point), undefined); + assertSpyCall(func, 4, { + self: undefined, + args: [Point, stringifyPoint, point], + returned: undefined, + }); + assertSpyCalls(func, 5); + + assertEquals(func.restored, false); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: function cannot be restored", + ); + assertEquals(func.restored, false); +}); + +Deno.test("spy() works on function", () => { + const func = spy((value) => value); + assertSpyCalls(func, 0); + + assertEquals(func(undefined), undefined); + assertSpyCall(func, 0, { + self: undefined, + args: [undefined], + returned: undefined, + }); + assertSpyCalls(func, 1); + + assertEquals(func("x"), "x"); + assertSpyCall(func, 1, { + self: undefined, + args: ["x"], + returned: "x", + }); + assertSpyCalls(func, 2); + + assertEquals(func({ x: 3 }), { x: 3 }); + assertSpyCall(func, 2, { + self: undefined, + args: [{ x: 3 }], + returned: { x: 3 }, + }); + assertSpyCalls(func, 3); + + const point = new Point(2, 3); + assertEquals(func(point), point); + assertSpyCall(func, 3, { + self: undefined, + args: [point], + returned: point, + }); + assertSpyCalls(func, 4); + + assertEquals(func.restored, false); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: function cannot be restored", + ); + assertEquals(func.restored, false); + + // Check if the returned type is correct: + const explicitTypesSpy = spy(point, "explicitTypes"); + assertThrows(() => { + assertSpyCall(explicitTypesSpy, 0, { + // @ts-expect-error Test if passing incorrect argument types causes an error + args: ["not a number", "string"], + // @ts-expect-error Test if passing incorrect return type causes an error + returned: "not a boolean", + }); + }); + + // Calling assertSpyCall with the correct types should not cause any type errors: + point.explicitTypes(1, "hello"); + assertSpyCall(explicitTypesSpy, 0, { + args: [1, "hello"], + returned: true, + }); +}); + +Deno.test("spy() works on instance method", () => { + const point = new Point(2, 3); + const func = spy(point, "action"); + assertSpyCalls(func, 0); + + assertEquals(func.call(point), undefined); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), undefined); + assertSpyCall(func, 1, { self: point, args: [] }); + assertSpyCalls(func, 2); + + assertEquals(func.call(point, "x"), "x"); + assertSpyCall(func, 2, { + self: point, + args: ["x"], + returned: "x", + }); + assertSpyCalls(func, 3); + + assertEquals(point.action("x"), "x"); + assertSpyCall(func, 3, { + self: point, + args: ["x"], + returned: "x", + }); + assertSpyCalls(func, 4); + + assertEquals(func.call(point, { x: 3 }), { x: 3 }); + assertSpyCall(func, 4, { + self: point, + args: [{ x: 3 }], + returned: { x: 3 }, + }); + assertSpyCalls(func, 5); + + assertEquals(point.action({ x: 3 }), { x: 3 }); + assertSpyCall(func, 5, { + self: point, + args: [{ x: 3 }], + returned: { x: 3 }, + }); + assertSpyCalls(func, 6); + + assertEquals(func.call(point, 3, 5, 7), 3); + assertSpyCall(func, 6, { + self: point, + args: [3, 5, 7], + returned: 3, + }); + assertSpyCalls(func, 7); + + assertEquals(point.action(3, 5, 7), 3); + assertSpyCall(func, 7, { + self: point, + args: [3, 5, 7], + returned: 3, + }); + assertSpyCalls(func, 8); + + assertEquals(func.call(point, Point, stringifyPoint, point), Point); + assertSpyCall(func, 8, { + self: point, + args: [Point, stringifyPoint, point], + returned: Point, + }); + assertSpyCalls(func, 9); + + assertEquals(point.action(Point, stringifyPoint, point), Point); + assertSpyCall(func, 9, { + self: point, + args: [Point, stringifyPoint, point], + returned: Point, + }); + assertSpyCalls(func, 10); + + assertNotEquals(func, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); + +Deno.test("spy() works on instance method symbol", () => { + const point = new Point(2, 3); + const func = spy(point, Symbol.iterator); + assertSpyCalls(func, 0); + + const values: number[] = []; + for (const value of point) { + values.push(value); + } + assertSpyCall(func, 0, { + self: point, + args: [], + }); + assertSpyCalls(func, 1); + + assertEquals(values, [2, 3]); + assertEquals([...point], [2, 3]); + assertSpyCall(func, 1, { + self: point, + args: [], + }); + assertSpyCalls(func, 2); + + assertNotEquals(func, Point.prototype[Symbol.iterator]); + assertEquals(point[Symbol.iterator], func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(point[Symbol.iterator], Point.prototype[Symbol.iterator]); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); + +Deno.test("spy() works on instance method property descriptor", () => { + const point = new Point(2, 3); + const actionDescriptor: PropertyDescriptor = { + configurable: true, + enumerable: false, + writable: false, + value: function (...args: unknown[]) { + return args[1]; + }, + }; + Object.defineProperty(point, "action", actionDescriptor); + const action = spy(point, "action"); + assertSpyCalls(action, 0); + + assertEquals(action.call(point), undefined); + assertSpyCall(action, 0, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(action, 1); + + assertEquals(point.action(), undefined); + assertSpyCall(action, 1, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(action, 2); + + assertEquals(action.call(point, "x", "y"), "y"); + assertSpyCall(action, 2, { + self: point, + args: ["x", "y"], + returned: "y", + }); + assertSpyCalls(action, 3); + + assertEquals(point.action("x", "y"), "y"); + assertSpyCall(action, 3, { + self: point, + args: ["x", "y"], + returned: "y", + }); + assertSpyCalls(action, 4); + + assertNotEquals(action, actionDescriptor.value); + assertEquals(point.action, action); + + assertEquals(action.restored, false); + action.restore(); + assertEquals(action.restored, true); + assertEquals(point.action, actionDescriptor.value); + assertEquals( + Object.getOwnPropertyDescriptor(point, "action"), + actionDescriptor, + ); + assertThrows( + () => action.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(action.restored, true); +}); + +Deno.test("spy() supports explicit resource management", () => { + const point = new Point(2, 3); + let funcRef: MethodSpy | null = null; + { + using func = spy(point, "action"); + funcRef = func; + assertSpyCalls(func, 0); + + assertEquals(func.call(point), undefined); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), undefined); + assertSpyCall(func, 1, { self: point, args: [] }); + assertSpyCalls(func, 2); + + assertEquals(func.call(point, "x"), "x"); + assertSpyCall(func, 2, { + self: point, + args: ["x"], + returned: "x", + }); + assertSpyCalls(func, 3); + + assertEquals(point.action("x"), "x"); + assertSpyCall(func, 3, { + self: point, + args: ["x"], + returned: "x", + }); + assertSpyCalls(func, 4); + + assertEquals(func.call(point, { x: 3 }), { x: 3 }); + assertSpyCall(func, 4, { + self: point, + args: [{ x: 3 }], + returned: { x: 3 }, + }); + assertSpyCalls(func, 5); + + assertEquals(point.action({ x: 3 }), { x: 3 }); + assertSpyCall(func, 5, { + self: point, + args: [{ x: 3 }], + returned: { x: 3 }, + }); + assertSpyCalls(func, 6); + + assertEquals(func.call(point, 3, 5, 7), 3); + assertSpyCall(func, 6, { + self: point, + args: [3, 5, 7], + returned: 3, + }); + assertSpyCalls(func, 7); + + assertEquals(point.action(3, 5, 7), 3); + assertSpyCall(func, 7, { + self: point, + args: [3, 5, 7], + returned: 3, + }); + assertSpyCalls(func, 8); + + assertEquals(func.call(point, Point, stringifyPoint, point), Point); + assertSpyCall(func, 8, { + self: point, + args: [Point, stringifyPoint, point], + returned: Point, + }); + assertSpyCalls(func, 9); + + assertEquals(point.action(Point, stringifyPoint, point), Point); + assertSpyCall(func, 9, { + self: point, + args: [Point, stringifyPoint, point], + returned: Point, + }); + assertSpyCalls(func, 10); + + assertNotEquals(func, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + } + if (funcRef) { + assertEquals(funcRef.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => { + if (funcRef) funcRef.restore(); + }, + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(funcRef.restored, true); + } +}); + +Deno.test("spy() works on constructor", () => { + const PointSpy = spy(Point); + assertSpyCalls(PointSpy, 0); + + const point = new PointSpy(2, 3); + assertEquals(point.x, 2); + assertEquals(point.y, 3); + assertEquals(point.action(), undefined); + + assertSpyCall(PointSpy, 0, { + self: undefined, + args: [2, 3], + returned: point, + }); + assertSpyCallArg(PointSpy, 0, 0, 2); + assertSpyCallArgs(PointSpy, 0, 0, 1, [2]); + assertSpyCalls(PointSpy, 1); + + new PointSpy(3, 5); + assertSpyCall(PointSpy, 1, { + self: undefined, + args: [3, 5], + }); + assertSpyCalls(PointSpy, 2); + + assertThrows( + () => PointSpy.restore(), + MockError, + "Cannot restore: constructor cannot be restored", + ); +}); + +Deno.test("spy() works on constructor of child class", () => { + const PointSpy = spy(Point); + const PointSpyChild = class extends PointSpy { + override action() { + return 1; + } + }; + const point = new PointSpyChild(2, 3); + + assertEquals(point.x, 2); + assertEquals(point.y, 3); + assertEquals(point.action(), 1); + + assertSpyCall(PointSpyChild, 0, { + self: undefined, + args: [2, 3], + returned: point, + }); + assertSpyCalls(PointSpyChild, 1); + + assertSpyCall(PointSpy, 0, { + self: undefined, + args: [2, 3], + returned: point, + }); + assertSpyCalls(PointSpy, 1); +}); + +Deno.test("spy() works on constructor that throws an error", () => { + class Foo { + constructor() { + throw new Error("foo"); + } + } + const FooSpy = spy(Foo); + assertThrows(() => new FooSpy(), Error, "foo"); + assertSpyCall(FooSpy, 0, { + self: undefined, + args: [], + error: { Class: Error, msgIncludes: "foo" }, + }); +}); + +Deno.test("spy() works with throwing method", () => { + const obj = { + fn() { + throw new Error("failed"); + }, + }; + const spyFn = spy(obj, "fn"); + assertThrows(() => obj.fn(), Error, "failed"); + assertSpyCall(spyFn, 0, { + self: obj, + args: [], + error: { Class: Error, msgIncludes: "failed" }, + }); +}); + +Deno.test("spy() throws when try spying already spied method", () => { + const obj = { fn() {} }; + spy(obj, "fn"); + assertThrows( + () => spy(obj, "fn"), + MockError, + "Cannot spy: already spying on instance method", + ); +}); + +Deno.test("spy() throws when the property is not a method", () => { + const obj = {}; + assertThrows( + // deno-lint-ignore no-explicit-any + () => spy(obj as any, "fn"), + MockError, + "Cannot spy: property is not an instance method", + ); +}); + +Deno.test("spy() throws when the property is not configurable", () => { + const obj = { fn() {} }; + Object.defineProperty(obj, "fn", { configurable: false }); + assertThrows( + () => spy(obj, "fn"), + MockError, + "Cannot spy: non-configurable instance method", + ); +}); + +Deno.test("stub()", () => { + const point = new Point(2, 3); + const func = stub(point, "action"); + + assertSpyCalls(func, 0); + + assertEquals(func.call(point), undefined); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), undefined); + assertSpyCall(func, 1, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); + +Deno.test("stub() works on function", () => { + const point = new Point(2, 3); + const returns = [1, "b", 2, "d"]; + const func = stub(point, "action", () => returns.shift()); + + assertSpyCalls(func, 0); + + assertEquals(func.call(point), 1); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: 1, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), "b"); + assertSpyCall(func, 1, { + self: point, + args: [], + returned: "b", + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); + +Deno.test("stub() supports explicit resource management", () => { + const point = new Point(2, 3); + const returns = [1, "b", 2, "d"]; + let funcRef: Stub | null = null; + { + using func = stub(point, "action", () => returns.shift()); + funcRef = func; + + assertSpyCalls(func, 0); + + assertEquals(func.call(point), 1); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: 1, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), "b"); + assertSpyCall(func, 1, { + self: point, + args: [], + returned: "b", + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + } + if (funcRef) { + assertEquals(funcRef.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => { + if (funcRef) funcRef.restore(); + }, + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(funcRef.restored, true); + } +}); + +Deno.test("stub() handles non existent function", () => { + const point = new Point(2, 3); + const castPoint = point as PointWithExtra; + let i = 0; + const func = stub(castPoint, "nonExistent", () => { + i++; + return i; + }); + + assertSpyCalls(func, 0); + + assertEquals(func.call(castPoint), 1); + assertSpyCall(func, 0, { + self: castPoint, + args: [], + returned: 1, + }); + assertSpyCalls(func, 1); + + assertEquals(castPoint.nonExistent(), 2); + assertSpyCall(func, 1, { + self: castPoint, + args: [], + returned: 2, + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, undefined); + assertEquals(castPoint.nonExistent, func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(castPoint.nonExistent, undefined); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); + +// This doesn't test any runtime code, only if the TypeScript types are correct. +Deno.test("stub() correctly handles types", () => { + // @ts-expect-error Stubbing with incorrect argument types should cause a type error + stub(new Point(2, 3), "explicitTypes", (_x: string, _y: number) => true); + + // @ts-expect-error Stubbing with an incorrect return type should cause a type error + stub(new Point(2, 3), "explicitTypes", () => "string"); + + // Stubbing without argument types infers them from the real function + stub(new Point(2, 3), "explicitTypes", (_x, _y) => { + // `toExponential()` only exists on `number`, so this will error if _x is not a number + _x.toExponential(); + // `toLowerCase()` only exists on `string`, so this will error if _y is not a string + _y.toLowerCase(); + return true; + }); + + // Stubbing with returnsNext() should not give any type errors + stub(new Point(2, 3), "explicitTypes", returnsNext([true, false, true])); + + // Stubbing without argument types should not cause any type errors: + const point2 = new Point(2, 3); + const explicitTypesFunc = stub(point2, "explicitTypes", () => true); + + // Check if the returned type is correct: + assertThrows(() => { + assertSpyCall(explicitTypesFunc, 0, { + // @ts-expect-error Test if passing incorrect argument types causes an error + args: ["not a number", "string"], + // @ts-expect-error Test if passing incorrect return type causes an error + returned: "not a boolean", + }); + }); + + // Calling assertSpyCall with the correct types should not cause any type errors + point2.explicitTypes(1, "hello"); + assertSpyCall(explicitTypesFunc, 0, { + args: [1, "hello"], + returned: true, + }); +}); + +Deno.test("stub() works with throwing fake implementation", () => { + const obj = { + fn() { + throw new Error("failed"); + }, + }; + const stubFn = stub(obj, "fn", () => { + throw new Error("failed"); + }); + assertThrows(() => obj.fn(), Error, "failed"); + assertSpyCall(stubFn, 0, { + self: obj, + args: [], + error: { Class: Error, msgIncludes: "failed" }, + }); +}); + +Deno.test("stub() throws when the property is not a method", () => { + const obj = { fn: 1 }; + assertThrows( + // deno-lint-ignore no-explicit-any + () => stub(obj as any, "fn"), + MockError, + "Cannot stub: property is not an instance method", + ); +}); + +Deno.test("stub() throws when try stubbing already stubbed method", () => { + const obj = { fn() {} }; + stub(obj, "fn"); + assertThrows( + () => stub(obj, "fn"), + MockError, + "Cannot stub: already spying on instance method", + ); +}); + +Deno.test("stub() throws then the property is not configurable", () => { + const obj = { fn() {} }; + Object.defineProperty(obj, "fn", { configurable: false }); + assertThrows( + () => stub(obj, "fn"), + MockError, + "Cannot stub: non-configurable instance method", + ); +}); + +Deno.test("mockSession() and mockSessionAsync()", async () => { + const points = [ + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + ] as const; + let actions: Spy[] = []; + function assertRestored(expected: boolean[]) { + assertEquals(actions.map((action) => action.restored), expected); + } + await mockSessionAsync(async () => { + actions.push(spy(points[0], "action")); + assertRestored([false]); + await mockSessionAsync(async () => { + await Promise.resolve(); + actions.push(spy(points[1], "action")); + assertRestored([false, false]); + mockSession(() => { + actions.push(spy(points[2], "action")); + actions.push(spy(points[3], "action")); + assertRestored([false, false, false, false]); + })(); + actions.push(spy(points[4], "action")); + assertRestored([false, false, true, true, false]); + })(); + actions.push(spy(points[5], "action")); + assertRestored([false, true, true, true, true, false]); + })(); + assertRestored(Array(6).fill(true)); + restore(); + assertRestored(Array(6).fill(true)); + + actions = []; + mockSession(() => { + actions = points.map((point) => spy(point, "action")); + assertRestored(Array(6).fill(false)); + })(); + assertRestored(Array(6).fill(true)); + restore(); + assertRestored(Array(6).fill(true)); +}); + +Deno.test("mockSession() and restore current session", () => { + const points = [ + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + ] as const; + let actions: Spy[]; + function assertRestored(expected: boolean[]) { + assertEquals(actions.map((action) => action.restored), expected); + } + try { + actions = points.map((point) => spy(point, "action")); + + assertRestored(Array(6).fill(false)); + restore(); + assertRestored(Array(6).fill(true)); + restore(); + assertRestored(Array(6).fill(true)); + + actions = []; + try { + actions.push(spy(points[0], "action")); + try { + mockSession(); + actions.push(spy(points[1], "action")); + try { + mockSession(); + actions.push(spy(points[2], "action")); + actions.push(spy(points[3], "action")); + } finally { + assertRestored([false, false, false, false]); + restore(); + } + actions.push(spy(points[4], "action")); + } finally { + assertRestored([false, false, true, true, false]); + restore(); + } + actions.push(spy(points[5], "action")); + } finally { + assertRestored([false, true, true, true, true, false]); + restore(); + } + assertRestored(Array(6).fill(true)); + restore(); + assertRestored(Array(6).fill(true)); + + actions = points.map((point) => spy(point, "action")); + assertRestored(Array(6).fill(false)); + restore(); + assertRestored(Array(6).fill(true)); + restore(); + assertRestored(Array(6).fill(true)); + } finally { + restore(); + } +}); + +Deno.test("mockSession() and restore multiple sessions", () => { + const points = [ + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + new Point(2, 3), + ] as const; + let actions: Spy[]; + function assertRestored(expected: boolean[]) { + assertEquals(actions.map((action) => action.restored), expected); + } + try { + actions = []; + try { + actions.push(spy(points[0], "action")); + const id = mockSession(); + try { + actions.push(spy(points[1], "action")); + actions.push(spy(points[2], "action")); + mockSession(); + actions.push(spy(points[3], "action")); + actions.push(spy(points[4], "action")); + } finally { + assertRestored([false, false, false, false, false]); + restore(id); + } + actions.push(spy(points[5], "action")); + } finally { + assertRestored([false, true, true, true, true, false]); + restore(); + } + assertRestored(Array(6).fill(true)); + restore(); + assertRestored(Array(6).fill(true)); + } finally { + restore(); + } +}); + +Deno.test("assertSpyCalls()", () => { + const spyFunc = spy(); + + assertSpyCalls(spyFunc, 0); + assertThrows( + () => assertSpyCalls(spyFunc, 1), + AssertionError, + "Spy not called as much as expected", + ); + + spyFunc(); + assertSpyCalls(spyFunc, 1); + assertThrows( + () => assertSpyCalls(spyFunc, 0), + AssertionError, + "Spy called more than expected", + ); + assertThrows( + () => assertSpyCalls(spyFunc, 2), + AssertionError, + "Spy not called as much as expected", + ); +}); + +Deno.test("assertSpyCall() works with function", () => { + const spyFunc = spy((multiplier?: number) => 5 * (multiplier ?? 1)); + + assertThrows( + () => assertSpyCall(spyFunc, 0), + AssertionError, + "Spy not called as much as expected", + ); + + spyFunc(); + assertSpyCall(spyFunc, 0); + assertSpyCall(spyFunc, 0, { + args: [], + self: undefined, + returned: 5, + }); + assertSpyCall(spyFunc, 0, { + args: [], + }); + assertSpyCall(spyFunc, 0, { + self: undefined, + }); + assertSpyCall(spyFunc, 0, { + returned: 5, + }); + + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + args: [1], + self: {}, + returned: 2, + }), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + args: [1], + }), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + self: {}, + }), + AssertionError, + "Spy not called as method on expected self", + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + returned: 2, + }), + AssertionError, + "Spy call did not return expected value", + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + error: { msgIncludes: "x" }, + }), + AssertionError, + "Spy call did not throw an error, a value was returned.", + ); + assertThrows( + () => assertSpyCall(spyFunc, 1), + AssertionError, + "Spy not called as much as expected", + ); +}); + +Deno.test("assertSpyCall() works with method", () => { + const point = new Point(2, 3); + const spyMethod = spy(point, "action"); + + assertThrows( + () => assertSpyCall(spyMethod, 0), + AssertionError, + "Spy not called as much as expected", + ); + + point.action(3, 7); + assertSpyCall(spyMethod, 0); + assertSpyCall(spyMethod, 0, { + args: [3, 7], + self: point, + returned: 3, + }); + assertSpyCall(spyMethod, 0, { + args: [3, 7], + }); + assertSpyCall(spyMethod, 0, { + self: point, + }); + assertSpyCall(spyMethod, 0, { + returned: 3, + }); + + assertThrows( + () => + assertSpyCall(spyMethod, 0, { + args: [7, 4], + self: undefined, + returned: 7, + } as ExpectedSpyCall), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyMethod, 0, { + args: [7, 3], + }), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyMethod, 0, { + self: undefined, + } as ExpectedSpyCall), + AssertionError, + "Spy not expected to be called as method on object", + ); + assertThrows( + () => + assertSpyCall(spyMethod, 0, { + returned: 7, + }), + AssertionError, + "Spy call did not return expected value", + ); + assertThrows( + () => assertSpyCall(spyMethod, 1), + AssertionError, + "Spy not called as much as expected", + ); + + spyMethod.call(point, 9); + assertSpyCall(spyMethod, 1); + assertSpyCall(spyMethod, 1, { + args: [9], + self: point, + returned: 9, + }); + assertSpyCall(spyMethod, 1, { + args: [9], + }); + assertSpyCall(spyMethod, 1, { + self: point, + }); + assertSpyCall(spyMethod, 1, { + returned: 9, + }); + + assertThrows( + () => + assertSpyCall(spyMethod, 1, { + args: [7, 4], + self: point, + returned: 7, + }), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyMethod, 1, { + args: [7, 3], + }), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyMethod, 1, { + self: new Point(1, 2), + }), + AssertionError, + "Spy not called as method on expected self", + ); + assertThrows( + () => + assertSpyCall(spyMethod, 1, { + returned: 7, + }), + AssertionError, + "Spy call did not return expected value", + ); + assertThrows( + () => + assertSpyCall(spyMethod, 1, { + error: { msgIncludes: "x" }, + }), + AssertionError, + "Spy call did not throw an error, a value was returned.", + ); + assertThrows( + () => assertSpyCall(spyMethod, 2), + AssertionError, + "Spy not called as much as expected", + ); +}); + +class ExampleError extends Error {} +class OtherError extends Error {} + +Deno.test("assertSpyCall() works with error", () => { + const spyFunc = spy((_value?: number) => { + throw new ExampleError("failed"); + }); + + assertThrows(() => spyFunc(), ExampleError, "fail"); + assertSpyCall(spyFunc, 0); + assertSpyCall(spyFunc, 0, { + args: [], + self: undefined, + error: { + Class: ExampleError, + msgIncludes: "fail", + }, + }); + assertSpyCall(spyFunc, 0, { + args: [], + }); + assertSpyCall(spyFunc, 0, { + self: undefined, + }); + assertSpyCall(spyFunc, 0, { + error: { + Class: ExampleError, + msgIncludes: "fail", + }, + }); + assertSpyCall(spyFunc, 0, { + error: { + Class: Error, + msgIncludes: "fail", + }, + }); + + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + args: [1], + self: {}, + error: { + Class: OtherError, + msgIncludes: "fail", + }, + }), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + args: [1], + }), + AssertionError, + "Spy not called with expected args", + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + self: {}, + }), + AssertionError, + "Spy not called as method on expected self", + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + error: { + Class: OtherError, + msgIncludes: "fail", + }, + }), + AssertionError, + 'Expected error to be instance of "OtherError", but was "ExampleError".', + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + error: { + Class: OtherError, + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error to be instance of "OtherError", but was "ExampleError".', + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + error: { + Class: ExampleError, + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error message to include "x", but got "failed".', + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + error: { + Class: Error, + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error message to include "x", but got "failed".', + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + error: { + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error message to include "x", but got "failed".', + ); + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + returned: 7, + }), + AssertionError, + "Spy call did not return expected value, an error was thrown.", + ); + assertThrows( + () => assertSpyCall(spyFunc, 1), + AssertionError, + "Spy not called as much as expected", + ); +}); + +Deno.test("assertSpyCall() throws TypeError when returned and error are both provided", () => { + const spyFunc = spy(() => 5); + spyFunc(); + + assertThrows( + () => + assertSpyCall(spyFunc, 0, { + returned: 5, + error: { msgIncludes: "x" }, + }), + TypeError, + "Do not expect error and return, only one should be expected", + ); +}); + +Deno.test("assertSpyCallAsync() works with function", async () => { + const spyFunc = spy((multiplier?: number) => + Promise.resolve(5 * (multiplier ?? 1)) + ); + + await assertRejects( + () => assertSpyCallAsync(spyFunc, 0), + AssertionError, + "Spy not called as much as expected", + ); + + await spyFunc(); + await assertSpyCallAsync(spyFunc, 0); + await assertSpyCallAsync(spyFunc, 0, { + args: [], + self: undefined, + returned: 5, + }); + await assertSpyCallAsync(spyFunc, 0, { + args: [], + self: undefined, + returned: Promise.resolve(5), + }); + await assertSpyCallAsync(spyFunc, 0, { + args: [], + }); + await assertSpyCallAsync(spyFunc, 0, { + self: undefined, + }); + await assertSpyCallAsync(spyFunc, 0, { + returned: Promise.resolve(5), + }); + + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + args: [1], + self: {}, + returned: 2, + }), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + args: [1], + }), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + self: {}, + }), + AssertionError, + "Spy not called as method on expected self", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + returned: 2, + }), + AssertionError, + "Spy call did not resolve to expected value", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + returned: Promise.resolve(2), + }), + AssertionError, + "Spy call did not resolve to expected value", + ); + await assertRejects( + () => assertSpyCallAsync(spyFunc, 1), + AssertionError, + "Spy not called as much as expected", + ); +}); + +Deno.test("assertSpyCallAsync() works with method", async () => { + const point: Point = new Point(2, 3); + const spyMethod = stub( + point, + "action", + (x?: number, _y?: number) => Promise.resolve(x), + ); + + await assertRejects( + () => assertSpyCallAsync(spyMethod, 0), + AssertionError, + "Spy not called as much as expected", + ); + + await point.action(3, 7); + await assertSpyCallAsync(spyMethod, 0); + await assertSpyCallAsync(spyMethod, 0, { + args: [3, 7], + self: point, + returned: 3, + }); + await assertSpyCallAsync(spyMethod, 0, { + args: [3, 7], + self: point, + returned: Promise.resolve(3), + }); + await assertSpyCallAsync(spyMethod, 0, { + args: [3, 7], + }); + await assertSpyCallAsync(spyMethod, 0, { + self: point, + }); + await assertSpyCallAsync(spyMethod, 0, { + returned: 3, + }); + await assertSpyCallAsync(spyMethod, 0, { + returned: Promise.resolve(3), + }); + + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 0, { + args: [7, 4], + self: undefined, + returned: 7, + } as ExpectedSpyCall), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 0, { + args: [7, 3], + }), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 0, { + self: undefined, + } as ExpectedSpyCall), + AssertionError, + "Spy not expected to be called as method on object", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 0, { + returned: 7, + }), + AssertionError, + "Spy call did not resolve to expected value", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 0, { + returned: Promise.resolve(7), + }), + AssertionError, + "Spy call did not resolve to expected value", + ); + await assertRejects( + () => assertSpyCallAsync(spyMethod, 1), + AssertionError, + "Spy not called as much as expected", + ); + + await spyMethod.call(point, 9); + await assertSpyCallAsync(spyMethod, 1); + await assertSpyCallAsync(spyMethod, 1, { + args: [9], + self: point, + returned: 9, + }); + await assertSpyCallAsync(spyMethod, 1, { + args: [9], + self: point, + returned: Promise.resolve(9), + }); + await assertSpyCallAsync(spyMethod, 1, { + args: [9], + }); + await assertSpyCallAsync(spyMethod, 1, { + self: point, + }); + await assertSpyCallAsync(spyMethod, 1, { + returned: 9, + }); + await assertSpyCallAsync(spyMethod, 1, { + returned: Promise.resolve(9), + }); + + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 1, { + args: [7, 4], + self: point, + returned: 7, + }), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 1, { + args: [7, 3], + }), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 1, { + self: new Point(1, 2), + }), + AssertionError, + "Spy not called as method on expected self", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 1, { + returned: 7, + }), + AssertionError, + "Spy call did not resolve to expected value", + ); + await assertRejects( + () => + assertSpyCallAsync(spyMethod, 1, { + returned: Promise.resolve(7), + }), + AssertionError, + "Spy call did not resolve to expected value", + ); + await assertRejects( + () => assertSpyCallAsync(spyMethod, 2), + AssertionError, + "Spy not called as much as expected", + ); +}); + +Deno.test("assertSpyCallAsync() rejects on sync value", async () => { + const spyFunc = spy(() => 4 as unknown as Promise); + + spyFunc(); + await assertRejects( + () => assertSpyCallAsync(spyFunc, 0), + AssertionError, + "Spy call did not return a promise, a value was returned.", + ); +}); + +Deno.test("assertSpyCallAsync() rejects on sync error", async () => { + const spyFunc = spy(() => { + throw new ExampleError("failed"); + }); + + assertThrows(() => spyFunc(), ExampleError, "fail"); + await assertRejects( + () => assertSpyCallAsync(spyFunc, 0), + AssertionError, + "Spy call did not return a promise, an error was thrown.", + ); +}); + +Deno.test("assertSpyCallAsync() works with error", async () => { + const spyFunc = spy((..._args: number[]): Promise => + Promise.reject(new ExampleError("failed")) + ); + + await assertRejects(() => spyFunc(), ExampleError, "fail"); + await assertSpyCallAsync(spyFunc, 0); + await assertSpyCallAsync(spyFunc, 0, { + args: [], + self: undefined, + error: { + Class: ExampleError, + msgIncludes: "fail", + }, + }); + await assertSpyCallAsync(spyFunc, 0, { + args: [], + }); + await assertSpyCallAsync(spyFunc, 0, { + self: undefined, + }); + await assertSpyCallAsync(spyFunc, 0, { + error: { + Class: ExampleError, + msgIncludes: "fail", + }, + }); + await assertSpyCallAsync(spyFunc, 0, { + error: { + Class: Error, + msgIncludes: "fail", + }, + }); + await assertSpyCallAsync(spyFunc, 0, { + error: { + msgIncludes: "fail", + }, + }); + await assertSpyCallAsync(spyFunc, 0, { + error: { + Class: Error, + }, + }); + + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + args: [1], + self: {}, + error: { + Class: OtherError, + msgIncludes: "fail", + }, + }), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + args: [1], + }), + AssertionError, + "Spy not called with expected args", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + self: {}, + }), + AssertionError, + "Spy not called as method on expected self", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + error: { + Class: OtherError, + msgIncludes: "fail", + }, + }), + AssertionError, + 'Expected error to be instance of "OtherError"', + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + error: { + Class: OtherError, + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error to be instance of "OtherError"', + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + error: { + Class: ExampleError, + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error message to include "x", but got "failed".', + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + error: { + Class: Error, + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error message to include "x", but got "failed".', + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + error: { + msgIncludes: "x", + }, + }), + AssertionError, + 'Expected error message to include "x", but got "failed".', + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + returned: Promise.resolve(7), + }), + AssertionError, + "Spy call returned promise was rejected", + ); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + returned: Promise.resolve(7), + error: { msgIncludes: "x" }, + }), + TypeError, + "Do not expect error and return, only one should be expected", + ); + await assertRejects( + () => assertSpyCallAsync(spyFunc, 1), + AssertionError, + "Spy not called as much as expected", + ); +}); + +Deno.test("assertSpyCallAsync() throws type error if expected return value is rejected", async () => { + const spyFunc = spy(() => Promise.resolve(5)); + + spyFunc(); + await assertRejects( + () => + assertSpyCallAsync(spyFunc, 0, { + returned: Promise.reject(new Error("failed")), + }), + TypeError, + "Do not expect rejected promise, expect error instead", + ); +}); + +Deno.test("assertSpyCallArg()", () => { + const spyFunc = spy(); + + assertThrows( + () => assertSpyCallArg(spyFunc, 0, 0, undefined), + AssertionError, + "Spy not called as much as expected", + ); + + spyFunc(); + assertSpyCallArg(spyFunc, 0, 0, undefined); + assertSpyCallArg(spyFunc, 0, 1, undefined); + assertThrows( + () => assertSpyCallArg(spyFunc, 0, 0, 2), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + +- undefined ++ 2`, + ); + + spyFunc(7, 9); + assertSpyCallArg(spyFunc, 1, 0, 7); + assertSpyCallArg(spyFunc, 1, 1, 9); + assertSpyCallArg(spyFunc, 1, 2, undefined); + assertThrows( + () => assertSpyCallArg(spyFunc, 0, 0, 9), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + +- undefined ++ 9`, + ); + assertThrows( + () => assertSpyCallArg(spyFunc, 0, 1, 7), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + +- undefined ++ 7`, + ); + assertThrows( + () => assertSpyCallArg(spyFunc, 0, 2, 7), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + +- undefined ++ 7`, + ); +}); + +Deno.test("assertSpyCallArgs() throws without range", () => { + const spyFunc = spy(); + + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, []), + AssertionError, + "Spy not called as much as expected", + ); + + spyFunc(); + assertSpyCallArgs(spyFunc, 0, []); + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, [undefined]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + ++ [ ++ undefined, ++ ]`, + ); + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, [2]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + ++ [ ++ 2, ++ ]`, + ); + + spyFunc(7, 9); + assertSpyCallArgs(spyFunc, 1, [7, 9]); + assertThrows( + () => assertSpyCallArgs(spyFunc, 1, [7, 9, undefined]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + + [ + 7, + 9, ++ undefined, + ]`, + ); + assertThrows( + () => assertSpyCallArgs(spyFunc, 1, [9, 7]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + + [ +- 7, + 9, ++ 7, + ]`, + ); +}); + +Deno.test("assertSpyCallArgs() throws with start only", () => { + const spyFunc = spy(); + + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, 1, []), + AssertionError, + "Spy not called as much as expected", + ); + + spyFunc(); + assertSpyCallArgs(spyFunc, 0, 1, []); + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, 1, [undefined]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + ++ [ ++ undefined, ++ ]`, + ); + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, 1, [2]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + ++ [ ++ 2, ++ ]`, + ); + + spyFunc(7, 9, 8); + assertSpyCallArgs(spyFunc, 1, 1, [9, 8]); + assertThrows( + () => assertSpyCallArgs(spyFunc, 1, 1, [9, 8, undefined]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + + [ + 9, + 8, ++ undefined, + ]`, + ); + assertThrows( + () => assertSpyCallArgs(spyFunc, 1, 1, [9, 7]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + + [ + 9, +- 8, ++ 7, + ]`, + ); +}); + +Deno.test("assertSpyCallArgs() throws with range", () => { + const spyFunc = spy(); + + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, 1, 3, []), + AssertionError, + "Spy not called as much as expected", + ); + + spyFunc(); + assertSpyCallArgs(spyFunc, 0, 1, 3, []); + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, 1, 3, [undefined, undefined]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + ++ [ ++ undefined, ++ undefined, ++ ]`, + ); + assertThrows( + () => assertSpyCallArgs(spyFunc, 0, 1, 3, [2, 4]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + ++ [ ++ 2, ++ 4, ++ ]`, + ); + + spyFunc(7, 9, 8, 5, 6); + assertSpyCallArgs(spyFunc, 1, 1, 3, [9, 8]); + assertThrows( + () => assertSpyCallArgs(spyFunc, 1, 1, 3, [9, 8, undefined]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + + [ + 9, + 8, ++ undefined, + ]`, + ); + assertThrows( + () => assertSpyCallArgs(spyFunc, 1, 1, 3, [9, 7]), + AssertionError, + `Values are not equal. + + + [Diff] Actual / Expected + + + [ + 9, +- 8, ++ 7, + ]`, + ); +}); + +Deno.test("returnsThis()", () => { + const callback = returnsThis(); + const obj = { callback, x: 1, y: 2 }; + const obj2 = { x: 2, y: 3 }; + assertEquals(callback(), undefined); + assertEquals(obj.callback(), obj); + assertEquals(callback.apply(obj2, []), obj2); +}); + +Deno.test("returnsArg()", () => { + let callback = returnsArg(0); + assertEquals(callback(), undefined); + assertEquals(callback("a"), "a"); + assertEquals(callback("b", "c"), "b"); + callback = returnsArg(1); + assertEquals(callback(), undefined); + assertEquals(callback("a"), undefined); + assertEquals(callback("b", "c"), "c"); + assertEquals(callback("d", "e", "f"), "e"); +}); + +Deno.test("returnsArgs()", () => { + let callback = returnsArgs(); + assertEquals(callback(), []); + assertEquals(callback("a"), ["a"]); + assertEquals(callback("b", "c"), ["b", "c"]); + callback = returnsArgs(1); + assertEquals(callback(), []); + assertEquals(callback("a"), []); + assertEquals(callback("b", "c"), ["c"]); + assertEquals(callback("d", "e", "f"), ["e", "f"]); + callback = returnsArgs(1, 3); + assertEquals(callback("a"), []); + assertEquals(callback("b", "c"), ["c"]); + assertEquals(callback("d", "e", "f"), ["e", "f"]); + assertEquals(callback("d", "e", "f", "g"), ["e", "f"]); +}); + +Deno.test("returnsNext() works with array", () => { + let results = [1, 2, new Error("oops"), 3]; + let callback = returnsNext(results); + assertEquals(callback(), 1); + assertEquals(callback(), 2); + assertThrows(() => callback(), Error, "oops"); + assertEquals(callback(), 3); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 4 time(s)", + ); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 4 time(s)", + ); + + results = []; + callback = returnsNext(results); + results.push(1, 2, new Error("oops"), 3); + assertEquals(callback(), 1); + assertEquals(callback(), 2); + assertThrows(() => callback(), Error, "oops"); + assertEquals(callback(), 3); + results.push(4); + assertEquals(callback(), 4); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + results.push(5); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); +}); + +Deno.test("returnsNext() works with iterator", () => { + let results = [1, 2, new Error("oops"), 3]; + let callback = returnsNext(results.values()); + assertEquals(callback(), 1); + assertEquals(callback(), 2); + assertThrows(() => callback(), Error, "oops"); + assertEquals(callback(), 3); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 4 time(s)", + ); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 4 time(s)", + ); + + results = []; + callback = returnsNext(results.values()); + results.push(1, 2, new Error("oops"), 3); + assertEquals(callback(), 1); + assertEquals(callback(), 2); + assertThrows(() => callback(), Error, "oops"); + assertEquals(callback(), 3); + results.push(4); + assertEquals(callback(), 4); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + results.push(5); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); +}); + +Deno.test("returnsNext() works with generator", () => { + let results = [1, 2, new Error("oops"), 3]; + const generator = function* () { + yield* results; + }; + let callback = returnsNext(generator()); + assertEquals(callback(), 1); + assertEquals(callback(), 2); + assertThrows(() => callback(), Error, "oops"); + assertEquals(callback(), 3); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 4 time(s)", + ); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 4 time(s)", + ); + + results = []; + callback = returnsNext(generator()); + results.push(1, 2, new Error("oops"), 3); + assertEquals(callback(), 1); + assertEquals(callback(), 2); + assertThrows(() => callback(), Error, "oops"); + assertEquals(callback(), 3); + results.push(4); + assertEquals(callback(), 4); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + results.push(5); + assertThrows( + () => callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); +}); + +Deno.test("resolvesNext() works with array", async () => { + let results = [ + 1, + new Error("oops"), + Promise.resolve(2), + Promise.resolve(new Error("oops")), + 3, + ]; + let callback = resolvesNext(results); + const value = callback(); + assertEquals(Promise.resolve(value), value); + assertEquals(await value, 1); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 2); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 3); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + + results = []; + callback = resolvesNext(results); + results.push( + 1, + new Error("oops"), + Promise.resolve(2), + Promise.resolve(new Error("oops")), + 3, + ); + assertEquals(await callback(), 1); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 2); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 3); + results.push(4); + assertEquals(await callback(), 4); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 6 time(s)", + ); + results.push(5); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 6 time(s)", + ); +}); + +Deno.test("resolvesNext() works with iterator", async () => { + let results = [ + 1, + new Error("oops"), + Promise.resolve(2), + Promise.resolve(new Error("oops")), + 3, + ]; + let callback = resolvesNext(results.values()); + const value = callback(); + assertEquals(Promise.resolve(value), value); + assertEquals(await value, 1); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 2); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 3); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + + results = []; + callback = resolvesNext(results.values()); + results.push( + 1, + new Error("oops"), + Promise.resolve(2), + Promise.resolve(new Error("oops")), + 3, + ); + assertEquals(await callback(), 1); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 2); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 3); + results.push(4); + assertEquals(await callback(), 4); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 6 time(s)", + ); + results.push(5); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 6 time(s)", + ); +}); + +Deno.test("resolvesNext() works with async generator", async () => { + let results = [ + 1, + new Error("oops"), + Promise.resolve(2), + Promise.resolve(new Error("oops")), + 3, + ]; + const asyncGenerator = async function* () { + await delay(0); + yield* results; + }; + let callback = resolvesNext(asyncGenerator()); + const value = callback(); + assertEquals(Promise.resolve(value), value); + assertEquals(await value, 1); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 2); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 3); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 5 time(s)", + ); + + results = []; + callback = resolvesNext(asyncGenerator()); + results.push( + 1, + new Error("oops"), + Promise.resolve(2), + Promise.resolve(new Error("oops")), + 3, + ); + assertEquals(await callback(), 1); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 2); + await assertRejects(() => callback(), Error, "oops"); + assertEquals(await callback(), 3); + results.push(4); + assertEquals(await callback(), 4); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 6 time(s)", + ); + results.push(5); + await assertRejects( + async () => await callback(), + MockError, + "Not expected to be called more than 6 time(s)", + ); +}); diff --git a/testing/unstable_stub.ts b/testing/unstable_stub.ts index 2f503d66f6bf..23382243813a 100644 --- a/testing/unstable_stub.ts +++ b/testing/unstable_stub.ts @@ -1,5 +1,10 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -import { isSpy, registerMock, unregisterMock } from "./_mock_utils.ts"; +import { + defineSpyInternals, + isSpy, + registerMock, + unregisterMock, +} from "./_unstable_mock_utils.ts"; import { type GetParametersFromProp, type GetReturnFromProp, @@ -8,7 +13,7 @@ import { type Spy, spy, type SpyCall, -} from "./mock.ts"; +} from "./unstable_mock.ts"; /** An instance method replacement that records all calls made to it. */ export interface Stub< @@ -53,10 +58,7 @@ export interface Stub< * @param property The property of the instance to replace. * @returns The stub function which replaced the original. */ -export function stub< - Self, - Prop extends keyof Self, ->( +export function stub( self: Self, property: Prop, ): Stub, GetReturnFromProp>; @@ -86,10 +88,7 @@ export function stub< * @param func The fake implementation of the function. * @returns The stub function which replaced the original. */ -export function stub< - Self, - Prop extends keyof Self, ->( +export function stub( self: Self, property: Prop, func: ( @@ -173,9 +172,7 @@ export function stub( descriptorOrFunction.get === undefined && descriptorOrFunction.set === undefined ) { - throw new MockError( - "Cannot stub: neither setter nor getter is defined", - ); + throw new MockError("Cannot stub: neither setter nor getter is defined"); } const propertyDescriptor = Object.getOwnPropertyDescriptor(self, property); @@ -194,11 +191,12 @@ export function stub( const calls: SpyCall[] = []; let restored = false; const stub = function (this: Self, ...args: Args): Return { - const call: SpyCall = { args }; + const call: SpyCall = { result: "returned", args }; if (this) call.self = this; try { call.returned = fake.apply(this, args); } catch (error) { + call.result = "thrown"; call.error = error as Error; calls.push(call); throw error; @@ -246,6 +244,7 @@ export function stub( }, }, }); + defineSpyInternals(stub, { calls, original }); if (descriptorOrFunction && typeof descriptorOrFunction !== "function") { const getterSpy = descriptorOrFunction.get diff --git a/testing/unstable_stub_test.ts b/testing/unstable_stub_test.ts index 103be55da576..8763f33ba5a4 100644 --- a/testing/unstable_stub_test.ts +++ b/testing/unstable_stub_test.ts @@ -1,13 +1,13 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { assertEquals, assertThrows } from "@std/assert"; +import { Point, type PointWithExtra } from "./_test_utils.ts"; import { assertSpyCall, assertSpyCallArg, assertSpyCalls, MockError, returnsNext, -} from "./mock.ts"; -import { Point, type PointWithExtra } from "./_test_utils.ts"; +} from "./unstable_mock.ts"; import { type Stub, stub } from "./unstable_stub.ts"; Deno.test("stub()", () => {