amiceli / vitest-cucumber

Use gherkin in your unit tests with vitest
https://vitest-cucumber.miceli.click
37 stars 5 forks source link

Proposition: Vite plugin #145

Open Odonno opened 5 days ago

Odonno commented 5 days ago

The proposition is to go beyond the current provided CLI and to offer a built-in Vite plugin to generate the js/ts file architecture from feature files, relatively similar to the TanStack Router vite plugin.

Implementation details

The idea is to provide a fairly straightforward solution that automatically generate feature/scenario/step definitions files and the underlying folder architecture. But file generation is only the starting point as the ideal solution would be to keep the architecture completely in sync on every feature file change:

Having a predictable file architecture would allow such complex workflow letting the developer purely focus on the changes to make in each step definition (and possibly in-between scenarii and steps).

Setup & configuration

A minimal vite config could look like this:

import { defineConfig } from 'vite';
import { CucumberVite } from 'cucumber-vite';

export default defineConfig({
  plugins: [
    CucumberVite(),
    // your preferred framework plugin here
  ],
});

The previous example will use the default configuration. A fully configurated vite config using the CucumberVite plugin could look like this:

import { defineConfig } from 'vite';
import { CucumberVite } from 'cucumber-vite';

export default defineConfig({
  plugins: [
    CucumberVite({
        enabled: false, // it is possible to completely disable syncing, if we need to make a big refactor change for example
        mode: "feature",
        featuresFolder: "./src/__features__",
        definitionsFolder: "./src/__features__", // feature & definitions files can be colocated or not in the same folder
        sharedDefinitionsFolder: "./src/__features__/shared", // for the refactoring part when a same step is used at least twice
    }),
    // your preferred framework plugin here
  ],
});

Modes

The goal is to craft the ideal solution which would be the full autopilot/full sync mode. However, it can be 1. easier to build in steps and it can be 2. interesting to offer a way to provide a progressive enhancement from nothing to the full syncing mode, for cases when integrating this plugin into a brownfield project is impossible or when the full-sync mode feels too "aggressive".

A set of 3 modes can be integrated and built upon in the solution. From simplest to more feature complete:

  1. feature level, only generate new scenario on file change
  2. scenario level, like 1. but it can also generate new steps in a scenario definition when new steps are added on file change
  3. full sync, the one depicted earlier which offers the best experience possible and syncing on every change

Question: quid of the renaming and the removal of file/scenario/step on feature and scenario levels?

new feature

Let's take the following Gherkin definition from an hypothetic feature file:

Feature: Improve my unit tests
  Scenario: Use vitest-cucumber in my unit tests
    Given Developer using feature file
    And   Using vitest-cucumber
    When  I run my unit tests
    Then  I know if I forgot a scenario

This can generate a new definition file like this:

import { loadFeature, describeFeature } from '@amiceli/vitest-cucumber';
import { expect } from 'vitest';

const feature = await loadFeature('./file.feature');

describeFeature(feature, ({ Scenario }) => {
  Scenario('Use vitest-cucumber in my unit tests', ({ Given, When, Then, And }) => {
    Given('Developer using feature file', (ctx) => {
      ctx.todo();
    });
    And('sing vitest-cucumber', (ctx) => {
      ctx.todo();
    });
    When('I run my unit tests', (ctx) => {
      ctx.todo();
    });
    Then('I know if I forgot a scenario', (ctx) => {
      ctx.todo();
    });
  });
});

refactoring

The refactoring is applied when and only when the same step is used at least twice, both the step text and the step definition code. This refactoring can happen whether a feature file reuse the same step across 2+ scenarii or whether a step already exist in a scenario of another feature file.

Let's start with a feature file that will trigger a refactoring:

Feature: Improve my unit tests
  Scenario: Use vitest-cucumber in my unit tests
    Given Developer using feature file
    And   Using vitest-cucumber
    When  I run my unit tests
    Then  I know if I forgot a scenario

  Scenario: Generate definition files using the ViteCucumber plugin
    Given Developer using feature file
    And   Using ViteCucumber plugin
    When  I create a new feature file
    Then  A new definition file is generated

This will generate both the definition file and the shared step definition file.

import { loadFeature, describeFeature } from '@amiceli/vitest-cucumber';
import { expect } from 'vitest';
import { givenDeveloperUsingFeatureFile } from './shared/givenDeveloperUsingFeatureFile';

const feature = await loadFeature('./file.feature');

describeFeature(feature, ({ Scenario }) => {
  Scenario('Use vitest-cucumber in my unit tests', ({ Given, When, Then, And }) => {
    givenDeveloperUsingFeatureFile(Given);
    And('sing vitest-cucumber', (ctx) => {
      ctx.todo();
    });
    When('I run my unit tests', (ctx) => {
      ctx.todo();
    });
    Then('I know if I forgot a scenario', (ctx) => {
      ctx.todo();
    });
  });

  // the second scenario will be here...
});

Note: the generated "shared step" will be named givenDeveloperUsingFeatureFile.ts

export const givenDeveloperUsingFeatureFile = (Given) => {
  Given('Developer using feature file', (ctx) => {
    ctx.todo();
  });
};

Note 2: we can imagine that the shared step naming convention can be configured globally via the configuration (either via defined presets "camelCase" | "pascalCase" | "kebabCase" | "snakeCase" and/or simply via a function (stepName: string) => string)

The decision of having a single step per file is based on previous experiences where having N+ shared steps in a single step would re-trigger more tests than expected on a single step change because the test runner is incapable to detect which step has been changed and so which import is triggering a change.

Again, by enabling the autopilot mode, this opinionated decision is a no-brainer as the "shared step" file will be created/removed automatically by the Vite cucumber plugin.

flowt-au commented 4 days ago

Thanks for all your great proposals. With regards to this one, an addition that would be great is to add a config option for a code fragment that pulls in custom setup code. I have a support file and some basic assignments that are at the top of each of my tests. It would be great to define all that in a file to include and to add that code fragment via the config filepath when generating a new test file. eg:

// All my custom setup code
import { graphDB, msRequest, msBroadcast, msReceive, loadFeature, describeFeature, setupServerForTests, checkValidReceivedMessage, isISO8601, toBoolean } from 'test/vitest/__tests__/helpers/startupTestBusinessLogicClasses.js'

const loopServerIn = inject('loopServerIn')
import { basetestdata } from 'test/vitest/__tests__/testData/baseData.js'

let forceLoopServerIn

const useServerForTest = (forceLoopServerIn !== null) ? forceLoopServerIn : loopServerIn

Thanks again for continuing the development of this great library. Murray

amiceli commented 4 days ago

Vite plugin is really a good idea but I will see that in another project like vite-cucumber-plugin.

It will use vitest-cucumber for CLI etc but all code to detect file changes and update tests/shared according changes, generate new tests etc, in another project.

I never code a vite plugin but I can check how it works, docs, examples etc and maybe scratch an alpha project.