Skip to content

Commit

Permalink
feat: add basic done callback compatibility in steps
Browse files Browse the repository at this point in the history
  • Loading branch information
ychavoya committed May 14, 2024
1 parent fb5b2a2 commit 91dc81c
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 16 deletions.
57 changes: 50 additions & 7 deletions docs/AsynchronousSteps.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,38 @@ defineFeature(feature, test => {
});
```

Jest Cucumber does not support callbacks, but when steps are running asynchronous code that uses callbacks, the simplest solution is to wrap that code in a promise:
## Callbacks

Jest Cucumber also supports callbacks for asynchronous tasks. If an additional callback parameter is defined on the step function, it will wait for that callback to be called to continue with the next step:

```javascript
defineFeature(feature, test => {
test('Adding a todo', ({ given, when, then }) => {
...

when('I save my changes', () => {
return new Promise((resolve) => {
todo.saveChanges(() => {
console.log('Changes saved');
resolve();
});
when('I save my changes', (done) => {
todo.saveChanges(() => {
console.log('Changes saved');
done(); // When the callback is called, the step will end
});
});

...
});
});
```

The callback should always be the last parameter:

```javascript
defineFeature(feature, test => {
test('Adding a todo', ({ given, when, then }) => {
...

when(/I save my changes with name (\w+)/, (name, done) => {
todo.saveChanges(() => {
console.log(`Changes saved with name ${name}`);
done();
});
});

Expand All @@ -54,6 +73,30 @@ defineFeature(feature, test => {
});
```

Like in jest, if an argument is passed to the callback function, it will fail the test:

```javascript
defineFeature(feature, test => {
test('Adding a todo', ({ given, when, then }) => {
...

when(/I save my changes with name (\w+)/, (name, done) => {
todo.saveChanges((error) => {
if (error) {
done(error); // the test will fail if called with an argument
}
console.log(`Changes saved with name ${name}`);
done(); // the test will succeed if no argument is passed
});
});

...
});
});
```

## Timeout

You can also control how long Jest will wait for asynchronous operations before failing your test by passing a timeout (milliseconds) into your test:

```javascript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ Feature: Using latest Gherkin keywords
Examples: Unsuccessful division

| firstOperand | secondOperand | output |
| 4 | 0 | undefined |
| 4 | 0 | Infinity |
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ defineFeature(feature, test => {
};

const thenTheOutputOfXShouldBeDisplayed = (then: DefineStepFunction) => {
then(/^the output of "(\d+)" should be displayed$/, (expectedOutput: string) => {
then(/^the output of "(\w+)" should be displayed$/, (expectedOutput: string) => {
if (!expectedOutput) {
expect(output).toBeFalsy();
} else {
Expand Down
2 changes: 1 addition & 1 deletion examples/typescript/src/calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class Calculator {
}

public computeOutput() {
if (!this.firstOperand || !this.secondOperand || !this.operator) {
if (this.firstOperand === null || this.secondOperand === null) {
return null;
}

Expand Down
16 changes: 15 additions & 1 deletion specs/features/steps/step-execution.feature
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,18 @@ Feature: Step definitions
When I execute that scenario in Jest Cucumber
Then I should see the error that occurred
And I should see which step failed
And no more steps should execute
And no more steps should execute

Rule: When a step function provides a done callback, then the next step should not be executed until the callback is called

Scenario: Scenario with a done callback step
Given a scenario with a done callback step
When I execute that scenario in Jest Cucumber
Then the next step should not execute until the done callback is called

Scenario: Scenario with a failing done callback step
Given a scenario with a failing done callback step
When I execute that scenario in Jest Cucumber
Then I should see the error that occurred
And I should see which step failed
And no more steps should execute
36 changes: 36 additions & 0 deletions specs/step-definitions/step-execution.steps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { loadFeature, defineFeature, DefineStepFunction } from '../../src';
import {
asyncStep,
doneCallbackStep,
failingAsyncStep,
failingDoneCallbackStep,
failingSynchronousStep,
featureToExecute,
synchronousSteps,
Expand Down Expand Up @@ -116,4 +118,38 @@ defineFeature(feature, test => {
expect(logs[1]).toBe('when step started');
});
});

test('Scenario with a done callback step', ({ given, when, then }) => {
given('a scenario with a done callback step', () => {
featureFile = featureToExecute;
stepDefinitions = doneCallbackStep(logs);
});

whenIExecuteThatScenarioInJestCucumber(when);

then('the next step should not execute until the done callback is called', () => {
expect(logs).toHaveLength(4);
expect(logs[0]).toBe('given step');
expect(logs[1]).toBe('when step started');
expect(logs[2]).toBe('when step completed');
expect(logs[3]).toBe('then step');
});
});

test('Scenario with a failing done callback step', ({ given, when, then, and }) => {
given('a scenario with a failing done callback step', () => {
featureFile = featureToExecute;
stepDefinitions = failingDoneCallbackStep(logs);
});

whenIExecuteThatScenarioInJestCucumber(when);
thenIShouldSeeTheErrorThatOccurred(then);
andIShouldSeeWhichStepFailed(and);

and('no more steps should execute', () => {
expect(logs).toHaveLength(2);
expect(logs[0]).toBe('given step');
expect(logs[1]).toBe('when step started');
});
});
});
47 changes: 47 additions & 0 deletions specs/test-data/step-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,50 @@ export const failingAsyncStep = (logs: string[]): MockStepDefinitions => {
});
};
};

export const doneCallbackStep = (logs: string[]): MockStepDefinitions => {
return (mockFeature: ParsedFeature, defineMockFeature: DefineFeatureFunction) => {
defineMockFeature(mockFeature, test => {
test('Executing a scenario', ({ given, when, then }) => {
given('a given step', () => {
logs.push('given step');
});

when('i run the when step', done => {
logs.push('when step started');
setTimeout(() => {
logs.push('when step completed');
done();
}, 5);
});

then('it should run the then step', () => {
logs.push('then step');
});
});
});
};
};

export const failingDoneCallbackStep = (logs: string[]): MockStepDefinitions => {
return (mockFeature: ParsedFeature, defineMockFeature: DefineFeatureFunction) => {
defineMockFeature(mockFeature, test => {
test('Executing a scenario', ({ given, when, then }) => {
given('a given step', () => {
logs.push('given step');
});

when('i run the when step', done => {
logs.push('when step started');
setTimeout(() => {
done('when step failure');
}, 5);
});

then('it should run the then step', () => {
logs.push('then step');
});
});
});
};
};
18 changes: 17 additions & 1 deletion src/feature-definition-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,25 @@ export const createDefineFeature = (): DefineFeatureFunction => {
args.push(stepArgument as string);
}

const hasDoneCallback = nextStep.stepFunction.length > args.length;

return promiseChain.then(() => {
return Promise.resolve()
.then(() => nextStep.stepFunction(...args))
.then(async () => {
if (hasDoneCallback) {
return new Promise<void>((resolve, reject) => {
const doneFunction = (reason?: Error | string) => {
if (reason) {
reject(typeof reason === 'string' ? new Error(reason) : reason);
} else {
resolve();
}
};
nextStep.stepFunction(...args, doneFunction);
});
}
return nextStep.stepFunction(...args);
})
.catch(error => {
const formattedError = error;
formattedError.message = `Failing step: "${parsedStep.stepText}"\n\nStep arguments: ${JSON.stringify(args)}\n\nError: ${error.message}`;
Expand Down
4 changes: 3 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ export type FeatureFromStepDefinitions = {
scenarios: ScenarioFromStepDefinitions[];
};

export type ParsedStepArgument = (string | []) | null;

export type ParsedStep = {
keyword: string;
stepText: string;
stepArgument: string | NonNullable<unknown>;
stepArgument: ParsedStepArgument;
lineNumber: number;
};

Expand Down
6 changes: 3 additions & 3 deletions src/parsed-feature-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Parser, AstBuilder, Dialect, dialects, GherkinClassicTokenMatcher } fro
import { v4 as uuidv4 } from 'uuid';

import { getJestCucumberConfiguration, Options } from './configuration';
import { ParsedFeature, ParsedScenario, ParsedStep, ParsedScenarioOutline } from './models';
import { ParsedFeature, ParsedScenario, ParsedStep, ParsedScenarioOutline, ParsedStepArgument } from './models';

const parseDataTableRow = (astDataTableRow: any) => {
return astDataTableRow.cells.map((col: any) => col.value) as string[];
Expand Down Expand Up @@ -85,7 +85,7 @@ const parseScenarioOutlineExampleSteps = (exampleTableRow: any, scenarioSteps: P
return processedStepText.replace(new RegExp(`<${nextTableColumn}>`, 'g'), exampleTableRow[nextTableColumn]);
}, scenarioStep.stepText);

let stepArgument: string | NonNullable<unknown> = '';
let stepArgument: ParsedStepArgument = null;

if (scenarioStep.stepArgument) {
if (Array.isArray(scenarioStep.stepArgument)) {
Expand All @@ -106,7 +106,7 @@ const parseScenarioOutlineExampleSteps = (exampleTableRow: any, scenarioSteps: P
} else {
stepArgument = scenarioStep.stepArgument;

if (typeof scenarioStep.stepArgument === 'string' || scenarioStep.stepArgument instanceof String) {
if (typeof scenarioStep.stepArgument === 'string') {
Object.keys(exampleTableRow).forEach(nextTableColumn => {
stepArgument = (stepArgument as string).replace(
new RegExp(`<${nextTableColumn}>`, 'g'),
Expand Down

0 comments on commit 91dc81c

Please sign in to comment.