ANN vitest-bdd: ReScript testing with vitest (and source maps !)

Hi there !

:test_tube: vitest-bdd

A testing solution based on vitest that plays nice with ReScript.

I was finally annoyed at not having source maps and a simple testing solution for ReScript.

This plugin was initially created to support Gherkins tests with step definitions in ReScript or TypeScript but I thought that adding support for unit tests was a nice addition.

And then… I added source maps ! to the test files by retro-pedaling the compiled sources.

Usage

pnpm add -D vitest-bdd
import { vitestBdd } from "vitest-bdd";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [ vitestBdd() ],
  test: {
    pool: "threads",
    include: ["**/*.feature", "**/*.md", "**/*.spec.ts", "**/*_test.res"],
  },
});

Make sure to include the *.res file, not the compiled source.

If you want to keep compiling out of source, you can add your own resolver function for the compiled source in the options:

export type VitestBddOptions = { // defaults
  debug?: boolean;               // false
  markdownExtensions?: string[]; // [".md", ".mdx", ".markdown"]
  gherkinExtensions?: string[];  // [".feature"]
  rescriptExtensions?: string[]; // [".res"]
  stepsResolver?: (path: string) => string | null;
  resCompiledResolver?: (path: string) => string | null;
};

More information and a working test app on github (please star the project if you like it :slight_smile: ).

Feedback and experiences welcome !

PS: You can use “vitest” without “vite” (I use it in my react-native expo project)

4 Likes

Oh awesome! I have a couple projects I want to add unit tests to, so this is perfect timing.

Awesome !

Let me know how it goes.

Maybe also explore the Gherkins way. I found them super useful for “behavior” testing (like a complicated, multi-stage input). I use a mix of unit and feature tests and am super happy because they all end up in the same test infrastructure.

Example:

# test/TextEntry.feature

Feature: TextEntry

  Scenario: enter a single int
    Given template "I am [int] years old"
    When I type 4
    And I type 9
    And I am done
    Then I should receive "I am 49 years old"

  Scenario: enter fractions
    Given template "x = \frac{[int]}{[int]}"
    When I type 4
    And I type 9
    And I am done
    And I type 7
    And I type 4
    And I am done
    Then I should receive "x = \frac{49}{74}"

  Scenario: editing state
    Given template "x = \frac{[int]}{[int]}"
    When I type 4
    Then display should be "x = \frac{{\color{pink}4}}{{\color{violet}?}}"
open VitestBdd
given("template {string}", ({step}, str: string) => {
  let entry = TextEntry.make(str)
  step("I type {number}", (nb: int) => {
    entry.append(nb->Int.toString)
  })
  step("I am done", () => {
    entry.next()
  })
  step("I should receive {string}", (str: string) => {
    expect(entry.text).toEqual(str)
  })
  step("display should be {string}", (str: string) => {
    expect(entry.display).toEqual(str)
  })
})

Cool VS Code extensions for vitest and gherkins:

  • Vitest extension (for proper test integration)
  • Cucumber extension (for nice colors)
1 Like

Wow this is awesome! How did you support source maps? Could you give some pointers?

The full sources of the “compiler” is here.

The basic idea is that I parse each lines of the original rescript code and the compiled code looking for corresponding tests. It is done in a way that multiple tests can have the same string.

const TEST_RE = /(describe|it|test|only|skip|todo)\(("([^"]*)")/;

And then I interploate corresponding line numbers between the two files:


const map = new SourceMapGenerator({ file: path });

// rescript line, compiled line
function sm(rline: number, cline: number) {
  map.addMapping({
    source: path,
    generated: { line: cline, column: 0 },
    original: { line: rline, column: 0 },
  });
}
let ri = 1;
let ci = 1;

for (const [rline, cline] of smap) {
  let dr = rline - ri;
  let dc = cline - ci;
  if (dr > dc) {
    for (let i = 1; i <= dr; i++) {
      sm(ri + i, Math.ceil(ci + (i * dc) / dr));
    }
  } else {
    for (let i = 1; i <= dc; i++) {
      sm(Math.ceil(ri + (i * dr) / dc), ci + i);
    }
  }
  ri = rline;
  ci = cline;
}

The smap array is built by first parsing rescript and then matching the lines in the compiled source.

At the end, I send the compiled JS sources with the source maps to vitest.

2 Likes