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 async
function - or any other way to obtain a Promise
:
async function pathExists(path: string): Promise<boolean> {
//Implementation here
}
Add the matcher implementation just by returning a call to implementBooleanMatcher()
-
passing the Promise
as 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