Expect Messages
Expect messages are the error messages that custom methods throw whenever they fail.
@rbxts/expect
provides various utility methods to make creating these messages easier, and to help you practice
property DRY principles.
- What expect messages are
- The different components of an expect message
- How to implement your own expect message
- Guidance on proper usage
Overview
ExpectMessageBuilder is how we define the structure of an error message.
We then "populate" this message through methods on ExpectMessageBuilder, when in our custom method.
At the end of our methods, we return this populated instance, and @rbxts/expect
will handle throwing the actual
error.
Creation
To create an ExpectMessageBuilder, you just call the default constructor.
import { ExpectMessageBuilder, place } from "@rbxts/expect";
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.actual.value} to equal ${place.expected.value}`,
`Expected ${place.actual.value} to NOT equal ${place.expected.value}`,
);
The first argument is the message that will be used when your check fails:
expect(1).to.equal(2);
The second argument is the message that will be used when your check passes, but it was negated:
expect(1).to.not.equal(2);
Placeholders
You might've noticed these in the previous examples, but placeholders (prefixed with the place
keyword) are basically
just drop-in replacements for a variety of data that will be automatically populated before your message is thrown.
export interface ActualPlaceholder {
fullValue: string;
type: string;
value: string;
}
export interface ExpectedPlaceholder {
fullValue: string;
type: string;
value: string;
}
export interface Placeholder {
actual: ActualPlaceholder;
expected: ExpectedPlaceholder;
index: string;
name: string;
nil: string;
not: string;
path: string;
reason: string;
undefined: string;
}
export const place: Placeholder;
You'll learn more about each placeholder, and their usage, in the following sections.
Usage
To use a message, you call the use method; this will return a copy of the message. With this copy, you can then call various methods on the message to populate data- and use the pass/fail methods to return it.
import { CustomMethodImpl, ExpectMessageBuilder, place } from "@rbxts/expect";
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.name} to ${place.not} strictly equal ${place.expected.value}`,
);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
return actual === expected ? message.pass() : message.fail();
};
Not calling use may cause your variables to leak into other (unrelated)
messages. It's important to call use
before adding data.
The only reason you can populate data before calling use
is because this allows you to populate "default" data.
You'll learn more about what this looks like in the metadata section.
Variable data
VariableData is the type used behind the scenes for the "actual" and "expected" variables.
export interface VariableData {
value: unknown;
type?: string;
}
These correspond to the ActualPlaceholder and ExpectedPlaceholder types as well.
export interface ActualPlaceholder {
fullValue: string;
type: string;
value: string;
}
export interface ExpectedPlaceholder {
fullValue: string;
type: string;
value: string;
}
Usage
For example, lets say we were checking if two values are equal.
import { ExpectMessageBuilder, place, CustomMethodImpl } from "@rbxts/expect";
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.actual.value} (${place.actual.type}) to equal ${place.expected.value} (${place.expected.type})`,
);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage
.use()
.actualValue(actual)
.actualType(typeOf(actual))
.expectedValue(expected)
.expectedType(typeOf(expected));
return actual === expected ? message.pass() : message.fail();
};
Expected '5' (number) to equal "5" (string)
Using the helper methods on ExpectMessageBuilder
, we "populated" the data for the actual and expected variables in our
final message.
Alternatively, you can use the actual
and expected
methods to populate the value and type at the same time.
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage
.use()
.actual({
value: actual,
type: typeOf(actual),
})
.expected({
value: expected,
type: typeOf(expected),
});
return actual === expected ? message.pass() : message.fail();
};
Automatic behavior
expect
automatically populates some data for you, so some of this can actually be removed entirely.
Type
By default, the type
property is automatically computed according to the value
. So if you're calling typeOf
, you
don't have to manually populate this.
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().actualValue(actual).expectedValue(expected);
return actual === expected ? message.pass() : message.fail();
};
So why have it
There are certain situations where the inferred type from typeOf
may not be desired.
For example, what if we wanted to support a custom class type?
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
if (isMyCustomType(expected)) {
message.expectedType("MyCustomType");
// logic unique to MyCustomType ...
}
// ...
};
Actual
By default, the data for actual
is automatically computed and populated for you. So if you're not mutating it, or
adding additional context- there's no need to do it manually.
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
return actual === expected ? message.pass() : message.fail();
};
So why have it
While the actual
variable is set automatically, sometimes your method has additional context- or a better way to
represent the value.
A prime example of this is the enum method.
const baseMessage = new ExpectMessageBuilder(`Expected ${place.actual.value} to equal ${place.expected.value}`);
const equal: CustomMethodImpl<defined> = (_, enumTable, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
const valueAsEnum = enumTable[actual];
message.actualValue(valueAsEnum);
// ...
};
So instead of this output:
Expected '0' to equal "Basketball"
We can get this output:
Expected "Soccer" to equal "Basketball"
Full Value
You might have noticed that there's a property on the placeholder types called fullValue
that isn't present on the
VariableData
types.
export interface ActualPlaceholder {
fullValue: string;
type: string;
value: string;
}
export interface ExpectedPlaceholder {
fullValue: string;
type: string;
value: string;
}
By default, the value
property is what we call the "collapsed" version of the value.
This means that it respects the collapseLength as defined in the config.
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.actual.value} to be empty\nFullValue: ${place.actual.fullValue}`,
);
Expected '[...]' to be empty
FullValue: '[1,2,3,4,5,6,7,8,9,10]'
You can use the fullValue
property to ignore the collapseLength
and attach the full version of the value.
This allows you to keep the first sentence of your check short and easy to read, while also providing enough data after the first message- to debug the issue.
Keywords
Some of the placeholders are either drop-ins for certain words, or are only present given a certain context.
Not
The not
placeholder is a drop-in for the word "NOT"
, but is only present if your message is negated.
This can come in handy when the only difference between your failure message and your negated message is the word "NOT".
new ExpectMessageBuilder(`Expected ${place.actual.value} to ${place.not} be empty`);
// is the same as
new ExpectMessageBuilder(
`Expected ${place.actual.value} to be empty`,
`Expected ${place.actual.value} to NOT be empty`,
);
By default, if you don't provide a negated message as the second argument of your ExpectMessageBuilder
constructor
call, it defaults to the first argument (your standard failure message).
This allows you to take full advantage of the not
keyword.
Nil
The nil
and undefined
placeholders are drop-ins for the word "nil"
.
For example, let's say we were checking if an object was an arry:
new ExpectMessageBuilder(`Expected ${place.actual.value} to be an array`);
In our method, we could do something like this:
message.actualValue(actualValue ?? place.nil);
Then, (if actual
was undefined) our output would come out like so:
Expected 'nil' to be an array
Behind the scenes, expect
automatically does this for the actual
and expected
values.
The placeholders are provided anyways to allow you to do something similiar for nested values, or values beyond actual
and expected
.
Path
The path
placeholder maps to the path on nested variables.
When working with tables, you might want to retain the path to the property that failed.
Using a path
, you can specify a place in your message for this data to be provided.
While you can call the path method to manually provide this data, proxies will automatically provide this for you.
new ExpectMessageBuilder(`${place.path} - Expected ${place.actual.value} to be an array`);
parent.cars - Expected '2' to be an array
Name
The name
placeholder maps to either the path or the value of the actual
.
A common theme in messages in to display the path when working with tables, and display the actual value when working with non tables.
Using a name
, you can specify to use the path
whenever it's available, but fallback to the actual
value.
new ExpectMessageBuilder(`Expected ${place.name} to be an array`);
- tables
- others
withProxy(person, (p) => {
expect(p.cars).to.be.an.array();
});
Expected parent.cars to be an array
expect(5).to.be.an.array();
Expected '5' to be an array
Custom names
The default name
is the value of the actual
variable. But you can override this default via the name
method.
For example, maybe you wanna attach the type alongside the value.
new ExpectMessageBuilder(`Expected ${place.name} to be an array`).name(`${place.actual.value} (${place.actual.type})`);
Expected '5' (number) to be an array
Metadata
Metadata is additional data that follows after the main message, in the format of key: value
.
Lets say we're checking that all the values in an array are equal to an expected
value.
for (const [index, value] of ipairs(actual)) {
message.metadata({ Index: index, Value: value });
if (value !== expected) return message.fail();
}
Expected '[1,1,2]' to all be equal to '1', but there was a value that was not.
Index: 3
Value: 2
Metadata is useful for data that is only known at runtime, or data that is only needed for additional debugging- and should be seperate from the main contents of the message.
Metadata is sorted alphabetically in the output, but priority is placed for the following keys (in order): 'index', 'key', 'value', 'expected', 'actual'.
So if any of those keys are in your metadata, they'll come before the rest of your metadata. The capitalization doesn't matter either, so long as the keys are exactly equal.
Kind of metadata
Beyond the base metadata
, there are additional kinds of metadata that only get attached under certain circumstances.
Failure metadata
Failure metadata is metadata that is only attached to the message if your check fails.
A failure is effectively the same thing as !negated
. Useful for when you wanna conditionally attach data, but only
when the case is not a negation.
Lets say we're checking that all the values in an array are equal to an expected
value.
for (const [index, value] of ipairs(actual)) {
message.failureMetadata({ Index: index, Value: value });
if (value !== expected) return message.fail();
}
Expected '[1,1,2]' to all be equal to '1', but there was a value that was not.
Index: 3
Value: 2
This might look the same as using a normal metadata
, but the difference comes into play when the result is a pass.
If we used normal metadata
, and our check was passed- but it was negated:
expect([1, 1, 1]).to.not.allEqual(1);
Expected '[1,1,1]' to NOT all be equal to '1'.
Index: 3
Value: 1
But if we use failureMetadata
instead, it won't be attached in the case of a pass:
Expected '[1,1,1]' to NOT all be equal to '1'.
Failure metadata will come after any attached (base) metadata
, if there's any present.
Nested metadata
Nested metadata is metadata that is only attached to the message if there's a path present.
Useful for when you wanna conditionally attach data, but only when the case is on nested objects.
A very common use-case is attaching data about the actual value for nested objects when using a name, so you still get the path to the variable in the initial message.
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.name} to ${place.not} equal ${place.expected.value} (${place.expected.type})`,
)
.name(`${place.actual.value} (${place.actual.type})`)
.nestedMetadata({
[place.path]: `${place.actual.value} (${place.actual.type})`,
});
- with a path
- without a path
Expected parent.age to equal "5" (string)
parent.age: '5' (number)
Expected '5' (number) to equal "5" (string)
Nested metadata will come before all other metadata types (including the "base" metadata
), if there's any
present.
Surface metadata
Surface metadata is metadata that is only attached to the message if there's NOT a path present.
Effectively the opposite of nested metadata.
Useful for when you wanna conditionally attach data, but only when the case is on non nested objects.
For example, let's say we were checking if an object was empty, and we wanted to use the name the object
to keep the
message short:
const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} be empty`)
.name("the object")
.nestedMetadata({ [place.path]: place.actual.value });
We might have logic like so:
if (amount > 1) {
return message.suffix(`, but it had ${amount} keys`).surfaceMetadata({ Value: place.actual.value }).fail();
}
- with a path
- without a path
Expected parent.parent to be empty, but it had 2 keys
parent.parent: '{"name":"Daymon","age":24}'
Expected the object to be empty, but it had 2 keys
Value: '{"name":"Daymon","age":24}'
Surface metadata (like nested metadata) will come before all other metadata types (including the "base"
metadata
), if there's any present.
Reason
The reason
placeholder is a a utility placeholder for describing why the check failed.
A lot of times, your check might me making multiple assertions, or have different errors depending on the context.
Using a reason
, you can specify a place in your message for this data to be provided.
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.name} to ${place.not} equal ${place.expected.value}, but ${place.reason}`,
);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
if (typeOf(actual) !== typeOf(expected)) {
return message.reason("it was a different type").fail();
}
if (actual !== value) {
return message.reason("it had a different value").fail();
}
// apply a default reason for negations
return message.reason("it did").pass();
};
Expected "4" to equal '4', but it was a different type
Expected '10' to equal '10', but it was a different type
Expected '10' to NOT equal '10', but it did
You can use the helper method failWithReason
to provide a reason
and return a fail
at the same time.
return message.failWithReason("it was a different type");
// is the same as
return message.reason("it was a different type").fail();
Automatic attachment
If you don't have a place in your message for reason
to be populated, but still call "provide" a reason via the
reason method, then the message builder will automatically attach a
Reason: ${place.reason}
after your message.
const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);
if (typeOf(actual) !== typeOf(expected)) {
return message.reason("it was a different type").fail();
}
if (actual !== value) {
return message.reason("it had a different value").fail();
}
return message.pass();
};
Expected "4" to equal '4'
Reason: it was a different type
This way, you can add a reason
whenever you need it, without needing to find a predefined spot for it in your message.
If your message has any metadata attach to it, the Reason: message
line will come before it.
Prefix types
The base message is typically referred to as the "prefix" of your message, in contrast to all the data that follows.
But there are a few different types of prefixes your message can have.
Failure prefix
Otherwise known as the "base" prefix. This is the prefix attached to your messages whenever your check fails.
You can specify this prefix when creating your ExpectMessageBuilder
instance.
new ExpectMessageBuilder(`Expected ${place.name} to equal ${place.expected.value}`);
You can also also append this prefix with the appendPrefix
method.
new ExpectMessageBuilder(`Expected ${place.name} to equal ${place.expected.value}`).appendPrefix(", but it was not");
// Would be the same as
new ExpectMessageBuilder(
`Expected ${place.name} to equal ${place.expected.value},
but it was not`,
);
Or when you call use
, you can provide an additional message to append as well.
const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to equal ${place.expected.value}`);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use(", but it was not").expectedValue(expected);
return actual === expected ? message.pass() : message.fail();
};
Negated prefix
The negated prefix is a prefix that replaces the failure prefix, but only when your message is negated.
You can specify this prefix (as the second argument) when creating your ExpectMessageBuilder
instance.
new ExpectMessageBuilder(
`Expected ${place.name} to equal ${place.expected.value}`,
`Expected ${place.name} to NOT equal ${place.expected.value}`,
);
Calling appendPrefix
and use
will also append to the negation prefix.
By default, the negation prefix is set to be the same as the failure prefix.
This allows you to do things like this:
new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`);
Trailing failure prefix
The trailing failure prefix is a prefix that is attached to the end of your prefix, but only if the check fails.
Another way to look at it is that it's a prefix attached to the end of your failure prefix, but NOT your negated prefix.
You can specify a trailing failure prefix with the trailingFailurePrefix
method.
new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`).trailingFailurePrefix(
", but it was not",
);
// Would be the same as
new ExpectMessageBuilder(
`Expected ${place.name} to equal ${place.expected.value}, but it was not`,
`Expected ${place.name} to NOT equal ${place.expected.value}`,
);
Alternatively, you can specify a trailing failure prefix as the second argument of your use
call.
const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to equal ${place.expected.value}`);
const equal: CustomMethodImpl<defined> = (_, actual: defined, expected: defined) => {
const message = baseMessage.use("", ", but it was not").expectedValue(expected);
return actual === expected ? message.pass() : message.fail();
};
Suffix types
Suffixes are messages that come after the prefix, but before the metadata.
Failure suffix
This is the suffix attached to your messages whenever your check fails.
You can specify a string for this suffix via the suffix
method.
new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`)
.suffix(` because ${place.reason}`)
.appendPrefix(", but it was not");
// Would be the same as
new ExpectMessageBuilder(
`Expected ${place.name} to ${place.not} equal ${place.expected.value},
but it was not because ${place.reason}`,
);
Negation suffix
The negation suffix is a suffix that replaces the failure suffix, but only when your message is negated.
You can specify a string for this suffix via the negationSuffix
method.
new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`)
.suffix(", but it was not.")
.negationSuffix(", but it did");
// Would be the same as
new ExpectMessageBuilder(
`Expected ${place.name} to equal ${place.expected.value}, but it was not.`,
`Expected ${place.name} to NOT equal ${place.expected.value}, but it did.`,
);
Trailing failure suffix
The trailing failure suffix is a suffix that is attached to the end of your suffix, but only if the check fails.
Another way to look at it is that it's a suffix attached to the end of your failure suffix, but NOT your negation suffix.
You can specify a trailing failure suffix with the trailingFailureSuffix
method.
new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`)
.suffix(", but it was not.")
.negationSuffix(", but it did")
.trailingFailureSuffix(` because ${place.reason}`);
// Would be the same as
new ExpectMessageBuilder(
`Expected ${place.name} to equal ${place.expected.value}, but it was not because ${place.reason}`,
`Expected ${place.name} to NOT equal ${place.expected.value}, but it did because ${place.reason}`,
);
Options
You can configure certain output behaviors for your ExpectMessageBuilder
instances via the
ExpectMessageBuilderOptions
interface.
export interface ExpectMessageBuilderOptions {
trimSpaces: boolean;
wrapValues: boolean;
attachFullOnCollapse: boolean;
trimWhiteSpace: boolean;
}
You pass this interface as the third argument to your ExpectMessageBuilder
constructor call.
new ExpectMessageBuilder(`Expected ${place.name} to equal ${place.expected.value}`, undefined, {
trimWhiteSpace: false,
});
Trim spaces
By default, trimSpaces
is enabled.
The trimSpaces
option is for trimming the spaces around placeholders when they're absent.
For example, given the following message:
const baseMessage = new ExpectMessageBuilder(
`Expected ${place.actual.value} to ${place.not} equal ${place.expected.value}`,
);
If the statement was not negated, that means that ${place.not}
would be absent.
- trimSpaces: true
- trimSpaces: false
Expected 5 to equal 5
Expected 5 to equal 5
Wrap values
By default, wrapValues
is enabled.
The wrapValues
option is for wrapping VariableData in quotes when output.
Strings are wrapped in double quotes ("
), while everything else is wrapped in single quotes ('
).
- wrapValues: true
- wrapValues: false
Expected '5' to equal "daymon"
Expected 5 to equal daymon
Attach full on collapse
By default, attachFullOnCollapse
is enabled.
The attachFullOnCollapse
option is for automatically attaching the full version of variables whenever
they end up collapsed.
- attachFullOnCollapse: true
- attachFullOnCollapse: false
Expected {...} to equal {...}
Expected (full): '{ name: "Daymon", age: 5 }'
Actual (full): '{ name: "Daymon", age: 6 }'
Expected {...} to equal {...}
Useful for ensuring your primary failure message is short (and easy to read quickly), while retaining further details for debugging.
Also allows you to take full advantage of collapsing, without needing to manually attach place.actual.fullValue
and
place.expected.fullValue
.
Trim white space
By default, trimWhiteSpace
is enabled.
The trimWhiteSpace
option is for trimming the white space around the entire message, after it's been full built.
Lets say you appended the path to the start of your messages:
const baseMessage = new ExpectMessageBuilder(
`${place.path} Expected ${place.actual.value} to ${place.not} equal ${place.expected.value}`,
);
If the statement ended up not having a path, your message would come out with a leading space.
- trimWhiteSpace: false
- trimWhiteSpace: true
Expected 5 to equal 4
Expected 5 to equal 4
Custom encoding
Sometimes, you need to add variables to your message that are outside the scope of the actual
and expected
variables.
A good example of this is our deepEquals method.
Expected '{...}' to deep equal '{...}', but 'cars' was missing some elements
Expected: '["Tesla","Civic"]'
Actual: '["Tesla"]'
Missing: '["Civic"]'
Expected (full): '{"name":"Daymon","cars":["Tesla","Civic"]}'
Actual (full): '{"name":"Daymon","cars":["Tesla"]}'
You can use the encode
method on your ExpectMessageBuilder
instances to mirror this behavior.
return message
.suffix(`, but '${result.path}' was missing some elements`)
.metadata({
Actual: message.encode(result.leftValue),
Expected: message.encode(result.rightValue),
Missing: message.encode(result.leftMissing),
})
.fail();
There are various parameters you can pass to encode
for further configuration.
For the sake of keeping this page short, we won't get into them here. If you'd like to learn more about them, take a look at the api docs for encode.
Best practices
- Keep proxies in mind: Ensure your output messages are adjusted in a way that is easily consumable when used alongside proxies.
- Provide context: If your check fails because of a certain key or index, ensure that key or index is included in your output. Your message should contain enough metadata so that someone can properly debug the issue without needing to rewrite the test just to get further details.
- Call
use
before populating with values: It's fine to call methods to add placeholder or static values before callinguse
, but failing to calluse
before adding invocation specific data (such as the expected value, or index values) will cause your expect message to leak values into subsquent callers. - Use nested metadata when using names: If a path is used, you lose the data associated about your variable when you
use a
name
. To ensure this data is present, take advantage of nestedMetadata.
Summary
Let's recap what we've learned about expect messages:
- They define the structure of an error message
- They get populated within our custom methods
- They can be used alongside placeholders to define drop-in sites for common variables
- They define how a message changes when used with proxies or when the check is negated
- They can provide metadata to follow after the message for additional context