Implementing Step Definitions
A step definition is a class that that provides the implementation for a step. Every step in the step specs must have a step definition. Whenever Cycle executes a step provided by a plugin, it sends an API request to the plugin, which internally executes the step definition for the given step. The Java Plugin SDK provides an interface named StepDefinition for all step definition classes, as well as other supporting classes for context objects and step inputs.
Project conventions
In a Java plugin project, step definition classes should be placed under the steps subpackage. (The full path is located at src/main/java/<parent-package-name>/<plugin-name>/steps.) Each step definition class's name should include the ID of the step it implements plus the suffix "Step". For example, if the step ID from the steps.yml file is helloWorld, then the step definition class should be named HelloWorldStep.
Step examples
A step definition class has a rigid but conventional structure. Each part enables it to accept inputs, perform its duties, and yield results. The best way to explain how step definitions work is through a series of concrete examples that progressively illustrates concepts.
Performing a basic task
Consider a basic step that prints "Hello World!" to the console. Its step spec would look like this:
- id: helloWorld
description: 'Prints "Hello World!" to the console.'
stepText: 'I print Hello World'
examples: ['I print Hello World']
Its step definition class would look like this:
// Note: import statements are required but not included in this example.
// Every step definition class needs a `StepIdentifier` annotation to link it to
// the step spec it implements. Note that `StepIdentifier` may be given multiple
// step IDs if the step definition may be applied to more than one step.
@StepIdentifier("helloWorld")
// Every step definition class must also bear the `Component` annotation so that
// Spring Boot will automatically instantiate the class and treat it as a bean,
// which in turn enables the Java Plugin SDK code to discover it and call it
// when needed.
@Component
// This is the class signature. Every step definition class must implement the
// `StepDefinition` interface from the Java Plugin SDK.
public class HelloWorldStep implements StepDefinition {
// This is the method that must be implemented to execute the step's logic.
@Override
public StepResponse call(SuiteContext suiteContext, ScenarioContext scenarioContext, StepInputs inputs) {
// This is the main logic for this step.
// It prints "Hello World!" to the console.
System.out.println("Hello World!");
// This is the response object for the step.
// Every step definition's `call` method must return a `StepResponse`
// object that reflects the step's result, which Cycle will report.
return new StepResponse()
.status(ExecutionStatus.PASS) // a passing result
.message("Printed \"Hello World!\""); // a summary for the result
}
}
This is the bare minimum a step definition class would need to function. This step does not use any context objects or step inputs: it merely performs a task and yields a result. If the call method raises an unhandled exception, the plugin will catch it and yield a failing response for the step.
Accepting an input parameter
It is very common for a step to accept input parameters. For example, suppose a variation of the "hello world" step accepted a name to greet. The step spec would look like this:
steps:
- id: helloName
description: 'Prints a greeting for the named person to the console.'
stepText: 'I print Hello {name}'
examples: ['I print Hello "Andy"']
parameters:
- name: name
type: string
description: The name of the person to greet.
A step definition accesses input parameters through the call method's StepInputs object, which enables the method to parse parameters by name according to the types given in the step spec.
@StepIdentifier("helloName")
@Component
public class HelloNameStep implements StepDefinition {
@Override
public StepResponse call(SuiteContext suiteContext, ScenarioContext scenarioContext, StepInputs inputs) {
// This parses the "name" parameter as a string value.
String name = inputs.getParameterAsString("name");
String message = "Hello, " + name + "!";
// Then, this prints the desired message and returns a passing result.
System.out.println(message);
return new StepResponse()
.status(ExecutionStatus.PASS)
.message("Printed \"" + message + "\"");
}
}
Setting scenario context
Sometimes, steps need to share context (or data/object) between them. It is common for one step to set up an object while a subsequent step uses that object. Consider a counter: an object that accumulates an incremental value. We could write steps to create and increment a counter. Let's start with a step to set/reset a counter object:
- id: resetCounter
description: 'Resets the counter to zero.'
stepText: 'I reset the counter'
examples: ['I reset the counter']
Its step definition will need to create and store the counter object in such a way that other steps later in the scenario can access it. The correct way to do this is with the ScenarioContext object. A scenario context object stores objects for the duration of the current scenario. All steps receive a reference to this context object when they are called. If a step adds something to the context, then a later step can retrieve it and even modify it. The step definition for resetCounter would look like this:
@StepIdentifier("resetCounter")
@Component
public class ResetCounterStep implements StepDefinition {
@Override
public StepResponse call(SuiteContext suiteContext, ScenarioContext scenarioContext, StepInputs inputs) {
// Set/reset the counter to zero in the scenario context object.
// `CounterConstants.KEY_COUNTER` is a constant from another file.
// Its value is "Counter".
scenarioContext.getScenarioState().set(CounterConstants.KEY_COUNTER, 0);
// Return a passing result.
return new StepResponse()
.status(ExecutionStatus.PASS)
.message("Reset the counter to zero.");
}
}
Accessing scenario context
Once the counter has been set (or reset), steps can be used to increment the counter:
steps:
- id: incrementCounter
description: 'Adds the given number to the counter.'
stepText: 'I add {increment} to the counter'
examples: ['I add 3 to the counter']
parameters:
- name: increment
type: number
description: The number to add to the counter.
The step definition must get the increment parameter from step inputs, get the counter value from scenario context, add the two together, and store the new value for the counter back into scenario context:
@StepIdentifier("incrementCounter")
@Component
public class IncrementCounterStep implements StepDefinition {
@Override
public StepResponse call(SuiteContext suiteContext, ScenarioContext scenarioContext, StepInputs inputs) {
// Get the increment value from the step parameter.
int increment = inputs.getParameterAsNumber("increment").intValue();
// Get the current counter value.
int counter = scenarioContext.getScenarioState().get(CounterConstants.KEY_COUNTER);
// Increment the counter and store it back into scenario context.
int newCounter = counter + increment;
scenarioContext.getScenarioState().set(CounterConstants.KEY_COUNTER, newCounter);
// Return the step response.
return new StepResponse()
.status(ExecutionStatus.PASS)
.message("Incremented the counter by " + increment + " from " + counter + " to " + newCounter + ".");
}
}
Performing assertions
So far, all the steps have performed interactions. Steps may also perform verifications (or assertions) in which the result may be either pass or fail. Consider a step to verify the counter's value:
steps:
- id: verifyCounter
description: 'Verifies that the counter is equal to the given total.'
stepText: 'I verify the counter is {total}'
examples: ['I verify the counter is 5']
parameters:
- name: total
type: number
description: The total value to verify.
The step definition will need to return different response objects depending upon the counter's value:
@StepIdentifier("verifyCounter")
@Component
public class VerifyCounterStep implements StepDefinition {
@Override
public StepResponse call(SuiteContext suiteContext, ScenarioContext scenarioContext, StepInputs inputs) {
// Get the expected counter value.
int expected = inputs.getParameterAsNumber("total").intValue();
// Get the actual counter value.
int actual = scenarioContext.getScenarioState().get(CounterConstants.KEY_COUNTER);
// Compare them.
if (expected == actual) {
return new StepResponse()
.status(ExecutionStatus.PASS)
.message("The counter value is " + expected + ".");
} else {
return new StepResponse()
.status(ExecutionStatus.FAIL)
.message("The counter value should be " + expected + ", but it is actually " + actual + ".");
}
}
}
Assigning output variables
Steps do not have "return values" like methods and functions in a traditional programming language. Nevertheless, you can return values from steps in the form of Cycle variables. For example, the following step assigns the counter's value to a variable:
steps:
- id: assignCounter
description: "Assigns the counter's value to the given variable."
stepText: 'I assign the counter to variable {variableName}'
examples: ['I assign the counter to variable "AndyCount"']
outputVariables: ['{variableName}']
parameters
- name: variableName
type: string
description: The Cycle variable name.
In this step spec, variableName is the parameter for the variable name. Note that it is surrounded by curly braces ({variableName}) in the outputVariables field. This lets the tester provide any variable name they wish.
The step definition class must get the variable name from the inputs, get the counter value from scenario context, and then return the counter as a variable on the step response object:
@StepIdentifier("assignCounter")
@Component
public class AssignCounterStep implements StepDefinition {
@Override
public StepResponse call(SuiteContext suiteContext, ScenarioContext scenarioContext, StepInputs inputs) {
// Get the variable name and the counter value.
String variableName = inputs.getParameterAsString("variableName");
int counter = scenarioContext.getScenarioState().get(CounterConstants.KEY_COUNTER);
// Return the step response with the assigned variable.
return new StepResponse()
.status(ExecutionStatus.PASS)
.putVariablesItem(variableName, counter)
.message("Assigned the counter value of '" + counter + "' to variable '" + variableName + "'.");
}
}
The putVariablesItem method on the StepResponse object attached the variable value to the response. A response object may have zero-to-many variables. When Cycle receives the step response, it sets all the variables provided by the response as Cycle variables. If Cycle variables already exist, then their values will be overwritten by the step response's variable values.
Accepting input variables
In addition to step parameters, a step may also receive Cycle variables as inputs. This lets steps work with Cycle variables without needing to declare them as step parameters. For example, there could be a step that resets the counter to the value of the SECRET_VARIABLE variable. The step spec would look like this:
- id: resetCounterSecret
description: 'Resets the counter to the value of SECRET_VARIABLE.'
stepText: 'I reset the counter to the secret variable'
examples: ['I reset the counter to the secret variable']
inputVariables: ['SECRET_VARIABLE']
The step definition would access the input variable from the StepInputs object like this:
@StepIdentifier("resetCounterSecret")
@Component
public class ResetCounterSecretStep implements StepDefinition {
@Override
public StepResponse call(SuiteContext suiteContext, ScenarioContext scenarioContext, StepInputs inputs) {
// Get the secret variable from the StepInputs object.
int resetValue = inputs.getVariableAsNumber(CounterConstants.SECRET_VARIABLE).intValue();
// Reset the counter.
scenarioContext.getScenarioState().set(CounterConstants.KEY_COUNTER, resetValue);
// Return the step response.
return new StepResponse()
.status(ExecutionStatus.PASS)
.message("Reset the counter to " + resetValue + ".");
}
}