Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(testing): Add mocking utilities #2048

Merged
merged 7 commits into from
Mar 29, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 324 additions & 0 deletions testing/mock/_asserts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
/** This module is browser compatible. */

import {
assertEquals,
AssertionError,
assertIsError,
assertRejects,
} from "../asserts.ts";
import { Spy, SpyCall } from "./mock.ts";

/** An error related to spying on a function or instance method. */
export class MockError extends Error {
constructor(message: string) {
super(message);
this.name = "MockError";
}
}

/**
* Asserts that a spy is called as much as expected and no more.
*/
export function assertSpyCalls<
Self,
Args extends unknown[],
Return,
>(
spy: Spy<Self, Args, Return>,
expectedCalls: number,
) {
try {
assertEquals(spy.calls.length, expectedCalls);
} catch (e) {
assertIsError(e);
let message = spy.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;
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;
};
}

/**
* Asserts that a spy is called as expected.
* Returns the call.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think returning value from assertion is confusing and less readable

(Note: We had similar discussion in the past about this design #1052 (comment)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also can get n-th call object from spy.calls[n]. I think there's no need of returning call object here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be good with keeping the third argument optional so that it can still be used for just checking if the spyFunc was called enough times but just having this assertion function changed to not returning a value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went ahead and made this change, the assert functions no longer return anything.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

*/
export function assertSpyCall<
Self,
Args extends unknown[],
Return,
>(
spy: Spy<Self, Args, Return>,
callIndex: number,
expected?: ExpectedSpyCall<Self, Args, Return>,
) {
if (spy.calls.length < (callIndex + 1)) {
throw new AssertionError("spy not called as much as expected");
}
const call: SpyCall = spy.calls[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(call.self, 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,
);
}
}
return call;
}

/**
* Asserts that an async spy is called as expected.
* Returns the call.
*/
export async function assertSpyCallAsync<
Self,
Args extends unknown[],
Return,
>(
spy: Spy<Self, Args, Promise<Return>>,
callIndex: number,
expected?: ExpectedSpyCall<Self, Args, Promise<Return> | Return>,
) {
const expectedSync = expected && { ...expected };
if (expectedSync) {
delete expectedSync.returned;
delete expectedSync.error;
}
const call: SpyCall = assertSpyCall(
spy,
callIndex,
expectedSync,
);

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",
);
}
if (call.error) {
throw new AssertionError(
"spy call did not return expected value, an error was thrown.",
);
}
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 ?? "",
);
}
}
return call;
}

/**
* Asserts that a spy is called with a specific arg as expected.
* Returns the actual arg.
*/
export function assertSpyCallArg<
Self,
Args extends unknown[],
Return,
ExpectedArg,
>(
spy: Spy<Self, Args, Return>,
callIndex: number,
argIndex: number,
expected: ExpectedArg,
): ExpectedArg {
const call: SpyCall = assertSpyCall(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.
* Returns the actual args.
*/
export function assertSpyCallArgs<
Self,
Args extends unknown[],
Return,
ExpectedArgs extends unknown[],
>(
spy: Spy<Self, Args, Return>,
callIndex: number,
expected: ExpectedArgs,
): ExpectedArgs;
export function assertSpyCallArgs<
Self,
Args extends unknown[],
Return,
ExpectedArgs extends unknown[],
>(
spy: Spy<Self, Args, Return>,
callIndex: number,
argsStart: number,
expected: ExpectedArgs,
): ExpectedArgs;
export function assertSpyCallArgs<
Self,
Args extends unknown[],
Return,
ExpectedArgs extends unknown[],
>(
spy: Spy<Self, Args, Return>,
callIndex: number,
argStart: number,
argEnd: number,
expected: ExpectedArgs,
): ExpectedArgs;
export function assertSpyCallArgs<
ExpectedArgs extends unknown[],
Args extends unknown[],
Return,
Self,
>(
spy: Spy<Self, Args, Return>,
callIndex: number,
argsStart?: number | ExpectedArgs,
argsEnd?: number | ExpectedArgs,
expected?: ExpectedArgs,
): ExpectedArgs {
const call: SpyCall = assertSpyCall(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;
}
Loading