@giancosta86/rigoletto v1.0.1
rigoletto
Elegant matchers in TypeScript for Vitest

Elegance in software development is the result of several aspects - primarily expressiveness and minimalism - not only in the main codebase of a project, but in its tests as well.
Consequently, in modern test frameworks like Vitest, reusing test logic via declarative custom matchers - such as expect(myShape).toBeConvex() - seems a very effective option... but alas, these constructs are not always perceived as easy to create, let alone to test extensively.
As a result, rigoletto focuses on:
the creation and testing of custom matchers for Vitest, via a minimalist TypeScript programming interface.
providing various sets of ready-made matchers - especially for vanilla TypeScript as well as NodeJS.
as a plus, exporting configuration files to easily reference jest-extended in Vitest-based tests.
This guide will now briefly explain what rigoletto can bring to your project.
Installation
The package on NPM is:
@giancosta86/rigoletto
The public API entirely resides in multiple subpackages:
@giancosta86/rigoletto/creation: utilities for defining new matchers.@giancosta86/rigoletto/jest-extended: ready-made jest-extended declarations and registrations.@giancosta86/rigoletto/matchers/all: all the custom matchers provided by Rigoletto.@giancosta86/rigoletto/matchers/nodejs: a gallery of matchers for NodeJS.@giancosta86/rigoletto/matchers/vanilla: a gallery of matchers for any JavaScript VM.@giancosta86/rigoletto/testing: utilities for testing new matchers using fluent notation.
Each subpackage should be referenced via its name, with no references to its modules.
Defining your own matchers
Creating a basic synchronous matcher
The most straightforward way to create a matcher function is implementBooleanMatcher(), from @giancosta86/rigoletto/creation, designed for matchers that simply check a boolean condition - that is, a vast majority.
More precisely, let's create a new matcher step by step:
Define the matcher function:
import type { ExpectationResult, MatcherState } from "@vitest/expect"; export function toBeEven( this: MatcherState, subject: number ): ExpectationResult { //Implementation here }Add the implementation just by returning a call to
implementBooleanMatcher()import type { ExpectationResult, MatcherState } from "@vitest/expect"; import { implementBooleanMatcher } from "@giancosta86/rigoletto"; export function toBeEven( this: MatcherState, subject: number ): ExpectationResult { return implementBooleanMatcher({ matcherState: this, assertionCondition: subject % 2 == 0, errorWhenAssertionFails: `${subject} is odd!`, errorWhenNegationFails: `Unexpected even number: ${subject}` }); }
To plug the matcher into Vitest - especially when using TypeScript - you'll need to:
Declare the TypeScript extensions:
import "vitest"; interface MyMatchers { toBeEven: () => void; } declare module "vitest" { interface Assertion<T = any> extends MyMatchers {} interface AsymmetricMatchersContaining extends MyMatchers {} }Register the matcher into
expect(), to make it available at runtime:import { expect } from "vitest"; expect.extend({ toBeEven });
Should you need a more sophisticated example regarding synchronous matchers - using the general-purpose implementMatcher() function -
please refer to the toThrowClass matcher.
Creating an asynchronous matcher
Creating an asynchronous matcher is equally easy - in the case of implementBooleanMatcher() just pass a Promise<boolean> as its condition.
For example, let's walk through the implementation of the toExistInFileSystem() matcher - already provided by rigoletto:
Define the matcher function:
import type { ExpectationResult, MatcherState } from "@vitest/expect"; export function toExistInFileSystem( this: MatcherState, subjectPath: string ): ExpectationResult { //Implementation goes here }Define or import an
asyncfunction - or any other way to obtain aPromise:async function pathExists(path: string): Promise<boolean> { //Implementation here }Add the matcher implementation just by returning a call to
implementBooleanMatcher()- passing thePromiseas its assertion condition:import type { ExpectationResult, MatcherState } from "@vitest/expect"; import { implementBooleanMatcher } from "@giancosta86/rigoletto"; export function toExistInFileSystem( this: MatcherState, subjectPath: string ): ExpectationResult { return implementBooleanMatcher({ matcherState: this, assertionCondition: pathExists(subjectPath), errorWhenAssertionFails: `Missing file system entry: '${subjectPath}'`, errorWhenNegationFails: `Unexpected file system entry: '${subjectPath}'` }); }
And that's all! As you can notice, the result type of the matcher is always ExpectationResult - no matter whether it is synchronous or asynchronous.
The general-purpose implementMatcher() function also supports Promise in its flows - in particular, you can merely declare async functions among its inputs.
Once a matcher has been implemented, let's test it - because rigoletto supports that, too! 🥳
Testing matchers
The idea at the core of rigoletto's testing API - provided by @giancosta86/rigoletto/testing - resides in the fact that, given a scenario (for example, «when the input is an even number»), a matcher should ✅succeed(/❌fail) - and, conversely, its negation should ❌fail(/✅succeed).
To avoid code duplication, you can use the scenario() function - structurally equivalent to describe() - and its fluent notation; for example, in the case of the toBeEven() matcher declared previously, we could test this scenario:
import { scenario } from "@giancosta86/rigoletto/testing";
//We can build an arbitrary test structure
//using describe(), as usual
describe("toBeEven()", () => {
describe("in its most basic form", () => {
scenario("when applied to an even number")
.subject(8)
.passes(e => e.toBeEven())
.withErrorWhenNegated("Unexpected even number: 8");
});
});The above scenario(), followed by ✅.pass(), actually expands into a describe() call with the same description, containing 2 tests:
one, containing
expect(8).toBeEven(), which is expected to ✅passanother, containing
expect(8).not.toBeEven(), which is expected to ❌fail with the given error message
You can use as many scenarios as you wish - for example:
scenario("when applied to an odd number")
.subject(13)
.fails(e => e.toBeEven())
.withError("13 is odd!");In this case, scenario() followed by ❌.fail() expands into the following tests:
one, containing
expect(13).toBeEven(), which is expected to ❌fail with the given error messageanother, containing
expect(13).not.toBeEven(), which is expected to ✅pass
It is interesting to note that scenario() transparently supports both synchronous and asynchronous matchers, with the very same notation.
Important note
When defining a scenario via the scenario() function, you must never use .not inside a .passes() or .fails() call: use the opposite function instead.
For example, in lieu of testing like this:
scenario("when applied to an odd number")
.subject(13)
.passes(e => e.not.toBeEven()) //WRONG!!! USE .fails() INSTEAD!
.withError("13 is odd!");use .fails(e => e.toBeEven()), as previously seen.
The gallery of matchers
rigoletto comes with several ready-made matchers - please, consult the subsections below for details.
Vanilla matchers
This is a gallery of matchers that can be called within any JavaScript VM supported by Vitest.
To use them, add this import to some .d.ts file referenced by tsconfig.json:
import "@giancosta86/rigoletto/matchers/vanilla";In Vitest's configuration file, the following item must be included:
const config: ViteUserConfig = {
test: {
setupFiles: ["@giancosta86/rigoletto/matchers/vanilla"]
}
};NodeJS matchers
This is a gallery of matchers specifically designed for the NodeJS environment.
To use them, add this import to some .d.ts file referenced by tsconfig.json:
import "@giancosta86/rigoletto/matchers/nodejs";In Vitest's configuration file, the following item must be included:
const config: ViteUserConfig = {
test: {
setupFiles: ["@giancosta86/rigoletto/matchers/nodejs"]
}
};Importing all matchers
This will import all the Rigoletto matchers described in the previous subsections - therefore, all the related requirements apply.
To reference them, add this import to some .d.ts file referenced by tsconfig.json:
import "@giancosta86/rigoletto/matchers/all";In Vitest's configuration file, the following item must be included:
const config: ViteUserConfig = {
test: {
setupFiles: ["@giancosta86/rigoletto/matchers/all"]
}
};Using jest-extended
Rigoletto comes with support for jest-extended, simplifying its integration into test projects.
For TypeScript, just add the following import to some .d.ts file referenced by tsconfig.json:
import "@giancosta86/rigoletto/jest-extended";In Vitest's configuration file, the following item must be included:
const config: ViteUserConfig = {
test: {
setupFiles: ["@giancosta86/rigoletto/jest-extended"]
}
};Trivia
The project name stems from the 🌷exquisite Italian 🎶opera «Rigoletto» by Giuseppe Verdi - whose protagonist, Rigoletto, is a court 🃏jester.
Further references
Vitest - Next Generation Testing Framework
TypeScript - JavaScript with syntax for types