Skip to content

nfour/fermenter

Repository files navigation

CircleCInpmnpm

Fermenter

Fermenter is a tool for running tests written in the english-like language Gherkin.

It aims to be a function programming alternative to CucumberJS by doing away with things like global state and auto-discovery.

You use the same test runner you are used to: Jest, Mocha, Ava.

You also use the same expression parsers as CucumberJS:


Examples

Below is a very minimal example:

import { Feature } from 'fermenter';
import { sum } from 'lodash';

Feature('./features/calculator.feature', ({ Scenario }) => {
  Scenario('A simple addition test')
    .Given('I have numbers {int} and {int}', (state, num1, num2) => [num1, num2])
    .When('I add the numbers', sum)
    .Then('I get {int}', (summed, expectedResult) => {
      expect(summed).toBe(expectedResult);
    });
});

In the above example state is strongly typed throughout each step.

Below is a more realistic use of state with object spreading:

import { Feature } from 'fermenter';
import { sum } from 'lodash';

Feature('./features/calculator.feature', ({ Scenario }) => {
  Scenario('A simple addition test')
    .Given('I have numbers {int} and {int}', (state: {} = {}, num1, num2) => {
      return { ...state, numbers: [num1, num2] };
    })
    .When('I add the numbers', (state) => {
      return { ...state, summed: sum(state.numbers) };
    })
    .Then('I get {int}', ({ summed, numbers }, expectedResult) => {
      expect(numbers.length).toBe(2);
      expect(summed).toBe(expectedResult);
    });
});

The above test is explicitly mapped to the feature file'./features/calculator.feature':

Feature: Calculator
  Scenario: A simple addition test
    Given I have numbers 3 and 4
    When I add the numbers
    Then I get 7

To run this test we simply use our test runner (in this case Jest) as normal:

$> yarn jest

 PASS  src/tests/calculator.test.ts
  Feature: Calculator
    Scenario: A simple addition test
      ✓ Given: I have numbers 3 and 4
      ✓ When: I add the numbers
      ✓ Then: I get 7

The above output is generated by the default Jest runner, and thus will include any debug, diff, snapshotting and the error stack traces you expect.

Advanced example

Below is a more advanced example calculator.test.ts:

import { delay } from 'bluebird';
import { Feature } from 'fermenter';
import { getNumbers, addNumbers, checkResult, multiplyNumbers } from './steps';

Feature('./features/calculator.feature', ({ Scenario, Background, ScenarioOutline, AfterAll }) => {
  Background()
    .Given('I can calculate', () => {
      expect(Math).toBeTruthy();
    });

  Scenario('A simple addition test')
    .Given('I have numbers {int} and {int}', getNumbers)
    .When('I add the numbers', addNumbers)
    .Then.skip('I get {int}', checkResult) // This scenario step is skipped
    .And('I get {int}') // This scenario step is skipped because its missing a callback

  // This scenario and its steps will be skipped
  Scenario.skip('A simple multiplication test')
    .Given('I have numbers {int} and {int}', getNumbers)
    .When('I multiply the numbers', multiplyNumbers)
    .Then('I get {int}', checkResult);

  ScenarioOutline('A simple subtraction test')
    .Given('I have numbers {int} and {int}', getNumbers)
    .When.skip('I subtract the numbers', subtractNumbers)
    .Then('I get {int}', async (state, expectedResult) => { // Functions can be async too!
      await delay(5000);

      expect(state.result).toBe(expectedResult);
    });

  AfterAll(() => {
    console.log('Done!')
  })
});

The above example maps to: calculator.feature.

Api

The project bundles TypeScript definitions and so the library api is easy to discover.

For more examples, see the tests: src/tests

Scenarios and skipping steps

Feature('./test.feature', ({ Scenario }) => {
  Scenario('The scenario')
    .Given('I can do stuff')

  Scenario.skip('The scenario') // Scenarios can be referred to more than once
    .Given('I can do stuff') // All steps are skipped due to .skip

  Scenario('Another scenario')
    .Then('I can do stuff') // Skipped: the callback is missing
    .And.skip('I can do stuff') // Skipped: because of .skip
    .And('I can do stuff', () => {}) // Not skipped!
})

Each of the above Scenario is executed with its own state initial state.

The state provided to the Scenario defaults to {}, an empty object unless specified otherwise by a Background. More on that later.

You may also choose one scenario to run with only:

Feature('./test.feature', ({ Scenario }) => {
  Scenario('The scenario')
    .Given('I can do stuff', () => {}) // Skipped!

  Scenario.only('The scenario')
    .Given('I can do stuff', () => {}) // Not skipped!

  Scenario('Another scenario')
    .Given('I can do stuff', () => {}) // Skipped!
})

Skipping behaviour:

  • When a individual step is skipped:
    • The skipped function is replaced with (v) => v. A state passthrough function.
    • Steps after it are NOT skipped.
  • When an entire Scenario is skipped:
    • All steps are skipped

Background

Backgrounds are used to define common state/preparation between scenarios.

Building on top of the previous example, lets add a background to supply some initial state to the Scenario.

import { Feature } from 'fermenter';
import { sum } from 'lodash';

Feature('./features/calculator.feature', ({ Scenario, Background }) => {
  Background()
    .Given('I start with {int}', (state, initialNum) => initialNum)

  /** We have to tell TypeScript what the background return type is */
  Scenario<number>('A simple addition test')
    .Given('I have numbers {int} and {int}', (initialNum, num1, num2) => [initialNum, num1, num2])
    .When('I add the numbers', sum)
    .Then('I get {int}', (summed, expectedResult) => {
      expect(summed).toBe(expectedResult);
    });
});

Some things to note:

  • Backgrounds do not require a name
    • Background() will match all Backgrounds in the feature file.
    • Background('My background') will match only 'My background'
  • Backgrounds only support Given() and Given().And().And() steps
  • The state returned from the last step of a Background will supply all Scenarios in the Feature
  • Scenario can be provided an initial state generic

Tables

To use tables defined in your Gherkin, do this:

import { Feature, ITable } from 'fermenter';

Feature('./features/calculator.feature', ({ Scenario, }) => {
  Scenario('A simple addition test')
    .Given('I have the following numbers:', (state = {}, table: IGherkinTableParam) => {
      const [{ a, b }] = table.rows.mapByTop();

      return {
        ...state,
        a: parseInt(a, 10),
        b: parseInt(b, 10),
      };
    });
});

Scenario Outlines

Scenario Outlines function just like Scenarios, but are run for each provided example in the .feature.

Feature('./test.feature', ({ ScenarioOutline }) => {
  ScenarioOutline('My outline')
    .When('foo is {string}')
});

See the Scenario section for more info.

Typescript tips

The above is a simple example. Your Background state type will likely be quite large and you shouldnt have to manually define a type! We can use features from TS 3.0 to help here, and some types included in Fermenter.

This time, lets infer the type of the background:

import { Feature, AsyncReturnType } from 'fermenter';
import { delay } from 'bluebird';
import { sum } from 'lodash';

/** Lets also make this function async! */
async function initialNumberStep (state: undefined, initialNum: number) {
  await delay(500);

  return initialNum;
};

Feature('./features/calculator.feature', ({ Scenario, Background }) => {
  Background()
    .Given('I start with {int}', initialNumberStep)

  /** We have to tell TypeScript what the background return type is */
  Scenario<AsyncReturnType<typeof initialNumberStep>>('A simple addition test')
    .Given('I have numbers {int} and {int}', (initialNum, num1, num2) => [initialNum, num1, num2])
    .When('I add the numbers', sum)
    .Then('I get {int}', (summed, expectedResult) => {
      expect(summed).toBe(expectedResult);
    });
});

Yay, we didn't have to define any plumbing types!

Notes:

  • The first Scenario's Given will be run after the Background is complete
  • Using AsyncReturnType allows one to retrieve the promisified (or not) return value of any function

Global hooks

You may utilize this global hook to instrument or alter your steps and their state:

import { globallyBeforeEachStep } from 'fermenter';

globallyBeforeEachStep((step, state) => {
  console.log({
    stepName: step.name,
    scenarioName: step.definition.name,
    featureName: step.definition.feature.name,
    incomingState: state,
  });

  return state; // You can change this
});

Using other test runners

To set your own test runner, pass its test methods when configuring a feature:

Feature({
  feature: '...',
  methods: { test, afterAll, beforeAll, describe }
}, () => {})

// or

/** Here we wrap `Feature` and give it the global variables mocha provides as test methods */
export const MochaFeature = (...args: Parameters<typeof Feature>) =>
  Feature(
    { methods: { test, describe, afterAll: after, beforeAll: before  }, ...args[0] },
    args[1]
  )

The framework has been tested in Mocha, Jest and Cypress but is expected to work with any which satisfy the test runner method interfaces.

How it works

Coming from CucumberJS

If you're coming from CucumberJS then some functionality is carried over:

  • Same gherkin parser
  • Same expression parser

How it runs

  • Scenarios and ScenarioOutlines are executed with fresh state
    • There is no this
    • state is reduced with each step function
    • Strong TypeScript support for step state
      • Steps will inherit the state type of the previous step return value
      • You shouldn't need to manually define types for step functions used inline
  • Step names are no longer restricted to be unique for every feature file.
    • To reuse a step, simply reuse the function itself
  • Tests serve as a composition root. No magic happens inside this library.
  • Tests are executed by your test runner, which defaults to Jest
  • Steps are executed in synchronous order.
  • Background steps are executed before Scenario steps
  • Features can be run asynchronously depending on your runner (as they are file-separated)
    • Each Scenario will also be run synchronously after another for a given Feature() definition
      • This is the default in Jest
    • It is possible to define multiple Feature() calls to the same .feature file within many .test.ts files, which can allow the same feature to be run in parallel inside Jest for example.

More info