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.
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.
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 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! đ„ł
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 â pass -
another, 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 message -
another, 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.
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.
rigoletto comes with several ready-made matchers - please, consult the subsections below for details.
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"]
}
};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"]
}
};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"]
}
};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"]
}
};The project name stems from the đ·exquisite Italian đ¶opera «Rigoletto» by Giuseppe Verdi - whose protagonist, Rigoletto, is a court đjester.
-
Vitest - Next Generation Testing Framework
-
TypeScript - JavaScript with syntax for types
