1.0.1 • Published 10 months ago

@giancosta86/rigoletto v1.0.1

Weekly downloads
-
License
MIT
Repository
github
Last release
10 months ago

rigoletto

Elegant matchers in TypeScript for Vitest

NPM Version

Logo

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:

  1. the creation and testing of custom matchers for Vitest, via a minimalist TypeScript programming interface.

  2. providing various sets of ready-made matchers - especially for vanilla TypeScript as well as NodeJS.

  3. 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:

  1. Define the matcher function:

    import type { ExpectationResult, MatcherState } from "@vitest/expect";
    
    export function toBeEven(
      this: MatcherState,
      subject: number
    ): ExpectationResult {
      //Implementation here
    }
  2. 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:

  1. Declare the TypeScript extensions:

    import "vitest";
    
    interface MyMatchers {
      toBeEven: () => void;
    }
    
    declare module "vitest" {
      interface Assertion<T = any> extends MyMatchers {}
      interface AsymmetricMatchersContaining extends MyMatchers {}
    }
  2. 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:

  1. Define the matcher function:

    import type { ExpectationResult, MatcherState } from "@vitest/expect";
    
    export function toExistInFileSystem(
      this: MatcherState,
      subjectPath: string
    ): ExpectationResult {
      //Implementation goes here
    }
  2. Define or import an async function - or any other way to obtain a Promise:

    async function pathExists(path: string): Promise<boolean> {
      //Implementation here
    }
  3. 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! 🥳

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 ✅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.

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

1.0.1

10 months ago

1.0.0

10 months ago