Skip to content

Commit 2baccd0

Browse files
authored
feat(function): Refactor FunctionAssertion and add ErrorAssertion (#30)
* feat(function): Refactor FunctionAssertion and add ErrorAssertion * Address comments and expand `ErrorAssertion` methods
1 parent 36574f6 commit 2baccd0

File tree

6 files changed

+698
-113
lines changed

6 files changed

+698
-113
lines changed

src/lib/ErrorAssertion.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { AssertionError } from "assert";
2+
3+
import { Assertion } from "./Assertion";
4+
5+
export class ErrorAssertion<T extends Error> extends Assertion<T> {
6+
7+
public constructor(actual: T) {
8+
super(actual);
9+
}
10+
11+
/**
12+
* Check if the error has exactly the passed error.
13+
*
14+
* @param message the message the error should contain
15+
* @returns the assertion instance
16+
*/
17+
public toHaveMessage(message: string): this {
18+
const error = new AssertionError({
19+
actual: this.actual.message,
20+
expected: message,
21+
message: `Expected error to have the message: ${message}`
22+
});
23+
const invertedError = new AssertionError({
24+
actual: this.actual,
25+
message: `Expected error NOT to have the message: ${message}`
26+
});
27+
28+
return this.execute({
29+
assertWhen: this.actual.message === message,
30+
error,
31+
invertedError
32+
});
33+
}
34+
35+
/**
36+
* Check if the error has a message that starts with the provided fragment
37+
*
38+
* @param fragment the fragment the message should start with
39+
* @returns the assertion instance
40+
*/
41+
public toHaveMessageStartingWith(fragment: string): this {
42+
const error = new AssertionError({
43+
actual: this.actual.message,
44+
message: `Expected error to have a message starting with: ${fragment}`
45+
});
46+
const invertedError = new AssertionError({
47+
actual: this.actual.message,
48+
message: `Expected error NOT to have a message starting with: ${fragment}`
49+
});
50+
51+
return this.execute({
52+
assertWhen: this.actual.message.startsWith(fragment),
53+
error,
54+
invertedError
55+
});
56+
}
57+
58+
/**
59+
* Check if the error has a message that contains the provided fragment
60+
*
61+
* @param fragment the fragment the message should contain
62+
* @returns the assertion instance
63+
*/
64+
public toHaveMessageContaining(fragment: string): this {
65+
const error = new AssertionError({
66+
actual: this.actual.message,
67+
message: `Expected error to have a message containing: ${fragment}`
68+
});
69+
const invertedError = new AssertionError({
70+
actual: this.actual.message,
71+
message: `Expected error NOT to have a message containing: ${fragment}`
72+
});
73+
74+
return this.execute({
75+
assertWhen: this.actual.message.includes(fragment),
76+
error,
77+
invertedError
78+
});
79+
}
80+
81+
/**
82+
* Check if the error has a message that ends with the provided fragment
83+
*
84+
* @param fragment the fragment the message should end with
85+
* @returns the assertion instance
86+
*/
87+
public toHaveMessageEndingWith(fragment: string): this {
88+
const error = new AssertionError({
89+
actual: this.actual.message,
90+
message: `Expected error to have a message ending with: ${fragment}`
91+
});
92+
const invertedError = new AssertionError({
93+
actual: this.actual.message,
94+
message: `Expected error NOT to have a message ending with: ${fragment}`
95+
});
96+
97+
return this.execute({
98+
assertWhen: this.actual.message.endsWith(fragment),
99+
error,
100+
invertedError
101+
});
102+
}
103+
104+
/**
105+
* Check if the error has a message taht matches the provided regular
106+
* expression.
107+
*
108+
* @param regex the regular expression to match the error message
109+
* @returns the assertion error
110+
*/
111+
public toHaveMessageMatching(regex: RegExp): this {
112+
const error = new AssertionError({
113+
actual: this.actual.message,
114+
message: `Expected the error message to match the regex <${regex.source}>`
115+
});
116+
const invertedError = new AssertionError({
117+
actual: this.actual,
118+
message: `Expected the error message NOT to match the regex <${regex.source}>`
119+
});
120+
121+
return this.execute({
122+
assertWhen: regex.test(this.actual.message),
123+
error,
124+
invertedError
125+
});
126+
}
127+
128+
/**
129+
* Check if the name of the error is the passed name.
130+
*
131+
* @param name the name of the error
132+
* @returns the assertion instance
133+
*/
134+
public toHaveName(name: string): this {
135+
const error = new AssertionError({
136+
actual: this.actual.message,
137+
message: `Expected the error name to be <${name}>`
138+
});
139+
const invertedError = new AssertionError({
140+
actual: this.actual,
141+
message: `Expected the error name NOT to be <${name}>`
142+
});
143+
144+
return this.execute({
145+
assertWhen: this.actual.name === name,
146+
error,
147+
invertedError
148+
});
149+
}
150+
}

src/lib/FunctionAssertion.ts

Lines changed: 129 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,159 @@
11
import { AssertionError } from "assert";
22

33
import { Assertion } from "./Assertion";
4+
import { ErrorAssertion } from "./ErrorAssertion";
5+
import { TypeFactory } from "./helpers/TypeFactories";
46

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

7-
function functionExecution<T extends AnyFunction>(func: T): Error | undefined {
8-
try {
9-
func();
10-
return undefined;
11-
} catch (error) {
12-
return error instanceof Error
13-
? error
14-
: Error(`The function threw something that is not an Error: ${error}`);
15-
}
9+
interface Class<T> extends Function {
10+
prototype: T;
1611
}
1712

18-
function assertion<E extends Error>(error: E | undefined , expectedError: E): boolean {
19-
return !!error
20-
&& error?.name === expectedError.name
21-
&& error?.message === expectedError.message;
22-
}
13+
const NoThrow = Symbol("NoThrow");
2314

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

2617
constructor(actual: T) {
2718
super(actual);
2819
}
2920

21+
private captureError(): unknown | typeof NoThrow {
22+
try {
23+
this.actual();
24+
return NoThrow;
25+
} catch (error) {
26+
return error;
27+
}
28+
}
29+
3030
/**
31-
* Check if the value throws an error.
31+
* Check if the function throws anything when called.
3232
*
3333
* @returns the assertion instance
3434
*/
35-
public toThrowError<E extends Error>(expectedError?: E): this {
36-
const expected = expectedError || new Error();
37-
const errorExecution = functionExecution(this.actual);
35+
public toThrow(): this {
36+
const captured = this.captureError();
3837
const error = new AssertionError({
39-
actual: this.actual,
40-
expected,
41-
message: `Expected to throw error <${expected.name}> with message <'${expected.message || ""}'>`
38+
actual: captured,
39+
message: "Expected the function to throw when called"
4240
});
4341
const invertedError = new AssertionError({
44-
actual: this.actual,
45-
message: `Expected value to NOT throw error <${expected.name}> with message <'${expected.message || ""}'>`
42+
actual: captured,
43+
message: "Expected the function NOT to throw when called"
4644
});
4745

4846
return this.execute({
49-
assertWhen: expectedError
50-
? assertion(errorExecution, expected)
51-
: errorExecution instanceof Error,
47+
assertWhen: captured !== NoThrow,
48+
error,
49+
invertedError
50+
});
51+
}
52+
53+
/**
54+
* Check if the function throws an {@link Error}. If the `ErrorType` is passed,
55+
* it also checks if the error is an instance of the specific type.
56+
*
57+
* @example
58+
* ```
59+
* expect(throwingFunction)
60+
* .toThrowError()
61+
* .toHaveMessage("Oops! Something went wrong...")
62+
*
63+
* expect(myCustomFunction)
64+
* .toThrowError(MyCustomError)
65+
* .toHaveMessage("Something failed!");
66+
* ```
67+
*
68+
* @param ErrorType optional error type constructor to check the thrown error
69+
* against. If is not provided, it defaults to {@link Error}
70+
* @returns a new {@link ErrorAssertion} to assert over the error
71+
*/
72+
public toThrowError(): ErrorAssertion<Error>;
73+
public toThrowError<E extends Error>(ExpectedType: Class<E>): ErrorAssertion<E>;
74+
public toThrowError<E extends Error>(ExpectedType?: Class<E>): ErrorAssertion<E> {
75+
const captured = this.captureError();
76+
77+
if (captured === NoThrow) {
78+
throw new AssertionError({
79+
actual: captured,
80+
message: "Expected the function to throw when called"
81+
});
82+
}
83+
84+
const ErrorType = ExpectedType ?? Error;
85+
const error = new AssertionError({
86+
actual: captured,
87+
message: `Expected the function to throw an error instance of <${ErrorType.name}>`
88+
});
89+
const invertedError = new AssertionError({
90+
actual: captured,
91+
message: `Expected the function NOT to throw an error instance of <${ErrorType.name}>`
92+
});
93+
94+
this.execute({
95+
assertWhen: captured instanceof ErrorType,
96+
error,
97+
invertedError
98+
});
99+
100+
return new ErrorAssertion(captured as E);
101+
}
102+
103+
/**
104+
* Check if the function throws a non-error value when called. Additionally,
105+
* you can pass a {@link TypeFactory} in the second argument so the returned
106+
* assertion is for the specific value type. Otherwise, a basic
107+
* {@link Assertion Assertion<unknown>} instance is returned.
108+
*
109+
* @example
110+
* ```
111+
* expect(raiseValue)
112+
* .toThrowValue()
113+
* .toBeEqual(someValue);
114+
*
115+
* expect(raiseExitCode)
116+
* .toThrowValue(TypeFactories.Number)
117+
* .toBeNegative();
118+
* ```
119+
*
120+
* @param expected the value the function is expected to throw
121+
* @param typeFactory optional type factory to perform more specific
122+
* assertions over the thrown value
123+
* @returns the factory assertion or a basic assertion instance
124+
*/
125+
public toThrowValue<S, A extends Assertion<S>>(typeFactory?: TypeFactory<S, A>): A {
126+
const captured = this.captureError();
127+
128+
if (captured === NoThrow) {
129+
throw new AssertionError({
130+
actual: captured,
131+
message: "Expected the function to throw a value"
132+
});
133+
}
134+
135+
const error = new AssertionError({
136+
actual: captured,
137+
message: typeFactory
138+
? `Expected the function to throw a value of type "${typeFactory.typeName}"`
139+
: "Expected the function to throw a value"
140+
});
141+
const invertedError = new AssertionError({
142+
actual: captured,
143+
message: typeFactory
144+
? `Expected the function NOT to throw a value of type "${typeFactory.typeName}"`
145+
: "Expected the function NOT to throw a value"
146+
});
147+
const isTypeMatch = typeFactory?.predicate(captured) ?? true;
148+
149+
this.execute({
150+
assertWhen: captured !== NoThrow && isTypeMatch,
52151
error,
53152
invertedError
54153
});
154+
155+
return typeFactory?.predicate(captured)
156+
? new typeFactory.Factory(captured)
157+
: new Assertion(captured) as A;
55158
}
56159
}

src/lib/expect.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ArrayAssertion } from "./ArrayAssertion";
22
import { Assertion } from "./Assertion";
33
import { BooleanAssertion } from "./BooleanAssertion";
44
import { DateAssertion } from "./DateAssertion";
5+
import { ErrorAssertion } from "./ErrorAssertion";
56
import { AnyFunction, FunctionAssertion } from "./FunctionAssertion";
67
import { isAnyFunction, isJSObject, isPromise } from "./helpers/guards";
78
import { NumberAssertion } from "./NumberAssertion";
@@ -17,9 +18,10 @@ export function expect<T extends boolean>(actual: T): BooleanAssertion;
1718
export function expect<T extends number>(actual: T): NumberAssertion;
1819
export function expect<T extends string>(actual: T): StringAssertion;
1920
export function expect<T extends Date>(actual: T): DateAssertion;
21+
export function expect<T extends unknown[]>(actual: T): ArrayAssertion<ArrayType<T>>;
2022
export function expect<T extends Promise<any>>(actual: T): PromiseAssertion<PromiseType<T>>;
2123
export function expect<T extends AnyFunction>(actual: T): FunctionAssertion<T>;
22-
export function expect<T extends any[]>(actual: T): ArrayAssertion<ArrayType<T>>;
24+
export function expect<T extends Error>(actual: T): ErrorAssertion<T>;
2325
export function expect<T extends JSObject>(actual: T): ObjectAssertion<T>;
2426
export function expect<T>(actual: T): Assertion<T>;
2527
export function expect<T>(actual: T) {
@@ -29,14 +31,14 @@ export function expect<T>(actual: T) {
2931
case "string": return new StringAssertion(actual);
3032
}
3133

32-
if (Array.isArray(actual)) {
33-
return new ArrayAssertion(actual);
34-
}
35-
3634
if (actual instanceof Date) {
3735
return new DateAssertion(actual);
3836
}
3937

38+
if (Array.isArray(actual)) {
39+
return new ArrayAssertion(actual);
40+
}
41+
4042
if (isPromise<T>(actual)) {
4143
return new PromiseAssertion(actual);
4244
}
@@ -45,8 +47,8 @@ export function expect<T>(actual: T) {
4547
return new FunctionAssertion(actual);
4648
}
4749

48-
if (Array.isArray(actual)) {
49-
return new ArrayAssertion(actual);
50+
if (actual instanceof Error) {
51+
return new ErrorAssertion(actual);
5052
}
5153

5254
if (isJSObject(actual)) {

0 commit comments

Comments
 (0)