Passing true null values from Gherkin

By default Gherkin is not able to pass an actual null value from a feature file to a step definition. The following sample allows you to do more than just that.

Passing a null value

Supports inverse test cases: what if a value is not provided. (At all, not even as an empty string)

Passing a UUID

Having the option to provide a guaranteed unique value always comes in handy.

Automatic application of prefix

Helps identify & automatically clean test objects and keeps repeating data out of feature files. The ~ MUST be the first character to be replaced with a prefix. If you need a prefix any where else, use [~]. This supports using prefixed names with leading whitespace characters.

How to use

When defining a scentence, use the {str} placeholder instead of the {string} place holder.

The code

Next to a Hooks step definition file, I also make use of a GenericSteps file. In this file is the following code:

private final Pattern prefixPattern = Pattern.compile("^~(.*)");
private final String prefix = "some_prefix";

@ParameterType("\"(?:\\\\\"|[^\"])*\"")
public String str(String str) {
    return this.stringType(str);
}

@DataTableType()
public String stringType(String cell) {
    // Return null for '[null]'
    if (cell == null || cell.contains("[null]")) {
        return null;
    }

    // Remove any surrounding double quotes: 
    // The entire variable as used in Gherkin, including quotes, is passed.
    cell = cell.replaceAll("^\"|\"$", "");

    // Replace any '[uuid]' with a UUID.
    cell = cell.replaceAll("(\\[uuid])", UUID.randomUUID().toString());

    // Apply test prefix
    Matcher matcher = this.prefixPattern.matcher(cell);

    if (matcher.find()) {
        cell = String.format("%s%s", this.prefix, matcher.group(1));
    }

    // When a `~` is needed as-is at the beginning of a string, it needs to be escaped.
    cell = cell.replace("\\~","~");
    // When a prefix insertion is needed somewhere else, use `[~]` as replacement token.
    // This comes in handy when, for example, needing to test with leading whitespace characters.
    cell = cell.replace("[~]", this.prefix);

    return cell;
}

Version Based Scenario Filtering

The idea is to filter scenario execution based on the version of the software under test (SUT) using something like @version(v>= 1.0 && v<=2.1.1).

Unfortunately the current Gherkin parser & highlighting of IntelliJ’s IDEA doesn’t support the use of spaces between the parantheses, not even for string literals:

Not using spaces fixes the problem for now.

To apply the check, extend the implementation of the Before hook:

@Before
public void before(Scenario scenario) {
    scenario.getSourceTagNames().stream()
        .filter(tag -> tag.startsWith("@version"))
        .findFirst()
        .ifPresent(tag -> VersionComparison.checkVersion(tag, properties.getProperty("version")));

    // ...The rest of your code

Pick your own method for determining the version of the SUT. Comparing the version expression is done using EvalEx.

The VersionComparison class:

package util;

import com.ezylang.evalex.Expression;
import com.ezylang.evalex.data.EvaluationValue;
import org.junit.jupiter.api.Assumptions;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class VersionComparison {

/*
* Compares the version required for a scenario with the version of the NH installation.
* A scenario can be tagged with '@version([EXPR])' where the expression CAN be:
*
* v [== | < | > | <= | >=] [MAJOR.MINOR.PATCH]
*
* For example:
* 'v >= 1.6.0' or 'v < 1.6.0'
*
* Multiple checks can be combined:
* 'v >=1.1.0 && v<1.4.0'
*
* The version numbers MUST consist of 3 elements.
* For info on SemVer/Semantic Versioning see https://semver.org/spec/v2.0.0.html
*
* When the version doesn't match the scenario is skipped.
*/
public static void checkVersion(String versionTag, String version) throws Exception {
String expressionFromTag = prepareExpression(versionTag, version);
Expression expression = new Expression(expressionFromTag);
EvaluationValue result;

try {
result = expression.evaluate();
} catch (Exception ex) {
throw new Exception(
String.format("Cannot evaluate expression '%s'", versionTag),
ex
);
}

// If the expression evaluates to 'False': the system under test does not have the correct version
if(!result.getBooleanValue()) {
Assumptions.assumeFalse(
true,
String.format(
"Incorrect version. Needed: '%s', actual: %s'",
versionTag, version
));
}
}

private static String prepareExpression(String tag, String version) throws Exception {
// Parse the tag to retrieve the expression
// Matches everything between (..)
Pattern tagPattern = Pattern.compile("\\((.*)\\)");
Matcher tagMatcher = tagPattern.matcher(tag);

if(!tagMatcher.find()){
throw new Exception(String.format("Cannot find expression in tag '%s'", tag));
}

String expressionFromTag = tagMatcher.group(0);

// Insert the version of the system under test.
expressionFromTag = expressionFromTag.replaceAll("v", version);

// Replace _all_ SemVer versions.
// Matches [MAJOR].[MINOR].[PATCH], for example `1.6.3` or `2.15.0`
String regex = "\\b\\d+\\.\\d+\\.\\d+\\b";
Pattern semVerPattern = Pattern.compile(regex);
Matcher semVerMatch = semVerPattern.matcher(expressionFromTag);

while (semVerMatch.find()) {
String tmp = semVerMatch.group(0);
// Conversion to a number is necessary, because the expression evaluator cannot
// handle/does not support a SemVer number as data type.
expressionFromTag = expressionFromTag.replaceAll(tmp, Long.toString(semVerToNumeric(tmp)));
}

return expressionFromTag;
}

/*
* Converts a semVer string to a comparable number:
* '1.6.1' becomes '1006001',
* '1.12.2' becomes '1012002'.
* Supports numbers up to 3 digits.
*/
private static long semVerToNumeric(String semVer) {
String[] parts = semVer.split("\\.");

long major = Integer.parseInt(parts[0]);
long minor = Integer.parseInt(parts[1]);
long patch = Integer.parseInt(parts[2]);

return (major * 1000000L) + (minor * 1000L) + patch;
}
}

Important notes

  • All versions used, MUST be full SemVer numbers including all three parts: MAJOR.MINOR.PATCH, even if their value is zero.
  • The expression parsing MIGHT be too basic to handle complex expressions.