Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions src/lib/ErrorAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { AssertionError } from "assert";

import { Assertion } from "./Assertion";

export class ErrorAssertion<T extends Error> extends Assertion<T> {

public constructor(actual: T) {
super(actual);
}

/**
* Check if the error has exactly the passed error.
*
* @param message the message the error should contain
* @returns the assertion instance
*/
public toHaveMessage(message: string): this {
const error = new AssertionError({
actual: this.actual.message,
expected: message,
message: `Expected error to have the message: ${message}`
});
const invertedError = new AssertionError({
actual: this.actual,
message: `Expected error NOT to have the message: ${message}`
});

return this.execute({
assertWhen: this.actual.message === message,
error,
invertedError
});
}

/**
* Check if the error has a message that starts with the provided fragment
*
* @param fragment the fragment the message should start with
* @returns the assertion instance
*/
public toHaveMessageStartingWith(fragment: string): this {
const error = new AssertionError({
actual: this.actual.message,
message: `Expected error to have a message starting with: ${fragment}`
});
const invertedError = new AssertionError({
actual: this.actual.message,
message: `Expected error NOT to have a message starting with: ${fragment}`
});

return this.execute({
assertWhen: this.actual.message.startsWith(fragment),
error,
invertedError
});
}

/**
* Check if the error has a message that contains the provided fragment
*
* @param fragment the fragment the message should contain
* @returns the assertion instance
*/
public toHaveMessageContaining(fragment: string): this {
const error = new AssertionError({
actual: this.actual.message,
message: `Expected error to have a message containing: ${fragment}`
});
const invertedError = new AssertionError({
actual: this.actual.message,
message: `Expected error NOT to have a message containing: ${fragment}`
});

return this.execute({
assertWhen: this.actual.message.includes(fragment),
error,
invertedError
});
}

/**
* Check if the error has a message that ends with the provided fragment
*
* @param fragment the fragment the message should end with
* @returns the assertion instance
*/
public toHaveMessageEndingWith(fragment: string): this {
const error = new AssertionError({
actual: this.actual.message,
message: `Expected error to have a message ending with: ${fragment}`
});
const invertedError = new AssertionError({
actual: this.actual.message,
message: `Expected error NOT to have a message ending with: ${fragment}`
});

return this.execute({
assertWhen: this.actual.message.endsWith(fragment),
error,
invertedError
});
}

/**
* Check if the error has a message taht matches the provided regular
* expression.
*
* @param regex the regular expression to match the error message
* @returns the assertion error
*/
public toHaveMessageMatching(regex: RegExp): this {
const error = new AssertionError({
actual: this.actual.message,
message: `Expected the error message to match the regex <${regex.source}>`
});
const invertedError = new AssertionError({
actual: this.actual,
message: `Expected the error message NOT to match the regex <${regex.source}>`
});

return this.execute({
assertWhen: regex.test(this.actual.message),
error,
invertedError
});
}

/**
* Check if the name of the error is the passed name.
*
* @param name the name of the error
* @returns the assertion instance
*/
public toHaveName(name: string): this {
const error = new AssertionError({
actual: this.actual.message,
message: `Expected the error name to be <${name}>`
});
const invertedError = new AssertionError({
actual: this.actual,
message: `Expected the error name NOT to be <${name}>`
});

return this.execute({
assertWhen: this.actual.name === name,
error,
invertedError
});
}
}
155 changes: 129 additions & 26 deletions src/lib/FunctionAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,159 @@
import { AssertionError } from "assert";

import { Assertion } from "./Assertion";
import { ErrorAssertion } from "./ErrorAssertion";
import { TypeFactory } from "./helpers/TypeFactories";

export type AnyFunction = (...args: any[]) => any;

function functionExecution<T extends AnyFunction>(func: T): Error | undefined {
try {
func();
return undefined;
} catch (error) {
return error instanceof Error
? error
: Error(`The function threw something that is not an Error: ${error}`);
}
interface Class<T> extends Function {
prototype: T;
}

function assertion<E extends Error>(error: E | undefined , expectedError: E): boolean {
return !!error
&& error?.name === expectedError.name
&& error?.message === expectedError.message;
}
const NoThrow = Symbol("NoThrow");

export class FunctionAssertion<T extends AnyFunction> extends Assertion<T> {

constructor(actual: T) {
super(actual);
}

private captureError(): unknown | typeof NoThrow {
try {
this.actual();
return NoThrow;
} catch (error) {
return error;
}
}

/**
* Check if the value throws an error.
* Check if the function throws anything when called.
*
* @returns the assertion instance
*/
public toThrowError<E extends Error>(expectedError?: E): this {
const expected = expectedError || new Error();
const errorExecution = functionExecution(this.actual);
public toThrow(): this {
const captured = this.captureError();
const error = new AssertionError({
actual: this.actual,
expected,
message: `Expected to throw error <${expected.name}> with message <'${expected.message || ""}'>`
actual: captured,
message: "Expected the function to throw when called"
});
const invertedError = new AssertionError({
actual: this.actual,
message: `Expected value to NOT throw error <${expected.name}> with message <'${expected.message || ""}'>`
actual: captured,
message: "Expected the function NOT to throw when called"
});

return this.execute({
assertWhen: expectedError
? assertion(errorExecution, expected)
: errorExecution instanceof Error,
assertWhen: captured !== NoThrow,
error,
invertedError
});
}

/**
* Check if the function throws an {@link Error}. If the `ErrorType` is passed,
* it also checks if the error is an instance of the specific type.
*
* @example
* ```
* expect(throwingFunction)
* .toThrowError()
* .toHaveMessage("Oops! Something went wrong...")
*
* expect(myCustomFunction)
* .toThrowError(MyCustomError)
* .toHaveMessage("Something failed!");
* ```
*
* @param ErrorType optional error type constructor to check the thrown error
* against. If is not provided, it defaults to {@link Error}
* @returns a new {@link ErrorAssertion} to assert over the error
*/
public toThrowError(): ErrorAssertion<Error>;
public toThrowError<E extends Error>(ExpectedType: Class<E>): ErrorAssertion<E>;
public toThrowError<E extends Error>(ExpectedType?: Class<E>): ErrorAssertion<E> {
const captured = this.captureError();

if (captured === NoThrow) {
throw new AssertionError({
actual: captured,
message: "Expected the function to throw when called"
});
}

const ErrorType = ExpectedType ?? Error;
const error = new AssertionError({
actual: captured,
message: `Expected the function to throw an error instance of <${ErrorType.name}>`
});
const invertedError = new AssertionError({
actual: captured,
message: `Expected the function NOT to throw an error instance of <${ErrorType.name}>`
});

this.execute({
assertWhen: captured instanceof ErrorType,
error,
invertedError
});

return new ErrorAssertion(captured as E);
}

/**
* Check if the function throws a non-error value when called. Additionally,
* you can pass a {@link TypeFactory} in the second argument so the returned
* assertion is for the specific value type. Otherwise, a basic
* {@link Assertion Assertion<unknown>} instance is returned.
*
* @example
* ```
* expect(raiseValue)
* .toThrowValue()
* .toBeEqual(someValue);
*
* expect(raiseExitCode)
* .toThrowValue(TypeFactories.Number)
* .toBeNegative();
* ```
*
* @param expected the value the function is expected to throw
* @param typeFactory optional type factory to perform more specific
* assertions over the thrown value
* @returns the factory assertion or a basic assertion instance
*/
public toThrowValue<S, A extends Assertion<S>>(typeFactory?: TypeFactory<S, A>): A {
const captured = this.captureError();

if (captured === NoThrow) {
throw new AssertionError({
actual: captured,
message: "Expected the function to throw a value"
});
}

const error = new AssertionError({
actual: captured,
message: typeFactory
? `Expected the function to throw a value of type "${typeFactory.typeName}"`
: "Expected the function to throw a value"
});
const invertedError = new AssertionError({
actual: captured,
message: typeFactory
? `Expected the function NOT to throw a value of type "${typeFactory.typeName}"`
: "Expected the function NOT to throw a value"
});
const isTypeMatch = typeFactory?.predicate(captured) ?? true;

this.execute({
assertWhen: captured !== NoThrow && isTypeMatch,
error,
invertedError
});

return typeFactory?.predicate(captured)
? new typeFactory.Factory(captured)
: new Assertion(captured) as A;
}
}
16 changes: 9 additions & 7 deletions src/lib/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ArrayAssertion } from "./ArrayAssertion";
import { Assertion } from "./Assertion";
import { BooleanAssertion } from "./BooleanAssertion";
import { DateAssertion } from "./DateAssertion";
import { ErrorAssertion } from "./ErrorAssertion";
import { AnyFunction, FunctionAssertion } from "./FunctionAssertion";
import { isAnyFunction, isJSObject, isPromise } from "./helpers/guards";
import { NumberAssertion } from "./NumberAssertion";
Expand All @@ -17,9 +18,10 @@ export function expect<T extends boolean>(actual: T): BooleanAssertion;
export function expect<T extends number>(actual: T): NumberAssertion;
export function expect<T extends string>(actual: T): StringAssertion;
export function expect<T extends Date>(actual: T): DateAssertion;
export function expect<T extends unknown[]>(actual: T): ArrayAssertion<ArrayType<T>>;
export function expect<T extends Promise<any>>(actual: T): PromiseAssertion<PromiseType<T>>;
export function expect<T extends AnyFunction>(actual: T): FunctionAssertion<T>;
export function expect<T extends any[]>(actual: T): ArrayAssertion<ArrayType<T>>;
export function expect<T extends Error>(actual: T): ErrorAssertion<T>;
export function expect<T extends JSObject>(actual: T): ObjectAssertion<T>;
export function expect<T>(actual: T): Assertion<T>;
export function expect<T>(actual: T) {
Expand All @@ -29,14 +31,14 @@ export function expect<T>(actual: T) {
case "string": return new StringAssertion(actual);
}

if (Array.isArray(actual)) {
return new ArrayAssertion(actual);
}

if (actual instanceof Date) {
return new DateAssertion(actual);
}

if (Array.isArray(actual)) {
return new ArrayAssertion(actual);
}

if (isPromise<T>(actual)) {
return new PromiseAssertion(actual);
}
Expand All @@ -45,8 +47,8 @@ export function expect<T>(actual: T) {
return new FunctionAssertion(actual);
}

if (Array.isArray(actual)) {
return new ArrayAssertion(actual);
if (actual instanceof Error) {
return new ErrorAssertion(actual);
}

if (isJSObject(actual)) {
Expand Down
Loading