Custom Methods
Custom methods (or extension methods) are methods added to expect
that you provide the implementation for. They
allow you to extend the existing functionality of @rbxts/expect
for your own needs.
- What custom methods are
- The different components of a custom method
- How to implement your own custom method
- Guidance on proper usage
- How to use your implemented method
Overview
A custom method is an implementor of the CustomMethodImpl
type:
export type CustomMethodImpl<T = unknown> = (source: Assertion<T>, actual: T, ...args: never[]) => ExpectMethodResult;
Source
The source
parameter of a custom method is just a mapping to the parent Assertion
that created the call. The
Assertion
interface is the closest thing to an "instance" of the expect
function; it carries all the methods that
you call after expect
, and optionally some metadata for custom methods to use.
You'll rarely need to use it, but it comes in handy when dealing with metadata.
import { CustomMethodImpl } from "@src/expect/extend";
function validateArrayIsEmpty(actual: defined[]) {
// ...
}
function validateObjectIsEmpty(actual: object) {
// ...
}
const empty: CustomMethodImpl<unknown> = (source, actual) => {
if (typeIs(actual, "table")) {
if (source.is_array) return validateArrayIsEmpty(actual);
return validateObjectIsEmpty(actual);
}
// ...
};
Actual
The source
parameter of a custom method maps to what we call the "actual" value. This is the value that was passed
into the initial expect
call. This is in contrast to the "expected" value.
For example:
expect(5).to.equal(6);
In this case, 5
is the "actual" value and 6
is the "expected" value. We expected the value 6
, but we actually
got the value 5
.
You can see an example of its usage in the equal
method:
import { CustomMethodImpl, ExpectMessageBuilder, place } from "@src/expect/extend";
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.name} to ${place.not} strictly equal ${place.expected.value} (${place.expected.type})`,
);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
return actual === expected ? message.pass() : message.fail();
};
Additional arguments
The ...args
parameter in a custom method is just a variable amount of additional arguments you can pass. Usually, this
only really includes one; the "expected" argument.
For example:
expect(5).to.equal(6);
In this case, there's only one extra argument, and it's for the "expected" value.
Sometimes, you need more than one extra argument though. The enum method is a perfect example of this:
import type { EnumValue, LuaEnum, CustomMethodImpl } from "@rbxts/expect";
function validateIsEnumType(actual: keyof LuaEnum, enumType: LuaEnum) {
// ...
}
function validateIsEnumValue(actual: keyof LuaEnum, enumType: LuaEnum, value: keyof LuaEnum) {
// ...
}
const _enum: CustomMethodImpl<keyof LuaEnum> = (source, actual, enumType: LuaEnum, value?: keyof LuaEnum) => {
if (value !== undefined) {
return validateIsEnumValue(actual, enumType, value).inspect(() => {
source.enum_type = enumType;
});
} else {
return validateIsEnumType(actual, enumType).inspect(() => {
source.enum_type = enumType;
});
}
};
Here, we have two extra arguments; one that maps to the LuaEnum
and another (optionally) for a value of that enum.
This allows us to provide two different methods:
import { expect } from "@rbxts/expect";
enum Sport {
Basketball,
Soccer,
Football,
}
// validate "actual" is of enum type 'Sport'
expect("Basketball").to.be.enum(Sport);
// validate "actual" of of enum type 'Sport' and of value `Sport.Basketball`
expect("Basketball").to.be.enum(Sport, Sport.Basketball);
ExpectMethodResult
The return value of custom methods is of the type ExpectMethodResult
.
export type ExpectMethodResult = Result<ExpectMessageBuilder, ExpectMessageBuilder>;
The Result
type comes from the @rbxts/rust-classes
package, but we provide wrappers around it (which we'll get into
in a moment)- so there's no need to look much into that.
The key thing to focus on here is the ExpectMessageBuilder
.
You'll learn more about ExpectMessageBuilder
in the expect messages guide, but it's essentially
just a wrapper around the error messages your check throws.
import { ExpectMessageBuilder, CustomMethodImpl, place } from "@rbxts/expect";
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.name} to ${place.not} strictly equal ${place.expected.value} (${place.expected.type})`,
);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
return actual === expected ? message.pass() : message.fail();
};
You create instances of these builders through the use
method, populate them with various utility methods provided on
the ExpectMessageBuilder class (such as
expectedValue), and then return them through either the
pass or fail methods.
The pass and fail
methods are the "wrappers" around the Result
type we talked about above.
Returning these types allows expect
to throw the right message (either a negated pass, or a failure), without the
method needing to know what's going on state-wise. This helps keep your methods simple and deterministic.
Metadata
Metadata is additional data attached to expect
chains, that is derrived from the result of a previous method call.
For example, the array
method attaches the is_array
property, whenever it passes.
import { ExpectMessageBuilder, CustomMethodImpl, place } from "@rbxts/expect";
const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} be an array`);
const array: CustomMethodImpl<unknown> = (source, actual) => {
const message = baseMessage.use();
// ...
source.is_array = true;
return message.pass();
};
declare module "@rbxts/expect" {
interface Assertion<T> {
is_array?: boolean;
array(): Assertion<T extends unknown[] ? T : T[]>;
}
}
This allows other methods to take advantage of this property to skip certain checks, or change their output accordingly.
For example, the empty
method supports strings, arrays, and tables. But not only does it need different logic for
tables and strings- but it has a different message for arrays and tables:
- arrays
- tables
expect([1]).to.be.empty();
Expected '[1]' to be empty, but it had an element
expect({ name: "Daymon" }).to.be.empty();
Expected the object to be empty, but it had the key 'name'
Value of [name]: "Daymon"
To help accomplish this, the empty
method checks the is_array
property:
if (typeIs(actual, "table")) {
if (source.is_array) {
return validateArrayIsEmpty(actual as defined[]);
} else {
return validateObjectIsEmpty(actual);
}
}
Which would translate like so:
expect([1, 2, 3]).to.be.an.array().that.is.empty();
Usage
Once you've got a custom method implemented, you can add it to @rbxts/expect
through the
extendMethods.
import { CustomMethodImpl, extendMethods, ExpectMessageBuilder, place } from "@rbxts/expect";
import { includes } from "@rbxts/string-utils";
// define a message for when our check fails
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.name} to ${place.not} have the substring ${place.expected.value}`,
);
// implement our actual method
const substring: CustomMethodImpl<string> = (_, actual, str: string) => {
const message = baseMessage.use().expectedValue(str);
return includes(actual, str) ? message.pass() : message.fail();
};
// tell typescript about our method
declare module "@rbxts/expect" {
interface Assertion<T> {
substring(str: string): Assertion<T>;
}
}
// link our method at runtime
extendMethods({
substring: substring,
});
To learn more about how this works, check out the module augmentation guide.
Best practices
- Validate casted types: If your
CustomMethodImpl
specifies types other thanunknown
- you should have validations at the start of your method to ensure they're the expected type. - Split overloads into seperate functions: If your extension method provides an overload, or optional argument, don't be afraid to split your implementation into multiple functions to properly encompass this. You can look at the implementation for the enum method to see an example of this.
Examples
All of the matchers that are bundled with @rbxts/expect
are also implemented in this same way.
So if you want to see some example implementations, take a look at the extensions directory on the main repo.
Summary
Let's recap what we've learned about custom methods:
- They provide a way to add your own checks to
@rbxts/expect
- They return instances of
ExpectMessageBuilder
through the pass and fail methods - They can add metadata to
Assertion
forexpect
chains - They get added to
@rbxts/expect
through the extendMethods function - They get added to typescript through module augmentation