Custom Steps
BerryCrush provides multiple ways to define custom step definitions, allowing you to create reusable, domain-specific step implementations.
Step Binding Mechanisms
There are four ways to define custom steps:
Annotation-based - Using
@Stepannotation (recommended for Java users)Registration API - Programmatic step registration
Kotlin DSL - Type-safe DSL builder (recommended for Kotlin)
Package scanning - Auto-discovery of step classes
Annotation-Based Steps
The simplest way to define steps is using the @Step annotation:
class PetSteps {
@Step("a pet exists with name {string}")
fun createPet(name: String) {
// Create a pet with the given name
PetService.create(name)
}
@Step("I have {int} pets")
fun setPetCount(count: Int) {
// Set up the specified number of pets
repeat(count) { PetService.createRandom() }
}
@Step("the pet should have {int} legs")
fun verifyLegCount(expected: Int) {
val actual = PetService.getCurrentPet().legs
assert(actual == expected) { "Expected $expected legs but found $actual" }
}
}
Pattern Placeholders
The @Step pattern supports these placeholders:
{int}- Matches an integer (e.g.,42,-10){string}- Matches a quoted string (e.g.,"hello",'world'){word}- Matches a single word (e.g.,active,pending){float}- Matches a floating-point number (e.g.,3.14,-2.5){any}- Matches any text (greedy)
Registering Annotation-Based Steps
Register step classes via the configuration annotation:
@BerryCrushConfiguration(
stepClasses = [PetSteps::class, UserSteps::class]
)
class MyApiTest
Or via package scanning:
@BerryCrushConfiguration(
stepPackages = ["com.example.steps"]
)
class MyApiTest
Registration API
For dynamic step registration, use the StepRegistry API:
val registry = DefaultStepRegistry()
val scanner = AnnotationStepScanner()
// Register from a class
val definitions = scanner.scan(PetSteps::class.java)
registry.registerAll(definitions)
// Or register manually
registry.register(StepDefinition(
pattern = "the status is {word}",
method = MySteps::class.java.getMethod("setStatus", String::class.java),
instance = MySteps(),
description = "Sets the current status"
))
Kotlin DSL
The Kotlin DSL provides a type-safe, concise way to define steps:
val registry = DefaultStepRegistry()
steps {
step("I have {int} pets") { count: Int ->
PetService.setCount(count)
}
step("the pet name is {string}") { name: String ->
PetService.setName(name)
}
step("I add {int} and {int}") { a: Int, b: Int ->
a + b // Return value is stored
}
step("the setup is complete", description = "Verifies setup") {
// No parameters
verifySetup()
}
}.registerTo(registry)
Type-Safe Parameters
The DSL supports up to 5 typed parameters:
step<Int>("single param {int}") { value -> ... }
step<Int, String>("two params {int} {string}") { a, b -> ... }
step<Int, Int, Int>("{int} + {int} + {int}") { a, b, c -> ... }
Package Scanning
BerryCrush can automatically discover step classes in specified packages:
@BerryCrushConfiguration(
stepPackages = [
"com.example.steps.pets",
"com.example.steps.users"
]
)
class MyApiTest
All classes in these packages with @Step annotated methods will be discovered
and registered.
Spring Integration
With the Spring module, steps can be Spring-managed beans:
@Component
class PetSteps(
private val petRepository: PetRepository
) {
@Step("a pet exists with name {string}")
fun createPet(name: String, context: StepContext) {
val pet = petRepository.save(Pet(name = name))
context.variables["petId"] = pet.id
}
}
Enable auto-discovery with the Spring configuration:
@SpringBootTest
@Import(SpringStepDiscovery::class)
@IncludeEngines("berrycrush")
@BerryCrushScenarios(locations = "scenarios/pets.scenario")
class PetApiTest {
@Autowired
lateinit var stepRegistry: StepRegistry
}
Step Context
Steps can receive a StepContext parameter to access the execution context for
variables, HTTP responses, and configuration. The StepContext must be the
last parameter of the step method.
@Step("I save the pet ID as {word}")
fun savePetId(variableName: String, context: StepContext) {
val response = context.lastResponse
val petId = JsonPath.read<Int>(response?.body(), "$.id")
context.setVariable(variableName, petId)
}
@Step("I request the saved pet")
fun getSavedPet(context: StepContext) {
val petId = context.variable("petId")
// Make HTTP request using petId
}
StepContext API
The StepContext interface provides the following methods and properties:
Variables
variable(name: String): Any?- Get a variable by namevariable(name: String, type: Class<T>): T?- Get a typed variable by namesetVariable(name: String, value: Any?)- Set a scenario-scoped variablesetSharedVariable(name: String, value: Any?)- Set a suite-scoped (shared) variableallVariables(): Map<String, Any?>- Get all current variables
HTTP Response
lastResponse: HttpResponse<String>?- The last HTTP response received (null if no request made)
Configuration
configuration: BerryCrushConfiguration- The current execution configuration
Variable Scopes
Variables can be set with two different scopes:
Scenario-scoped (default): Variables set with
setVariable()are isolated to the current scenario and will NOT be shared with other scenarios.Suite-scoped: Variables set with
setSharedVariable()will be shared across scenarios when variable sharing is enabled in the suite configuration. If sharing is disabled,setSharedVariable()behaves likesetVariable().
@Step("I set up test data")
fun setupData(context: StepContext) {
// Scenario-scoped: only visible in current scenario
context.setVariable("tempId", generateId())
// Suite-scoped: shared across scenarios when sharing enabled
context.setSharedVariable("authToken", getAuthToken())
}
Variable lookup prioritizes scenario-scoped variables over shared variables when both exist.
Best Practices
Keep steps reusable: Steps should be generic enough to use across scenarios
Use descriptive patterns: Make step text readable and self-documenting
Handle return values: Steps can return values for chaining or verification
Use context variables: Share data between steps via
setVariable()Document complex steps: Use the
descriptionparameter for documentationGroup related steps: Organize step classes by domain (e.g.,
PetSteps,UserSteps)StepContext as last parameter: Always place
StepContextas the last method parameter
Example: Complete Step Library
Here’s a complete example of a step library for API testing:
class ApiSteps {
@Step("the API is available at {string}")
fun setBaseUrl(url: String, context: StepContext) {
// Store base URL for later use
context.setVariable("baseUrl", url)
}
@Step("I have an auth token {string}")
fun setAuthToken(token: String, context: StepContext) {
// Share token across scenarios
context.setSharedVariable("authToken", token)
}
@Step("the response status should be {int}")
fun verifyStatus(expected: Int, context: StepContext) {
val response = context.lastResponse
?: throw AssertionError("No HTTP response available")
val actual = response.statusCode()
assert(actual == expected) {
"Expected status $expected but got $actual"
}
}
@Step("the response body at {string} should be {string}")
fun verifyJsonPath(path: String, expected: String, context: StepContext) {
val response = context.lastResponse
?: throw AssertionError("No HTTP response available")
val actual = JsonPath.read<Any>(response.body(), path)
assert(actual.toString() == expected) {
"At $path: expected $expected but got $actual"
}
}
}
Custom Assertions
In addition to custom steps, BerryCrush supports custom assertions via the @Assertion
annotation. Custom assertions are similar to custom steps but are specifically designed
for validation logic and return an AssertionResult.
Defining Custom Assertions
Define custom assertions using the @Assertion annotation:
class MyAssertions {
@Assertion("the {word} should have status {string}")
fun assertStatus(entityType: String, expectedStatus: String, context: AssertionContext): AssertionResult {
val response = context.lastResponse
?: return AssertionResult.failed("No HTTP response available")
val actualStatus = parseStatus(response, entityType)
return if (actualStatus == expectedStatus) {
AssertionResult.passed()
} else {
AssertionResult.failed(
message = "Expected $entityType status '$expectedStatus' but got '$actualStatus'",
expectedValue = expectedStatus,
actualValue = actualStatus
)
}
}
@Assertion("the response should contain {int} items")
fun assertItemCount(expected: Int, context: AssertionContext): AssertionResult {
val response = context.lastResponse
?: return AssertionResult.failed("No HTTP response available")
val items = JsonPath.read<List<*>>(response.body(), "$")
val actual = items.size
return if (actual == expected) {
AssertionResult.passed()
} else {
AssertionResult.failed(
message = "Expected $expected items but got $actual",
expectedValue = expected,
actualValue = actual
)
}
}
}
AssertionResult
Custom assertions should return an AssertionResult to indicate success or failure:
// Passed assertion
AssertionResult.passed()
AssertionResult.passed("Optional success message")
// Failed assertion
AssertionResult.failed("Failure reason")
AssertionResult.failed(
message = "Values don't match",
expectedValue = expected,
actualValue = actual
)
If a custom assertion method:
Returns
AssertionResult.passed()→ Assertion passesReturns
AssertionResult.failed(...)→ Assertion fails with messageReturns
Unit/void → Assumed to pass (unless exception thrown)Throws
AssertionError→ Treated as failed assertionThrows other exception → Treated as error (not assertion failure)
AssertionContext
The AssertionContext provides read-only access to the execution context:
Variables (read-only)
variable(name: String): Any?- Get a variable by namevariable(name: String, type: Class<T>): T?- Get a typed variableallVariables(): Map<String, Any?>- Get all variables
HTTP Response
lastResponse: HttpResponse<String>?- The last HTTP response (may be null)
Configuration
configuration: BerryCrushConfiguration- The current configuration
Registering Custom Assertions
Register assertion classes via the configuration annotation:
@BerryCrushConfiguration(
assertionClasses = [MyAssertions::class, ValidationAssertions::class]
)
class MyApiTest
Or via package scanning:
@BerryCrushConfiguration(
assertionPackages = ["com.example.assertions"]
)
class MyApiTest
Assertion vs Step
Choose between @Step and @Assertion based on purpose:
Use @Step for actions, setup, and general-purpose logic
Use @Assertion for validation and verification that returns pass/fail results
Feature |
@Step |
@Assertion |
|---|---|---|
Purpose |
Actions & setup |
Validation & verification |
Context |
StepContext (read/write) |
AssertionContext (read-only) |
Return type |
Any (including StepResult) |
AssertionResult (pass/fail) |
Variable access |
Read and write |
Read-only |
Using Custom Assertions in Scenario Files
Custom assertions can be used in scenario files using the assert keyword:
Basic Usage
scenario: Verify item availability
when I get the product details
call ^getProduct
productId: 123
then the product should be available
assert the product should have status "available"
assert the response should contain 5 items
Pattern Matching
Custom assertion patterns are matched against registered @Assertion methods.
The parser captures the assertion text after assert and looks for a matching
pattern in the AssertionRegistry:
// Definition
@Assertion("the {word} should have status {string}")
fun assertStatus(entityType: String, expectedStatus: String, context: AssertionContext): AssertionResult
# Usage in scenario file
assert the product should have status "available"
# Matches pattern: the {word} should have status {string}
# Parameters: entityType="product", expectedStatus="available"
Combining with Built-in Assertions
Custom assertions can be used alongside built-in assertions in the same step:
scenario: Complete verification
when I get the product
call ^getProduct
productId: 123
then everything should be correct
assert status 200
assert $.id equals 123
assert the product should have status "available"
assert header Content-Type equals "application/json"
Execution Context
Custom assertions execute after an HTTP request has been made. They have read-only access to:
The last HTTP response (body, status code, headers)
All extracted variables from the scenario
Configuration settings
If no HTTP response is available when a custom assertion runs, it should handle the null case gracefully:
@Assertion("data should be valid")
fun assertDataValid(context: AssertionContext): AssertionResult {
val response = context.lastResponse
?: return AssertionResult.failed("No HTTP response available")
// Validate response...
return AssertionResult.passed()
}
Pattern Guidelines
For effective custom assertions:
Use clear, descriptive patterns that read naturally in scenario files
Prefer {string} for exact matches and {word} for identifiers
Include context in the pattern (e.g., “the user” vs just “user”)
Return descriptive failure messages that help diagnose issues
Sample Code Reference
For complete working examples of custom steps and assertions, see the Petstore sample:
Custom Steps:
samples/petstore/scenario/src/test/kotlin/.../steps/PetstoreSteps.ktCustom Assertions:
samples/petstore/scenario/src/test/kotlin/.../assertions/PetstoreAssertions.ktStep Scenarios:
samples/petstore/scenario/src/test/resources/scenarios/80-custom-steps.scenarioAssertion Scenarios:
samples/petstore/scenario/src/test/resources/scenarios/90-custom-assertions.scenarioConfiguration:
samples/petstore/scenario/src/test/kotlin/.../CustomStepsTest.kt
These samples demonstrate:
Defining step methods with
@StepannotationDefining assertion methods with
@AssertionannotationUsing
StepContextfor variable managementUsing
AssertionContextfor validationRegistering custom classes via
@BerryCrushConfigurationWriting scenario files that use custom steps and assertions