Skip to content

Instantly share code, notes, and snippets.

@nberlette
Last active February 9, 2024 23:53
Show Gist options
  • Save nberlette/216c2f335656aa5db5534f8e3ddcd1e3 to your computer and use it in GitHub Desktop.
Save nberlette/216c2f335656aa5db5534f8e3ddcd1e3 to your computer and use it in GitHub Desktop.
Hard fork of the Deno stdlib 'expect' module + BDD testing tools (from https://deno.land/std/expect/mod.ts)
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// Copyright 2019 Allain Lalonde. All rights reserved. ISC License.
import {
type AnyConstructor,
type Matcher,
matchers,
type TypeNames,
} from "./matchers.ts";
import { AssertionError, type Async, type Fn, isPromiseLike } from "./utils.ts";
export interface Expected {
not: Omit<this, "not">;
resolves: Async<Omit<this, "resolves" | "rejects">>;
rejects: Async<Omit<this, "resolves" | "rejects">>;
lastCalledWith<const A extends readonly unknown[]>(...expected: A): void;
lastReturnedWith<const T>(expected: T): void;
nthCalledWith<const A extends readonly unknown[]>(
nth: number,
...expected: A
): void;
nthReturnedWith<const T>(nth: number, expected: T): void;
toBeCalled(): void;
toBeCalledTimes(expected: number): void;
toBeCalledWith<const A extends readonly unknown[]>(
...expected: A
): void;
toBeCloseTo(candidate: number, tolerance?: number): void;
toBeDefined(): void;
toBeFalsy(): void;
toBeGreaterThan(expected: number): void;
toBeGreaterThanOrEqual(expected: number): void;
toBeInstanceOf<T extends AnyConstructor>(expected: T): void;
toBeLessThan(expected: number): void;
toBeLessThanOrEqual(expected: number): void;
toBeNaN(): void;
toBeNull(): void;
toBeTruthy(): void;
toBeUndefined(): void;
toBe:
& {
<const T>(expected: T): void;
truthy(): void;
falsy(): void;
null(): void;
undefined(): void;
defined(): void;
NaN(): void;
type<T extends TypeNames>(expected: T): void;
instanceof<T extends AnyConstructor>(expected: T): void;
instanceOf<T extends AnyConstructor>(expected: T): void;
closeTo(candidate: number, tolerance?: number): void;
greaterThan(expected: number): void;
greaterThanOrEqual(expected: number): void;
lessThan(expected: number): void;
lessThanOrEqual(expected: number): void;
iterable(): void;
asyncIterable(): void;
}
& {
[K in "a" | "an"]: {
<const T>(expected: T): void;
function(): void;
asyncFunction(): void;
generatorFunction(): void;
generator(): void;
asyncGeneratorFunction(): void;
asyncGenerator(): void;
iterable(): void;
asyncIterable(): void;
array(): void;
object(): void;
string(): void;
number(): void;
bigint(): void;
boolean(): void;
symbol(): void;
};
};
toBeFunction(): void;
toBeAsyncFunction(): void;
toBeGeneratorFunction(): void;
toBeGeneratorObject(): void;
toBeAsyncGeneratorFunction(): void;
toBeAsyncGeneratorObject(): void;
toBeIterable(): void;
toBeAsyncIterable(): void;
toBeType<T extends TypeNames>(expected: T): void;
toContainEqual<const T>(expected: T): void;
toContain<const T>(expected: T): void;
toEqual<const T>(expected: T): void;
toHaveBeenCalledTimes(expected: number): void;
toHaveBeenCalledWith<
const A extends readonly unknown[],
>(...expected: A): void;
toHaveBeenCalled(): void;
toHaveBeenLastCalledWith<
const A extends readonly unknown[],
>(...expected: A): void;
toHaveBeenNthCalledWith(nth: number, ...expected: unknown[]): void;
toHaveLength(expected: number): void;
toHaveLastReturnedWith<const T>(expected: T): void;
toHaveNthReturnedWith<const T>(nth: number, expected: T): void;
toHaveProperty<const T, const K extends PropertyKey | readonly PropertyKey[]>(
propName: K,
value?: T,
): void;
toHaveReturnedTimes(expected: number): void;
toHaveReturnedWith<const T>(expected: T): void;
toHaveReturned(): void;
toMatch(expected: RegExp): void;
toMatchObject<const T extends Record<PropertyKey, unknown>>(
expected: T,
): void;
toReturn(): void;
toReturnTimes(expected: number): void;
toReturnWith(expected: unknown): void;
toStrictEqual(candidate: unknown): void;
toThrow<E extends Error = Error>(
// deno-lint-ignore no-explicit-any
expected?: string | RegExp | E | (new (...args: any[]) => E),
): void;
}
type MatcherKey = keyof Omit<Expected, "not" | "resolves" | "rejects">;
export function expect<const T>(value: T, customMessage?: string): Expected;
export function expect(value: unknown, customMessage?: string): Expected {
let isNot = false;
let isPromised = false;
const self: Expected = new Proxy<Expected>(
<Expected> {},
{
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 matcher: Matcher = matchers[name as MatcherKey];
if (!matcher) {
throw new TypeError(
typeof name === "string"
? `matcher not found: ${name}`
: "matcher not found",
);
}
function applyMatcher(value: unknown, args: unknown[]) {
runMatcher(matcher, value, args);
}
function runMatcher(fn: Matcher, value: unknown, args: unknown[]) {
const context = { value, isNot, customMessage };
fn(context, ...args);
}
if (name === "toBe") {
return new Proxy(matcher as typeof matchers.toBe, {
apply(target, _thisArg, args) {
if (args.length !== 1) {
throw new TypeError(
`toBe expects exactly one argument, ${args.length} given`,
);
}
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(target, v, args)
);
}
return runMatcher(target, value, args);
},
get(target, p) {
if (typeof p === "string") {
switch (p) {
case "truthy": //fallthrough
case "falsy": //fallthrough
case "null": //fallthrough
case "undefined": //fallthrough
case "defined": //fallthrough
case "NaN": {
return () => {
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(Reflect.get(target, p), v, [])
);
}
return runMatcher(Reflect.get(target, p), value, []);
};
}
case "instanceof": //fallthrough
case "instanceOf": {
return (expected: unknown) => {
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(Reflect.get(target, p), v, [expected])
);
}
return runMatcher(Reflect.get(target, p), value, [
expected,
]);
};
}
case "type": {
return (expected: TypeNames) => {
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(Reflect.get(target, p), v, [expected])
);
}
return runMatcher(Reflect.get(target, p), value, [
expected,
]);
};
}
case "closeTo": {
return (expected: number, tolerance?: number) => {
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(Reflect.get(target, p), v, [
expected,
tolerance,
])
);
}
return runMatcher(Reflect.get(target, p), value, [
expected,
tolerance,
]);
};
}
case "greaterThan": //fallthrough
case "greaterThanOrEqual": //fallthrough
case "lessThan": //fallthrough
case "lessThanOrEqual": {
return (expected: number) => {
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(Reflect.get(target, p), v, [expected])
);
}
return runMatcher(Reflect.get(target, p), value, [
expected,
]);
};
}
case "iterable": //fallthrough
case "asyncIterable": {
return () => {
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(Reflect.get(target, p), v, [])
);
}
return runMatcher(Reflect.get(target, p), value, []);
};
}
case "a": //fallthrough
case "an": {
return new Proxy(target.a, {
apply(target, _thisArg, args) {
if (args.length !== 1) {
throw new TypeError(
`${p} expects exactly one argument, ${args.length} given`,
);
}
if (isPromised) {
return (value as Promise<unknown>).then((v) =>
runMatcher(target, v, args)
);
}
return runMatcher(target, value, args);
},
get(target, p) {
if (typeof p === "string") {
switch (p) {
case "function": //fallthrough
case "asyncFunction": //fallthrough
case "generatorFunction": //fallthrough
case "generator": //fallthrough
case "asyncGeneratorFunction": //fallthrough
case "asyncGenerator": //fallthrough
case "array": //fallthrough
case "object": //fallthrough
case "string": //fallthrough
case "number": //fallthrough
case "bigint": //fallthrough
case "boolean": //fallthrough
case "symbol": //fallthrough
case "null": //fallthrough
case "undefined": //fallthrough
case "instanceOf": //fallthrough
case "instanceof": //fallthrough
case "NaN": {
return (...args: unknown[]) => {
if (args.length !== 0) {
throw new TypeError(
`${p} expects zero arguments, ${args.length} given`,
);
}
if (isPromised) {
return (value as Promise<unknown>).then(
(v) =>
runMatcher(
Reflect.get(target, p),
v,
args,
),
);
}
return runMatcher(
Reflect.get(target, p),
value,
args,
);
};
}
}
}
return Reflect.get(target, p);
},
});
}
}
}
},
});
}
return (...args: unknown[]) =>
isPromised
? (value as Promise<unknown>).then((v) => applyMatcher(v, args))
: applyMatcher(value, args);
},
ownKeys() {
return Reflect.ownKeys(matchers);
},
getOwnPropertyDescriptor(_, p) {
return Reflect.getOwnPropertyDescriptor(matchers, p);
},
getPrototypeOf: () =>
Object.create(matchers, {
[Symbol.toStringTag]: { value: "Expected" },
}),
setPrototypeOf: () => false, // dummy
isExtensible: () => true, // dummy
preventExtensions: () => true, // dummy
},
);
return self;
}
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// deno-lint-ignore-file no-explicit-any
import {
type AnyConstructor,
assertEquals,
assertInstanceOf,
AssertionError,
assertIsError,
assertMatch,
assertNotEquals,
assertNotInstanceOf,
assertNotMatch,
assertNotStrictEquals,
assertObjectMatch,
assertStrictEquals,
AsyncFunction,
AsyncGenerator,
AsyncGeneratorFunction,
equal,
filterUndefined,
format,
Generator,
GeneratorFunction,
inspectArg,
inspectArgs,
} from "./utils.ts";
import { getMockCalls } from "./mock.ts";
import { assertArrayIncludes } from "./utils.ts";
export interface UntypedMatcherContext {
value: unknown;
isNot: boolean;
customMessage?: string | undefined;
}
export interface MatcherContext<T = unknown, N extends boolean = boolean>
extends UntypedMatcherContext {
value: T;
isNot: N;
customMessage?: string | undefined;
}
export interface Matcher<
T = unknown,
U = unknown,
N extends boolean = boolean,
A extends readonly unknown[] = readonly unknown[],
> {
(
this: MatcherContext<U, N>,
context: MatcherContext<U, N>,
...args: A
): asserts context is RetypeContext<T, U, N>;
(
this: MatcherContext<T | U, N> | void,
context: MatcherContext<T | U, N>,
...args: A
): asserts context is RetypeContext<T, U, N>;
(
this: MatcherContext<U, N>,
context: MatcherContext<U, N>,
...args: A
): void | Promise<void>;
(context: MatcherContext<T | U, N>): context is RetypeContext<T, U, N>;
(context: MatcherContext<T, N>, ...args: A): void | Promise<void>;
}
export interface Matchers {
[key: string]: Matcher;
}
export type MatchResult<T = void> = T | Promise<T> | boolean;
type RetypeContext<T, U, N extends boolean = boolean> = `${N}` extends
infer S extends string ? "true" extends S ? MatcherContext<Exclude<U, T>, N>
: MatcherContext<Extract<U, T>, N>
: MatcherContext<T & U, N>;
export function toBe<T, N extends boolean, const A>(
context: MatcherContext<unknown, N>,
expect: T,
): asserts context is RetypeContext<T, unknown, N> {
if (context.isNot) {
assertNotStrictEquals(context.value, expect, context.customMessage);
} else {
assertStrictEquals(context.value, expect, context.customMessage);
}
}
toBe.truthy = toBeTruthy;
toBe.falsy = toBeFalsy;
toBe.null = toBeNull;
toBe.undefined = toBeUndefined;
toBe.defined = toBeDefined;
toBe.NaN = toBeNaN;
toBe.instanceOf = toBe.instanceof = toBeInstanceOf;
toBe.a = toBe.an = Object.assign(
((...args: Parameters<typeof toBeType>) =>
toBeType(...args)) as typeof toBeType,
{
function: toBeFunction,
asyncFunction: toBeAsyncFunction,
object: toBeObject,
string: toBeString,
number: toBeNumber,
bigint: toBeBigInt,
boolean: toBeBoolean,
symbol: toBeSymbol,
generator: toBeGeneratorObject,
generatorFunction: toBeGeneratorFunction,
asyncGenerator: toBeAsyncGeneratorObject,
asyncGeneratorFunction: toBeAsyncGeneratorFunction,
iterable: toBeIterable,
asyncIterable: toBeAsyncIterable,
array: toBeArray,
},
);
toBe.type = toBeType;
toBe.closeTo = toBeCloseTo;
toBe.greaterThanOrEqual = toBeGreaterThanOrEqual;
toBe.greaterThan = toBeGreaterThan;
toBe.lessThanOrEqual = toBeLessThanOrEqual;
toBe.lessThan = toBeLessThan;
toBe.iterable = toBeIterable;
toBe.asyncIterable = toBeAsyncIterable;
toBe.array = toBeArray;
export function toBeGeneratorObject(
context: MatcherContext,
): MatchResult {
assertStrictEquals(
Object.prototype.isPrototypeOf.call(
Generator.prototype,
Object(context.value),
),
!context.isNot,
context.customMessage ??
`Expected value ${context.isNot ? "not " : ""}to be a generator object`,
);
}
export function toBeGeneratorFunction(
context: MatcherContext,
): MatchResult {
if (context.isNot) {
assertNotInstanceOf(
context.value,
GeneratorFunction,
context.customMessage,
);
} else {
assertInstanceOf(context.value, GeneratorFunction, context.customMessage);
}
}
export function toBeAsyncGeneratorObject(
context: MatcherContext,
): MatchResult {
assertStrictEquals(
Object.prototype.isPrototypeOf.call(
AsyncGenerator.prototype,
Object(context.value),
),
!context.isNot,
context.customMessage ??
`Expected value ${
context.isNot ? "not " : ""
}to be an async generator object`,
);
}
export function toBeAsyncGeneratorFunction(
context: MatcherContext,
): MatchResult {
if (context.isNot) {
assertNotInstanceOf(
context.value,
AsyncGeneratorFunction,
context.customMessage,
);
} else {
assertInstanceOf(
context.value,
AsyncGeneratorFunction,
context.customMessage,
);
}
}
export function toBeIterable<T>(
context: MatcherContext,
): MatchResult<Iterable<T>> {
if (context.isNot) {
assertNotStrictEquals(
typeof (context.value as Iterable<unknown>)?.[Symbol.iterator],
"function",
context.customMessage ?? "Expected value to not be iterable",
);
} else {
assertStrictEquals(
typeof (context.value as Iterable<unknown>)?.[Symbol.iterator],
"function",
context.customMessage ?? "Expected value to be iterable",
);
}
return undefined!;
}
export function toBeIterator<T>(
context: MatcherContext,
): MatchResult<Iterator<T>> {
if (context.isNot) {
assertNotStrictEquals(
typeof (context.value as Iterable<unknown>)?.[Symbol.iterator],
"function",
context.customMessage ?? "Expected value to not be iterable",
);
} else {
assertStrictEquals(
typeof (context.value as Iterable<unknown>)?.[Symbol.iterator],
"function",
context.customMessage ?? "Expected value to be iterable",
);
}
return undefined!;
}
export function toBeAsyncIterable<
T = unknown,
U = unknown,
N extends boolean = boolean,
>(
context: MatcherContext<U, N>,
): asserts context is MatcherContext<
[`${N}`] extends ["true"] ? Exclude<U, AsyncIterable<T>>
: Extract<U, AsyncIterable<T>>,
N
> {
if (context.isNot) {
assertNotStrictEquals(
typeof (context.value as AsyncIterable<unknown>)?.[Symbol.asyncIterator],
"function",
context.customMessage ??
"Expected value to not be asynchronously iterable",
);
} else {
assertStrictEquals(
typeof (context.value as AsyncIterable<unknown>)?.[Symbol.asyncIterator],
"function",
context.customMessage ?? "Expected value to be asynchronously iterable",
);
}
}
export function toBeAsyncIterator<
T = unknown,
U = unknown,
N extends boolean = boolean,
>(
context: MatcherContext<U, N>,
): asserts context is MatcherContext<
[`${N}`] extends ["true"] ? Exclude<U, AsyncIterator<T>>
: Extract<U, AsyncIterator<T>>,
N
> {
if (context.isNot) {
assertNotStrictEquals(
typeof (context.value as AsyncIterable<unknown>)?.[Symbol.asyncIterator],
"function",
context.customMessage ??
"Expected value to not be asynchronously iterable",
);
} else {
assertStrictEquals(
typeof (context.value as AsyncIterable<unknown>)?.[Symbol.asyncIterator],
"function",
context.customMessage ?? "Expected value to be asynchronously iterable",
);
}
}
export function toBeArray(context: MatcherContext): MatchResult {
if (context.isNot) {
assertNotInstanceOf(context.value, Array, context.customMessage);
assertNotStrictEquals(
Array.isArray(context.value),
true,
context.customMessage,
);
} else {
assertInstanceOf(context.value, Array, context.customMessage);
assertStrictEquals(
Array.isArray(context.value),
true,
context.customMessage,
);
}
}
export function toEqual<const T>(
context: MatcherContext,
expected: T,
): MatchResult {
const v = filterUndefined(context.value);
const e = filterUndefined(expected);
if (context.isNot) {
assertNotEquals(v, e, context.customMessage);
} else {
assertEquals(v, e, context.customMessage);
}
}
export function toStrictEqual(
context: MatcherContext,
expected: unknown,
): MatchResult {
if (context.isNot) {
assertNotEquals(context.value, expected, context.customMessage);
} else {
assertEquals(context.value, expected, context.customMessage);
}
}
export function toBeCloseTo(
context: MatcherContext,
expected: number,
numDigits = 2,
): MatchResult {
if (numDigits < 0) {
throw new Error(
"toBeCloseTo second argument must be a non-negative integer. Got " +
numDigits,
);
}
const tolerance = 0.5 * Math.pow(10, -numDigits);
const value = Number(context.value);
const pass = Math.abs(expected - value) < tolerance;
if (context.isNot) {
if (pass) {
throw new AssertionError(
`Expected the value not to be close to ${expected} (using ${numDigits} digits), but it is`,
);
}
} else {
if (!pass) {
throw new AssertionError(
`Expected the value (${value} to be close to ${expected} (using ${numDigits} digits), but it is not`,
);
}
}
}
export function toBeDefined(context: MatcherContext): MatchResult {
if (context.isNot) {
assertStrictEquals(context.value, undefined, context.customMessage);
} else {
assertNotStrictEquals(context.value, undefined, context.customMessage);
}
}
export function toBeUndefined(context: MatcherContext): MatchResult {
if (context.isNot) {
assertNotStrictEquals(
context.value,
undefined,
context.customMessage,
);
} else {
assertStrictEquals(context.value, undefined, context.customMessage);
}
}
export function toBeFunction(context: MatcherContext): MatchResult {
if (context.isNot) {
assertNotStrictEquals(
typeof context.value,
"function",
context.customMessage,
);
} else {
assertStrictEquals(typeof context.value, "function", context.customMessage);
}
}
export function toBeAsyncFunction(context: MatcherContext): MatchResult {
if (context.isNot) {
assertNotInstanceOf(context.value, AsyncFunction, context.customMessage);
} else {
assertInstanceOf(context.value, AsyncFunction, context.customMessage);
}
}
interface TypeMap {
string: string;
number: number;
bigint: bigint;
boolean: boolean;
object: object;
// deno-lint-ignore ban-types
function: Function;
symbol: symbol;
null: null;
undefined: undefined;
}
export type TypeNames = keyof TypeMap & string;
export function toBeType<const K extends readonly TypeNames[]>(
context: MatcherContext,
...expectedTypes: [...K]
): MatchResult {
const actual = context.value === null ? "null" : typeof context.value;
const expected = expectedTypes.includes(actual as TypeNames);
if (context.isNot) {
if (expected) {
throw new AssertionError(
context.customMessage ??= `Expected value not to be of type ${
expectedTypes.map((t, i, a) =>
i === a.length - 1 ? `or '${t}'` : `'${t}'`
).join(", ")
}. Received type: '${actual}'`,
);
}
} else {
assertArrayIncludes(expectedTypes, [actual], context.customMessage);
}
}
export function toBeString(context: MatcherContext): MatchResult {
return toBeType(context, "string");
}
export function toBeNumber(context: MatcherContext): MatchResult {
return toBeType(context, "number");
}
export function toBeBigInt(context: MatcherContext): MatchResult {
return toBeType(context, "bigint");
}
export function toBeBoolean(context: MatcherContext): MatchResult {
return toBeType(context, "boolean");
}
export function toBeSymbol(context: MatcherContext): MatchResult {
return toBeType(context, "symbol");
}
export function toBeObject(context: MatcherContext): MatchResult {
return toBeType(context, "object");
}
export function toBeFalsy(
context: MatcherContext,
): MatchResult {
const isFalsy = !(context.value);
if (context.isNot) {
if (isFalsy) {
throw new AssertionError(
`Expected ${context.value} to NOT be falsy`,
);
}
} else {
if (!isFalsy) {
throw new AssertionError(
`Expected ${context.value} to be falsy`,
);
}
}
}
export function toBeTruthy(
context: MatcherContext,
): MatchResult {
const isTruthy = !!(context.value);
if (context.isNot) {
if (isTruthy) {
throw new AssertionError(
`Expected ${context.value} to NOT be truthy`,
);
}
} else {
if (!isTruthy) {
throw new AssertionError(
`Expected ${context.value} to be truthy`,
);
}
}
}
export function toBeGreaterThanOrEqual(
context: MatcherContext,
expected: number,
): MatchResult {
const isGreaterOrEqual = Number(context.value) >= Number(expected);
if (context.isNot) {
if (isGreaterOrEqual) {
throw new AssertionError(
`Expected ${context.value} to NOT be greater than or equal ${expected}`,
);
}
} else {
if (!isGreaterOrEqual) {
throw new AssertionError(
`Expected ${context.value} to be greater than or equal ${expected}`,
);
}
}
}
export function toBeGreaterThan(
context: MatcherContext,
expected: number,
): MatchResult {
const isGreater = Number(context.value) > Number(expected);
if (context.isNot) {
if (isGreater) {
throw new AssertionError(
`Expected ${context.value} to NOT be greater than ${expected}`,
);
}
} else {
if (!isGreater) {
throw new AssertionError(
`Expected ${context.value} to be greater than ${expected}`,
);
}
}
}
export function toBeLessThanOrEqual(
context: MatcherContext,
expected: number,
): MatchResult {
const isLower = Number(context.value) <= Number(expected);
if (context.isNot) {
if (isLower) {
throw new AssertionError(
`Expected ${context.value} to NOT be lower than or equal ${expected}`,
);
}
} else {
if (!isLower) {
throw new AssertionError(
`Expected ${context.value} to be lower than or equal ${expected}`,
);
}
}
}
export function toBeLessThan(
context: MatcherContext,
expected: number,
): MatchResult {
const isLower = Number(context.value) < Number(expected);
if (context.isNot) {
if (isLower) {
throw new AssertionError(
`Expected ${context.value} to NOT be lower than ${expected}`,
);
}
} else {
if (!isLower) {
throw new AssertionError(
`Expected ${context.value} to be lower than ${expected}`,
);
}
}
}
export function toBeNaN(context: MatcherContext): MatchResult {
if (context.isNot) {
assertNotEquals(
isNaN(Number(context.value)),
true,
context.customMessage || `Expected ${context.value} to not be NaN`,
);
} else {
assertEquals(
isNaN(Number(context.value)),
true,
context.customMessage || `Expected ${context.value} to be NaN`,
);
}
}
export function toBeNull(context: MatcherContext): MatchResult {
if (context.isNot) {
assertNotStrictEquals(
context.value as number,
null,
context.customMessage || `Expected ${context.value} to not be null`,
);
} else {
assertStrictEquals(
context.value as number,
null,
context.customMessage || `Expected ${context.value} to be null`,
);
}
}
export function toHaveLength(
context: MatcherContext,
expected: number,
): MatchResult {
const { value } = context;
// deno-lint-ignore no-explicit-any
const maybeLength = (value as any)?.length;
const hasLength = maybeLength === expected;
if (context.isNot) {
if (hasLength) {
throw new AssertionError(
`Expected value not to have length ${expected}, but it does`,
);
}
} else {
if (!hasLength) {
throw new AssertionError(
`Expected value to have length ${expected}, but it does not. (The value has length ${maybeLength})`,
);
}
}
}
export function toBeInstanceOf<T extends AnyConstructor>(
context: MatcherContext,
expected: T,
): MatchResult {
if (context.isNot) {
assertNotInstanceOf(context.value, expected);
} else {
assertInstanceOf(context.value, expected);
}
}
export function toHaveProperty(
context: MatcherContext,
propName: PropertyKey | PropertyKey[],
v?: unknown,
): MatchResult {
const { value } = context;
let propPath = [] as PropertyKey[];
if (Array.isArray(propName)) {
propPath = propName;
} else if (typeof propName === "string") {
propPath = propName.split(".");
} else {
propPath = [propName];
}
propPath = propPath.flatMap<PropertyKey>((prop) => {
if (typeof prop === "symbol") return prop;
const p = String(prop);
return p.split(/\.|\[(?=[^\]]*)\]/g).filter((p) => p);
});
// deno-lint-ignore no-explicit-any
let current = value as any;
while (true) {
if (current === undefined || current === null) {
break;
}
if (propPath.length === 0) {
break;
}
const prop = propPath.shift()!;
current = current[prop];
}
let hasProperty;
if (v) {
hasProperty = current !== undefined && propPath.length === 0 &&
equal(current, v);
} else {
hasProperty = current !== undefined && propPath.length === 0;
}
let ofValue = "";
if (v) {
ofValue = ` of the value ${inspectArg(v)}`;
}
const joined = propPath.reduce((acc, p) => {
if (!p) return acc.toString();
if (typeof p === "symbol") {
return `${acc.toString()}[${String(p)}]`;
}
if (!isNaN(Number(p))) {
return `${acc.toString()}[${p}]`;
}
return `${acc ? acc.toString() + "." : ""}${p}`;
}, "") as string;
if (context.isNot) {
if (hasProperty) {
throw new AssertionError(
`Expected the value not to have the property ${joined}${ofValue}, but it does.`,
);
}
} else {
if (!hasProperty) {
throw new AssertionError(
`Expected the value to have the property ${joined}${ofValue}, but it does not.`,
);
}
}
}
export function toContain(
context: MatcherContext,
expected: unknown,
): MatchResult {
// deno-lint-ignore no-explicit-any
const doesContain = (context.value as any)?.includes?.(expected);
if (context.isNot) {
if (doesContain) {
throw new AssertionError("The value contains the expected item");
}
} else {
if (!doesContain) {
throw new AssertionError("The value doesn't contain the expected item");
}
}
}
export function toContainEqual(
context: MatcherContext,
expected: unknown,
): MatchResult {
const { value } = context;
assertIsIterable(value);
let doesContain = false;
for (const item of value) {
if (equal(item, expected)) {
doesContain = true;
break;
}
}
if (context.isNot) {
if (doesContain) {
throw new AssertionError("The value contains the expected item");
}
} else {
if (!doesContain) {
throw new AssertionError("The value doesn't contain the expected item");
}
}
}
// deno-lint-ignore no-explicit-any
function assertIsIterable(value: any): asserts value is Iterable<unknown> {
if (value == null) {
throw new AssertionError("The value is null or undefined");
}
if (typeof value[Symbol.iterator] !== "function") {
throw new AssertionError("The value is not iterable");
}
}
export function toMatch(
context: MatcherContext,
expected: RegExp,
): MatchResult {
if (context.isNot) {
assertNotMatch(
String(context.value),
expected,
context.customMessage,
);
} else {
assertMatch(String(context.value), expected, context.customMessage);
}
}
export function toMatchObject(
context: MatcherContext,
expected: Record<PropertyKey, unknown>,
): MatchResult {
if (context.isNot) {
let objectMatch = false;
try {
assertObjectMatch(
// deno-lint-ignore no-explicit-any
context.value as Record<PropertyKey, any>,
expected,
context.customMessage,
);
objectMatch = true;
const actualString = format(context.value);
const expectedString = format(expected);
throw new AssertionError(
`Expected ${actualString} to NOT match ${expectedString}`,
);
} catch (e) {
if (objectMatch) {
throw e;
}
return;
}
} else {
assertObjectMatch(
// deno-lint-ignore no-explicit-any
context.value as Record<PropertyKey, any>,
expected,
context.customMessage,
);
}
}
export function toHaveBeenCalled(context: MatcherContext): MatchResult {
const calls = getMockCalls(context.value);
const hasBeenCalled = calls.length > 0;
if (context.isNot) {
if (hasBeenCalled) {
throw new AssertionError(
`Expected mock function not to be called, but it was called ${calls.length} time(s)`,
);
}
} else {
if (!hasBeenCalled) {
throw new AssertionError(
`Expected mock function to be called, but it was not called`,
);
}
}
}
export function toHaveBeenCalledTimes(
context: MatcherContext,
expected: number,
): MatchResult {
const calls = getMockCalls(context.value);
if (context.isNot) {
if (calls.length === expected) {
throw new AssertionError(
`Expected mock function not to be called ${expected} time(s), but it was`,
);
}
} else {
if (calls.length !== expected) {
throw new AssertionError(
`Expected mock function to be called ${expected} time(s), but it was called ${calls.length} time(s)`,
);
}
}
}
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) {
throw new AssertionError(
`Expected mock function not to be called with ${
inspectArgs(expected)
}, but it was`,
);
}
} else {
if (!hasBeenCalled) {
let otherCalls = "";
if (calls.length > 0) {
otherCalls = `\n Other calls:\n ${
calls.map((call) => inspectArgs(call.args)).join("\n ")
}`;
}
throw new AssertionError(
`Expected mock function to be called with ${
inspectArgs(expected)
}, but it was not.${otherCalls}`,
);
}
}
}
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) {
throw new AssertionError(
`Expected mock function not to be last called with ${
inspectArgs(expected)
}, but it was`,
);
}
} else {
if (!hasBeenCalled) {
const lastCall = calls.at(-1);
if (!lastCall) {
throw new AssertionError(
`Expected mock function to be last called with ${
inspectArgs(expected)
}, but it was not.`,
);
} else {
throw new AssertionError(
`Expected mock function to be last called with ${
inspectArgs(expected)
}, but it was last called with ${inspectArgs(lastCall.args)}.`,
);
}
}
}
}
export function toHaveBeenNthCalledWith(
context: MatcherContext,
nth: number,
...expected: unknown[]
): MatchResult {
if (nth < 1) {
new Error(`nth must be greater than 0. ${nth} was given.`);
}
const calls = getMockCalls(context.value);
const callIndex = nth - 1;
const hasBeenCalled = calls.length > callIndex &&
equal(calls[callIndex]?.args, expected);
if (context.isNot) {
if (hasBeenCalled) {
throw new AssertionError(
`Expected the n-th call (n=${nth}) of mock function is not with ${
inspectArgs(expected)
}, but it was`,
);
}
} else {
if (!hasBeenCalled) {
const nthCall = calls[callIndex];
if (!nthCall) {
throw new AssertionError(
`Expected the n-th call (n=${nth}) of mock function is with ${
inspectArgs(expected)
}, but the n-th call does not exist.`,
);
} else {
throw new AssertionError(
`Expected the n-th call (n=${nth}) of mock function is with ${
inspectArgs(expected)
}, but it was with ${inspectArgs(nthCall.args)}.`,
);
}
}
}
}
export function toHaveReturned(context: MatcherContext): MatchResult {
const calls = getMockCalls(context.value);
const returned = calls.filter((call) => call.returns);
if (context.isNot) {
if (returned.length > 0) {
throw new AssertionError(
`Expected the mock function to not have returned, but it returned ${returned.length} times`,
);
}
} else {
if (returned.length === 0) {
throw new AssertionError(
`Expected the mock function to have returned, but it did not return`,
);
}
}
}
export function toHaveReturnedTimes(
context: MatcherContext,
expected: number,
): MatchResult {
const calls = getMockCalls(context.value);
const returned = calls.filter((call) => call.returns);
if (context.isNot) {
if (returned.length === expected) {
throw new AssertionError(
`Expected the mock function to not have returned ${expected} times, but it returned ${returned.length} times`,
);
}
} else {
if (returned.length !== expected) {
throw new AssertionError(
`Expected the mock function to have returned ${expected} times, but it returned ${returned.length} times`,
);
}
}
}
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`,
);
}
}
}
export function toHaveLastReturnedWith(
context: MatcherContext,
expected: unknown,
): MatchResult {
const calls = getMockCalls(context.value);
const returned = calls.filter((call) => call.returns);
const lastReturnedWithExpected = returned.length > 0 &&
equal(returned.at(-1)?.returned, expected);
if (context.isNot) {
if (lastReturnedWithExpected) {
throw new AssertionError(
`Expected the mock function to not have last returned with ${
inspectArg(expected)
}, but it did`,
);
}
} else {
if (!lastReturnedWithExpected) {
throw new AssertionError(
`Expected the mock function to have last returned with ${
inspectArg(expected)
}, but it did not`,
);
}
}
}
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.returns);
const returnIndex = nth - 1;
const maybeNthReturned = returned[returnIndex];
const nthReturnedWithExpected = maybeNthReturned &&
equal(maybeNthReturned.returned, expected);
if (context.isNot) {
if (nthReturnedWithExpected) {
throw new AssertionError(
`Expected the mock function to not have n-th (n=${nth}) returned with ${
inspectArg(expected)
}, but it did`,
);
}
} else {
if (!nthReturnedWithExpected) {
throw new AssertionError(
`Expected the mock function to have n-th (n=${nth}) returned with ${
inspectArg(expected)
}, but it did not`,
);
}
}
}
export function toThrow<E extends Error = Error>(
context: MatcherContext,
// deno-lint-ignore no-explicit-any
expected?: string | RegExp | E | (new (...args: any[]) => E),
): MatchResult {
if (typeof context.value === "function") {
try {
context.value = context.value();
} catch (err) {
context.value = err;
}
}
// deno-lint-ignore no-explicit-any
type ErrorClass = new (...args: any[]) => Error;
let expectClass: undefined | ErrorClass = undefined;
let expectMessage: undefined | string | RegExp = undefined;
if (expected instanceof Error) {
expectClass = expected.constructor as ErrorClass;
expectMessage = expected.message;
}
if (typeof expected === "function") {
expectClass = expected as ErrorClass;
}
if (typeof expected === "string" || expected instanceof RegExp) {
expectMessage = expected;
}
if (context.isNot) {
let isError = false;
try {
assertIsError(
context.value,
expectClass,
expectMessage,
context.customMessage,
);
isError = true;
throw new AssertionError(`Expected to NOT throw ${expected}`);
} catch (e) {
if (isError) {
throw e;
}
return;
}
}
return assertIsError(
context.value,
expectClass,
expectMessage,
context.customMessage,
);
}
export const matchers = {
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,
toBeBigInt,
toBeBoolean,
toBeNumber,
toBeObject,
toBeString,
toBeSymbol,
toBeType,
toBeAsyncFunction,
toBeFunction,
toBeGeneratorFunction,
toBeGeneratorObject,
toBeAsyncGeneratorFunction,
toBeAsyncGeneratorObject,
toBeIterable,
toBeAsyncIterable,
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,
} as const;
export type matchers = typeof matchers;
const MOCK_SYMBOL = Symbol.for("@MOCK");
type MOCK_SYMBOL = typeof MOCK_SYMBOL;
export interface MockCall<
A extends readonly unknown[] = readonly unknown[],
R = unknown,
T = unknown,
> {
args: A;
returned?: R;
thrown?: T;
timestamp: number;
returns: boolean;
throws: boolean;
}
export function getMockCalls<
const F extends {
readonly [MOCK_SYMBOL]: {
readonly calls: readonly MockCall<any, any, any>[];
};
},
>(
f: F,
): F[MOCK_SYMBOL] extends
{ readonly calls: infer C extends readonly MockCall[] } ? C
: readonly MockCall[];
export function getMockCalls(f: unknown): readonly MockCall[];
export function getMockCalls(f: any): readonly MockCall[] {
if (f == null || !(typeof f === "function" || typeof f === "object")) {
throw new TypeError("Received value must be a function or object");
}
if (!(MOCK_SYMBOL in f)) {
throw new TypeError("Received value must be a mock or spy function");
}
const mockInfo = f[MOCK_SYMBOL];
if (!mockInfo) {
throw new Error("Received function must be a mock or spy function");
}
return [...mockInfo.calls] as any;
}
export function fn<A extends readonly unknown[], R = unknown>(
...stubs: readonly ((...args: A) => R)[]
): {
<const Args extends A>(...args: Args): R | undefined;
readonly [MOCK_SYMBOL]: {
readonly calls: readonly MockCall[];
};
} {
const calls: MockCall[] = [];
const f = (...args: A) => {
const stub = stubs.length === 1
// keep reusing the first
? stubs[0]
// pick the exact mock for the current call
: stubs[calls.length];
try {
const returned = stub ? stub(...args) : undefined;
calls.push({
args,
returned,
timestamp: Date.now(),
returns: true,
throws: false,
});
return returned;
} catch (thrown) {
calls.push({
args,
timestamp: Date.now(),
returns: false,
thrown,
throws: true,
});
throw thrown;
}
};
Reflect.defineProperty(f, MOCK_SYMBOL, {
value: { calls },
writable: false,
configurable: true,
enumerable: false,
});
return f as unknown as ReturnType<typeof fn>;
}
// runtime exports
import { expect } from "./expect.ts";
import { matchers } from "./matchers.ts";
import { fn, getMockCalls } from "./mock.ts";
// type-only exports
import type { Expected } from "./expect.ts";
import type { Matcher, MatcherContext, Matchers } from "./matchers.ts";
import type { MockCall } from "./mock.ts";
// vendored behavior-driven development (BDD) helpers
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
it,
} from "https://deno.land/std@0.214.0/testing/bdd.ts";
export {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
fn,
fn as mock,
getMockCalls,
it,
matchers,
};
export type { Expected, Matcher, MatcherContext, Matchers, MockCall };
export default {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
fn,
getMockCalls,
it,
matchers,
mock: fn,
};
// deno-lint-ignore-file no-explicit-any
import { AssertionError } from "https://deno.land/std@0.215.0/assert/mod.ts";
export {
assertArrayIncludes,
assertEquals,
assertInstanceOf,
AssertionError,
assertIsError,
assertMatch,
assertNotEquals,
assertNotInstanceOf,
assertNotMatch,
assertNotStrictEquals,
assertObjectMatch,
assertStrictEquals,
assertStringIncludes,
equal,
} from "https://deno.land/std@0.215.0/assert/mod.ts";
export type Constructor<T, A extends readonly unknown[]> = {
new (...args: A): T;
};
export type AnyConstructor = Constructor<any, any>;
export interface AsyncFunctionConstructor
extends Constructor<AsyncFunction, string[]> {
readonly prototype: AsyncFunction;
(...args: string[]): AsyncFunction;
}
export interface AsyncFunction<TReturn = unknown> {
<T extends TReturn>(...args: unknown[]): Promise<T>;
readonly prototype: AsyncFunction;
}
export type Fn<T = any, A extends readonly unknown[] = any> = (...args: A) => T;
/**
* Converts all the methods in an interface to be async functions,
* including deeply nested methods, preserving the original types + structure.
*/
export type Async<T> = [T] extends [
((...a: infer A) => infer R) & (infer O),
] ?
& (A extends readonly [] ? () => Promise<Awaited<R>>
: <const Args extends A>(...args: Args) => Promise<Awaited<R>>)
& ([keyof O] extends [never] ? unknown : { [P in keyof O]: Async<O[P]> })
: [T] extends [(...args: infer A) => infer R]
? <const Args extends A>(...args: Args) => Promise<Awaited<R>>
: { [K in keyof T]: T[K] extends Fn ? Async<T[K]> : T[K] };
export const AsyncFunction: AsyncFunctionConstructor =
Object.getPrototypeOf(async () => {}).constructor;
export const AsyncGenerator: AsyncGeneratorFunction = Object.getPrototypeOf(
async function* () {},
);
export const Generator: GeneratorFunction = Object.getPrototypeOf(
function* () {},
);
export const AsyncGeneratorFunction: AsyncGeneratorFunctionConstructor =
AsyncGenerator.constructor as AsyncGeneratorFunctionConstructor;
export const GeneratorFunction: GeneratorFunctionConstructor = Generator
.constructor as GeneratorFunctionConstructor;
/**
* Converts the input into a string. Objects, Sets and Maps are sorted so as to
* make tests less flaky
* @param v Value to be formatted
*/
export function format(v: unknown): string {
const { Deno } = globalThis as any;
return typeof Deno?.inspect === "function"
? Deno.inspect(v, {
depth: Infinity,
sorted: true,
trailingComma: true,
compact: false,
iterableLimit: Infinity,
// getters should be true in assertEquals.
getters: true,
strAbbreviateSize: Infinity,
})
: `"${String(v).replace(/(?=["\\])/g, "\\")}"`;
}
export function inspectArgs<const A extends readonly unknown[]>(
args: A,
): string {
return args.map(inspectArg).join(", ");
}
export function inspectArg(arg: unknown): string {
const { Deno } = globalThis as any;
return typeof Deno !== "undefined" && Deno.inspect
? Deno.inspect(arg)
: String(arg);
}
export function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
if (value == null) {
return false;
} else {
return typeof ((value as Record<string, unknown>).then) === "function";
}
}
export function assertIsIterable<T>(
value: unknown,
): asserts value is Iterable<T> {
if (value == null || typeof value !== "object") {
throw new AssertionError("The value is null or undefined");
}
if (
Symbol.iterator in value && typeof value[Symbol.iterator] !== "function"
) {
throw new AssertionError("The value is not iterable");
}
}
export type Filter<A extends readonly unknown[], T = never> = A extends
readonly [infer B, ...infer C] ? B extends T ? Filter<C, T>
: B extends readonly unknown[] // filter deeeep bro
? [Filter<B, T>, ...Filter<C, T>]
: [B, ...Filter<C, T>]
: [];
export function filterUndefined<const T extends readonly unknown[]>(
array: T,
): Filter<T, undefined>;
export function filterUndefined<const T extends object>(obj: T): {
[K in keyof T as [T[K]] extends [undefined] ? never : K]: T[K];
};
export function filterUndefined<T>(obj: T): Exclude<T, undefined>;
export function filterUndefined(obj: any) {
if (
typeof obj !== "object" || obj instanceof Date || obj instanceof RegExp ||
ArrayBuffer.isView(obj) || obj === null || "size" in obj ||
Symbol.iterator in obj
) {
return obj;
} else if (Array.isArray(obj)) {
return obj.map(filterUndefined);
}
const result = structuredClone(obj);
for (const key in result) {
const val = result[key];
if (val === undefined) {
Reflect.deleteProperty(result, key);
continue;
}
result[key] = filterUndefined(val);
}
return result;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment