Custom Matchers
Learn how to add custom matchers for expect() calls
This guide will walk you through creating your own custom matchers for expect() assertions. Custom matchers allow you to extend the standard set of assertions with your own domain-specific logic, making your tests more readable and expressive.
Before writing a custom matcher, check Built-in Matchers and Helpers. Acton already provides built-in transaction matchers, map expectations, and out-action helpers that often cover the common cases.
The Problem with Standalone Check Functions
Imagine you have some validation logic that you want to extract into a function. By extracting it into a function, you'll get code like this:
import "@acton/testing/expect"
import "@acton/testing/assert"
fun checkIfValueIsEqualWithFlags(left: int, right: int, param1: bool, param2: bool) {
// some complex logic
Assert.equal(left, right);
}
get fun `test unit`() {
checkIfValueIsEqualWithFlags(5 + 5, 10, false, true);
expect(2 + 2).toEqual(1 + 3);
}This approach works, but it breaks the fluent API pattern of expect() and makes tests harder to read. It's not immediately clear what is being asserted.
The Solution: Custom Matchers
This code can be written much more readably and consistently by creating a custom matcher:
import "@acton/testing/expect"
import "@acton/testing/assert"
fun Expectation<int>.toEqualWithFlags(self, right: int, param1: bool, param2: bool) {
// some complex logic
Assert.equal(self.value, right);
}
get fun `test unit`() {
expect(5 + 5).toEqualWithFlags(10, false, true);
expect(2 + 2).toEqual(1 + 3);
}As you can see, instead of a standalone function, we added a new method directly to the Expectation<int> type. The expect() function returns an instance of Expectation<T>, where T is the type of the value passed to expect(). By extending this type, you can add your own matchers.
Now using your matcher is no different from standard ones.
Custom matchers are a powerful way to create reusable validation logic. They keep your tests clean and focused on the business logic rather than implementation details.
Creating Custom Matchers
To create a custom matcher, you need to define a function that extends the Expectation<T> struct. The T is a generic parameter that will be replaced with the actual type of the value you're testing.
Here's a basic structure of a custom matcher:
fun Expectation<T>.myCustomMatcher(self, arg1: Type1, arg2: Type2) {
// Your validation logic goes here
// Use self.value to access the value passed to expect()
}Matchers for Specific Types
While you can define generic matchers for Expectation<T>, it's often more useful to create matchers for specific data types. This allows you to build powerful, domain-specific assertions.
Example: int Matcher
Let's create a matcher that checks if an integer is even.
import "@acton/testing/expect"
import "@acton/testing/assert"
import "@acton/fmt"
fun Expectation<int>.toBeEven(self) {
if (self.value % 2 != 0) {
Assert.fail(
format("Expected {} to be even", self.value),
self.location
);
}
}
get fun `test even number`() {
expect(10).toBeEven();
// expect(9).toBeEven(); // This would fail
}In this example, toBeEven is defined on Expectation<int>, so it will only be available when expect() is called with an int. Inside the matcher, self.value holds the integer 10.
Example: address Matcher
You can create matchers for any type, including complex ones like address. Let's create a matcher to check if an address has a specific workchain.
import "@acton/testing/expect"
import "@acton/testing/assert"
import "@acton/fmt"
fun Expectation<address>.toHaveWorkchain(self, expectedWorkchain: int) {
val actualWorkchain = self.value.getWorkchain();
if (actualWorkchain != expectedWorkchain) {
Assert.fail(
format(
"Expected address to have workchain {}, but got {}",
expectedWorkchain,
actualWorkchain
),
self.location
);
}
}
get fun `test address workchain`() {
val myAddress = address("0:527964d55cfa6eb731f4bfc07e9d025098097ef8505519e853986279bd8400d8");
expect(myAddress).toHaveWorkchain(0);
}Example: string Matcher
Here is how you can write a matcher for a string to check if it has desired length.
import "@acton/testing/expect"
import "@acton/testing/assert"
import "@acton/fmt"
import "@stdlib/strings"
fun Expectation<string>.toHaveLength(self, expectedLen: int) {
if (self.value.calculateLength() != expectedLen) {
Assert.fail(
format(
"Expected string '{}' to have length '{}'",
self.value,
expectedLen
),
self.location
);
}
}
get fun `test string len`() {
expect("hello world").toHaveLength(11);
}Matchers for Custom Structs
You can create matchers for any custom type in your project including structs. This is a great way to build a domain-specific testing language that makes your tests more readable.
Let's say you have a User struct:
struct User {
id: int
name: string
isActive: bool
}Now, let's create a matcher to verify that a user is active.
import "@acton/testing/expect"
import "@acton/testing/assert"
import "@acton/fmt"
// Assume User struct is defined here or imported
fun Expectation<User>.toBeActive(self) {
if (!self.value.isActive) {
Assert.fail(
format("Expected user {} to be active", self.value.name),
self.location
);
}
}
get fun `test user status`() {
val activeUser = User { id: 1, name: "Alice", isActive: true };
val inactiveUser = User { id: 2, name: "Bob", isActive: false };
expect(activeUser).toBeActive();
// expect(inactiveUser).toBeActive(); // This would fail
}Leveraging Assert
Inside your custom matchers, you can use the Assert library to perform checks and produce descriptive error messages. This is the preferred way to report assertion failures.
The Assert.fail() function is particularly useful for providing custom error messages. It takes the error message and an optional location parameter, which is available as self.location in your matcher. The testing framework uses this location to point to the exact line in your test where the assertion failed.
fun Expectation<int>.toBePositive(self) {
if (self.value <= 0) {
Assert.fail(
format("Expected {} to be a positive number", self.value),
self.location // Pass the location for better error reporting
);
}
}By using Assert functions, your custom matchers will integrate seamlessly with the test runner, providing consistent and helpful output on failures.
Best Practices
Here are some tips to help you write effective and maintainable custom matchers.
Providing Clear Error Messages
A good matcher is only as good as its error message. When an assertion fails, the message should clearly explain what was expected and what was actually received.
Bad:
Assert.fail("Value is not even");Good:
Assert.fail(
format("Expected {} to be even, but it was odd.", self.value),
self.location
);Including the actual value in the error message helps developers debug failures much faster.
Organizing Your Matchers
As your test suite grows, you'll likely accumulate many custom matchers. Instead of scattering them across your test files, it's a good practice to group them in dedicated files.
For example, you could create a matchers.tolk file in your tests directory:
my-project/
├── contracts/
│ └── ...
├── tests/
│ ├── my_contract.test.tolk
│ └── matchers.tolk <-- Your custom matchers
└── Acton.tomlThen, you can import them in your test files where needed:
import "./matchers"
get fun `test my`() {
expect(42).toBeEven();
}This keeps your testing utilities organized and reusable across your entire project.
Last updated on