Skip to content

Commit

Permalink
Add more assertion utils (#49)
Browse files Browse the repository at this point in the history
* Add more assertion utils

* Update assertions
  • Loading branch information
Mrtenz authored Nov 2, 2022
1 parent 48feb84 commit 182509a
Show file tree
Hide file tree
Showing 4 changed files with 324 additions and 143 deletions.
75 changes: 73 additions & 2 deletions src/assert.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { assert, assertExhaustive, AssertionError } from './assert';
import { string } from 'superstruct';
import * as superstructModule from 'superstruct';
import {
assert,
assertExhaustive,
AssertionError,
assertStruct,
} from './assert';

describe('assert', () => {
it('succeeds', () => {
Expand All @@ -22,7 +29,7 @@ describe('assert', () => {
expect(() => assert(false)).toThrow('Assertion failed.');
});

it('throw custom error', () => {
it('throws a custom error', () => {
class MyError extends Error {}
expect(() => assert(false, new MyError('Thrown'))).toThrow(MyError);
});
Expand All @@ -35,3 +42,67 @@ describe('assertExhaustive', () => {
);
});
});

describe('assertStruct', () => {
it('does not throw for a valid value', () => {
expect(() => assertStruct('foo', string())).not.toThrow();
});

it('throws meaningful error messages for an invalid value', () => {
expect(() => assertStruct(undefined, string())).toThrow(
'Assertion failed: Expected a string, but received: undefined.',
);

expect(() => assertStruct(1, string())).toThrow(
'Assertion failed: Expected a string, but received: 1.',
);
});

it('throws with a custom error prefix', () => {
expect(() => assertStruct(null, string(), 'Invalid string')).toThrow(
'Invalid string: Expected a string, but received: null.',
);
});

it('throws with a custom error class', () => {
class CustomError extends Error {
constructor({ message }: { message: string }) {
super(message);
this.name = 'CustomError';
}
}

expect(() =>
assertStruct({ data: 'foo' }, string(), 'Invalid string', CustomError),
).toThrow(
new CustomError({
message:
'Invalid string: Expected a string, but received: [object Object].',
}),
);
});

it('throws with a custom error function', () => {
const CustomError = ({ message }: { message: string }) =>
new Error(message);

expect(() =>
assertStruct({ data: 'foo' }, string(), 'Invalid string', CustomError),
).toThrow(
CustomError({
message:
'Invalid string: Expected a string, but received: [object Object].',
}),
);
});

it('includes the value thrown in the message if it is not an error', () => {
jest.spyOn(superstructModule, 'assert').mockImplementation(() => {
throw 'foo.';
});

expect(() => assertStruct(true, string())).toThrow(
'Assertion failed: foo.',
);
});
});
109 changes: 106 additions & 3 deletions src/assert.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,74 @@
import { assert as assertSuperstruct, Struct } from 'superstruct';

export type AssertionErrorConstructor =
| (new (args: { message: string }) => Error)
| ((args: { message: string }) => Error);

/**
* Type guard for determining whether the given value is an error object with a
* `message` property, such as an instance of Error.
*
* @param error - The object to check.
* @returns True or false, depending on the result.
*/
function isErrorWithMessage(error: unknown): error is { message: string } {
return typeof error === 'object' && error !== null && 'message' in error;
}

/**
* Check if a value is a constructor, i.e., a function that can be called with
* the `new` keyword.
*
* @param fn - The value to check.
* @returns `true` if the value is a constructor, or `false` otherwise.
*/
function isConstructable(
fn: AssertionErrorConstructor,
): fn is new (args: { message: string }) => Error {
/* istanbul ignore next */
return Boolean(typeof fn?.prototype?.constructor?.name === 'string');
}

/**
* Get the error message from an unknown error object. If the error object has
* a `message` property, that property is returned. Otherwise, the stringified
* error object is returned.
*
* @param error - The error object to get the message from.
* @returns The error message.
*/
function getErrorMessage(error: unknown): string {
const message = isErrorWithMessage(error) ? error.message : String(error);

// If the error ends with a period, remove it, as we'll add our own period.
if (message.endsWith('.')) {
return message.slice(0, -1);
}

return message;
}

/**
* Initialise an {@link AssertionErrorConstructor} error.
*
* @param ErrorWrapper - The error class to use.
* @param message - The error message.
* @returns The error object.
*/
function getError(ErrorWrapper: AssertionErrorConstructor, message: string) {
if (isConstructable(ErrorWrapper)) {
return new ErrorWrapper({
message,
});
}
return ErrorWrapper({
message,
});
}

/**
* The default error class that is thrown if an assertion fails.
*/
export class AssertionError extends Error {
readonly code = 'ERR_ASSERTION';

Expand All @@ -10,17 +81,49 @@ export class AssertionError extends Error {
* Same as Node.js assert.
* If the value is falsy, throws an error, does nothing otherwise.
*
* @throws {@link AssertionError}. If value is falsy.
* @throws {@link AssertionError} If value is falsy.
* @param value - The test that should be truthy to pass.
* @param message - Message to be passed to {@link AssertionError} or an
* {@link Error} instance to throw.
* @param ErrorWrapper - The error class to throw if the assertion fails.
* Defaults to {@link AssertionError}. If a custom error class is provided for
* the `message` argument, this argument is ignored.
*/
export function assert(value: any, message?: string | Error): asserts value {
export function assert(
value: any,
message: string | Error = 'Assertion failed.',
ErrorWrapper: AssertionErrorConstructor = AssertionError,
): asserts value {
if (!value) {
if (message instanceof Error) {
throw message;
}
throw new AssertionError({ message: message ?? 'Assertion failed.' });

throw getError(ErrorWrapper, message);
}
}

/**
* Assert a value against a Superstruct struct.
*
* @param value - The value to validate.
* @param struct - The struct to validate against.
* @param errorPrefix - A prefix to add to the error message. Defaults to
* "Assertion failed".
* @param ErrorWrapper - The error class to throw if the assertion fails.
* Defaults to {@link AssertionError}.
* @throws If the value is not valid.
*/
export function assertStruct<T, S>(
value: unknown,
struct: Struct<T, S>,
errorPrefix = 'Assertion failed',
ErrorWrapper: AssertionErrorConstructor = AssertionError,
): asserts value is T {
try {
assertSuperstruct(value, struct);
} catch (error) {
throw getError(ErrorWrapper, `${errorPrefix}: ${getErrorMessage(error)}.`);
}
}

Expand Down
40 changes: 20 additions & 20 deletions src/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('json', () => {
'throws an error for invalid JSON-RPC notifications',
(notification) => {
expect(() => assertIsJsonRpcNotification(notification)).toThrow(
'Not a JSON-RPC notification',
'Invalid JSON-RPC notification',
);
},
);
Expand All @@ -87,7 +87,7 @@ describe('json', () => {
expect(() =>
assertIsJsonRpcNotification(JSON_RPC_NOTIFICATION_FIXTURES.invalid[0]),
).toThrow(
'Not a JSON-RPC notification: At path: jsonrpc -- Expected the literal `"2.0"`, but received: undefined.',
'Invalid JSON-RPC notification: At path: jsonrpc -- Expected the literal `"2.0"`, but received: undefined.',
);
});

Expand All @@ -98,7 +98,7 @@ describe('json', () => {

expect(() =>
assertIsJsonRpcNotification(JSON_RPC_NOTIFICATION_FIXTURES.invalid[0]),
).toThrow('Not a JSON-RPC notification: oops');
).toThrow('Invalid JSON-RPC notification: oops');
});
});

Expand Down Expand Up @@ -130,7 +130,7 @@ describe('json', () => {
'throws an error for invalid JSON-RPC requests',
(request) => {
expect(() => assertIsJsonRpcRequest(request)).toThrow(
'Not a JSON-RPC request',
'Invalid JSON-RPC request',
);
},
);
Expand All @@ -139,7 +139,7 @@ describe('json', () => {
expect(() =>
assertIsJsonRpcRequest(JSON_RPC_REQUEST_FIXTURES.invalid[0]),
).toThrow(
'Not a JSON-RPC request: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.',
'Invalid JSON-RPC request: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.',
);
});

Expand All @@ -150,7 +150,7 @@ describe('json', () => {

expect(() =>
assertIsJsonRpcRequest(JSON_RPC_REQUEST_FIXTURES.invalid[0]),
).toThrow('Not a JSON-RPC request: oops');
).toThrow('Invalid JSON-RPC request: oops');
});
});

Expand Down Expand Up @@ -182,7 +182,7 @@ describe('json', () => {
'throws an error for invalid JSON-RPC success',
(success) => {
expect(() => assertIsJsonRpcSuccess(success)).toThrow(
'Not a successful JSON-RPC response',
'Invalid JSON-RPC success response',
);
},
);
Expand All @@ -191,7 +191,7 @@ describe('json', () => {
expect(() =>
assertIsJsonRpcSuccess(JSON_RPC_SUCCESS_FIXTURES.invalid[0]),
).toThrow(
'Not a successful JSON-RPC response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.',
'Invalid JSON-RPC success response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.',
);
});

Expand All @@ -202,7 +202,7 @@ describe('json', () => {

expect(() =>
assertIsJsonRpcSuccess(JSON_RPC_SUCCESS_FIXTURES.invalid[0]),
).toThrow('Not a successful JSON-RPC response: oops');
).toThrow('Invalid JSON-RPC success response: oops.');
});
});

Expand Down Expand Up @@ -234,7 +234,7 @@ describe('json', () => {
'throws an error for invalid JSON-RPC failure',
(failure) => {
expect(() => assertIsJsonRpcFailure(failure)).toThrow(
'Not a failed JSON-RPC response',
'Invalid JSON-RPC failure response',
);
},
);
Expand All @@ -243,7 +243,7 @@ describe('json', () => {
expect(() =>
assertIsJsonRpcFailure(JSON_RPC_FAILURE_FIXTURES.invalid[0]),
).toThrow(
'Not a failed JSON-RPC response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.',
'Invalid JSON-RPC failure response: At path: id -- Expected the value to satisfy a union of `number | string`, but received: undefined.',
);
});

Expand All @@ -254,7 +254,7 @@ describe('json', () => {

expect(() =>
assertIsJsonRpcFailure(JSON_RPC_FAILURE_FIXTURES.invalid[0]),
).toThrow('Not a failed JSON-RPC response: oops');
).toThrow('Invalid JSON-RPC failure response: oops.');
});
});

Expand Down Expand Up @@ -286,7 +286,7 @@ describe('json', () => {
'throws an error for invalid JSON-RPC error',
(error) => {
expect(() => assertIsJsonRpcError(error)).toThrow(
'Not a JSON-RPC error',
'Invalid JSON-RPC error',
);
},
);
Expand All @@ -295,7 +295,7 @@ describe('json', () => {
expect(() =>
assertIsJsonRpcError(JSON_RPC_ERROR_FIXTURES.invalid[0]),
).toThrow(
'Not a JSON-RPC error: At path: code -- Expected an integer, but received: undefined.',
'Invalid JSON-RPC error: At path: code -- Expected an integer, but received: undefined.',
);
});

Expand All @@ -306,7 +306,7 @@ describe('json', () => {

expect(() =>
assertIsJsonRpcError(JSON_RPC_ERROR_FIXTURES.invalid[0]),
).toThrow('Not a JSON-RPC error: oops');
).toThrow('Invalid JSON-RPC error: oops');
});
});

Expand Down Expand Up @@ -338,7 +338,7 @@ describe('json', () => {
'throws for an invalid pending JSON-RPC response',
(response) => {
expect(() => assertIsPendingJsonRpcResponse(response)).toThrow(
'Not a pending JSON-RPC response',
'Invalid pending JSON-RPC response',
);
},
);
Expand All @@ -350,7 +350,7 @@ describe('json', () => {

expect(() =>
assertIsPendingJsonRpcResponse(JSON_RPC_FAILURE_FIXTURES.invalid[0]),
).toThrow('Not a pending JSON-RPC response: oops');
).toThrow('Invalid pending JSON-RPC response: oops');
});
});

Expand Down Expand Up @@ -382,7 +382,7 @@ describe('json', () => {
'throws an error for invalid JSON-RPC response',
(response) => {
expect(() => assertIsJsonRpcResponse(response)).toThrow(
'Not a JSON-RPC response',
'Invalid JSON-RPC response',
);
},
);
Expand All @@ -391,7 +391,7 @@ describe('json', () => {
expect(() =>
assertIsJsonRpcResponse(JSON_RPC_RESPONSE_FIXTURES.invalid[0]),
).toThrow(
'Not a JSON-RPC response: Expected the value to satisfy a union of `object | object`, but received: [object Object].',
'Invalid JSON-RPC response: Expected the value to satisfy a union of `object | object`, but received: [object Object].',
);
});

Expand All @@ -402,7 +402,7 @@ describe('json', () => {

expect(() =>
assertIsJsonRpcResponse(JSON_RPC_RESPONSE_FIXTURES.invalid[0]),
).toThrow('Not a JSON-RPC response: oops');
).toThrow('Invalid JSON-RPC response: oops');
});
});

Expand Down
Loading

0 comments on commit 182509a

Please sign in to comment.