Kotlin DSL
BerryCrush provides a type-safe Kotlin DSL for defining API test scenarios programmatically. This approach offers IDE autocompletion, compile-time type safety, and the full power of Kotlin for complex test logic.
Overview
The Kotlin DSL provides an alternative to text-based .scenario files, allowing you to:
Define scenarios with compile-time type safety
Use Kotlin expressions for dynamic values
Leverage IDE features like autocompletion and refactoring
Mix declarative scenario structure with imperative logic
Quick Start
Here’s a minimal example:
import org.berrycrush.berrycrush.dsl.berryCrush
val suite = berryCrush("petstore.yaml") {
baseUrl = "https://api.example.com"
}
@ScenarioTest Annotation
-------------------------
call("listPets")
The ``@ScenarioTest`` annotation provides a cleaner way to define scenarios
afterwards("I receive a list") {
statusCode(200)
bodyArrayNotEmpty("$.pets")
}
}
Creating a Test Suite
- Single Spec
import org.berrycrush.junit.ScenarioTest
For APIs with a single OpenAPI specification:
@ScenarioTest
baseUrl = "https://api.example.com"
timeout(30) // seconds
header("Accept", "application/json")
}
Multi-Spec
For APIs with multiple OpenAPI specifications:
@ScenarioTest
- val suite = berryCrush {
- spec(“petstore”, “petstore.yaml”) {
baseUrl = “https://petstore.example.com”
}
- spec(“auth”, “auth.yaml”) {
baseUrl = “https://auth.example.com”
}
- configure {
timeout(60)
}
}
Defining Scenarios
Methods annotated with
@ScenarioTestthat returnScenarioare automatically discoveredEach
@ScenarioTestmethod becomes a separate test in the JUnit test tree
Scenarios follow the Given-When-Then pattern:
Note
The whenever keyword is the preferred alternative to when, which requires backticks.
API Calls
Basic Call
- Call an operation by its OpenAPI
operationId: Methods annotated with
@ScenarioTestmust:
whenever("I get a pet") {
call("getPetById") {
pathParam("petId", 123)
}
}
With Query Parameters
call("findPetsByStatus") {
queryParam("status", "available")
queryParam("limit", 10)
}
With Headers
call("listPets") {
header("Accept", "application/json")
header("X-Api-Key", "my-api-key")
}
With Request Body
Using a map (recommended):
call("createPet") {
body(mapOf(
"name" to "Fluffy",
"category" to "cat",
"status" to "available"
))
}
Using a JSON string:
call("createPet") {
body("""{"name": "Fluffy", "status": "available"}""")
}
Authentication Shortcuts
// Bearer token
call("protectedEndpoint") {
bearerToken("my-jwt-token")
}
// Basic auth
call("protectedEndpoint") {
basicAuth("username", "password")
}
// API key
call("protectedEndpoint") {
apiKey("X-Api-Key", "secret-key")
}
Multi-Spec Calls
Call operations from a specific spec:
whenever("I authenticate") {
using("auth") // Switch to auth spec
call("login") {
body(mapOf("username" to "test", "password" to "secret"))
}
}
Variable Extraction
Extract values from responses for use in subsequent steps:
whenever("I create a pet") {
call("createPet") {
body(mapOf("name" to "Fluffy"))
}
extractTo("petId", "$.id")
extractTo("petName", "$.name")
}
and("I retrieve the pet") {
call("getPetById") {
pathParam("petId", $$"${petId}") // Use extracted variable
}
}
Cross-Scenario Variable Sharing
Variables can be shared across scenarios when enabled in configuration:
val suite = berryCrush("petstore.yaml") {
shareVariablesAcrossScenarios = true
}
suite.scenario("Create a pet") {
whenever("I create a pet") {
call("createPet") { body(mapOf("name" to "Fluffy")) }
extractTo("petId", "$.id")
}
}
suite.scenario("Use the created pet") {
whenever("I get the pet from the previous scenario") {
call("getPetById") {
pathParam("petId", $$"${petId}")
}
}
}
Assertions
Status Code
afterwards("the response is successful") {
statusCode(200) // Exact match
statusCode(200..299) // Range match
}
Body Assertions
afterwards("the response body is correct") {
bodyContains("Fluffy") // Contains substring
bodyEquals("$.name", "Fluffy") // JSONPath equals
bodyMatches("$.email", ".*@.*\\.com") // JSONPath matches regex
bodyArrayNotEmpty("$.pets") // Array is not empty
bodyArraySize("$.pets", 10) // Array has specific size
}
Header Assertions
afterwards("the headers are correct") {
headerExists("Content-Type")
headerEquals("Content-Type", "application/json")
}
Schema Validation
afterwards("the response matches the OpenAPI schema") {
matchesSchema()
}
Custom Assertions
Define programmatic assertions with full access to the test execution context:
suite.scenario("Custom assertion example") {
whenever("I get user profile") {
call("getUserProfile") {
pathParam("userId", 123)
}
}
afterwards("the response is valid") {
statusCode(200)
// Custom assertion with programmatic logic
assert("user age is valid") { ctx ->
val body = ctx.responseBody ?: error("No response body")
val age = """\"age\"\s*:\s*(\d+)""".toRegex().find(body)
?.groupValues?.get(1)?.toInt() ?: error("Age not found")
require(age >= 0) { "Age must be non-negative: $age" }
require(age <= 150) { "Age must be realistic: $age" }
// Store extracted value for later use
ctx.set("userAge", age)
}
// Use the extracted value in another assertion
assert("verify age was extracted") { ctx ->
val age = ctx.get<Int>("userAge")
require(age != null) { "Age should have been extracted" }
}
}
}
TestExecutionContext provides:
Property/Method |
Description |
|---|---|
|
The current HTTP response |
|
The current HTTP request |
|
The response status code |
|
The response body as string |
|
Read-only view of all variables |
|
Get a variable with type casting |
|
Store a value for subsequent assertions |
|
Store an extracted value for parameter binding |
Conditionals
Execute different assertions based on runtime conditions:
suite.scenario("Conditional assertion example") {
whenever("I search for a pet") {
call("findPetsByStatus") {
queryParam("status", "available")
}
}
afterwards("I handle the response appropriately") {
// Conditional based on status code
conditional({ ctx -> ctx.statusCode == 200 }) {
// Success case
bodyArrayNotEmpty("$")
} orElse {
// Error case
bodyContains("error")
}
// Conditional based on response content
conditional({ ctx ->
ctx.responseBody?.contains("premium") == true
}) {
// Premium pets have extra fields
assert("verify premium fields") { ctx ->
val body = ctx.responseBody ?: ""
require(body.contains("premiumFeatures"))
}
}
}
}
The conditional block:
Takes a predicate function
(TestExecutionContext) -> BooleanExecutes the first block if the predicate returns
trueExecutes the
orElseblock (if present) if the predicate returnsfalseCan be used multiple times in the same step
Supports nested standard assertions (
statusCode,bodyEquals, etc.)
Scenario File Compatibility
For consistency with .scenario file keywords, the DSL provides aliases:
Alias |
Primary Method |
Description |
|---|---|---|
|
|
Matches |
|
|
Matches |
|
|
Matches |
These aliases allow you to use the same keywords in Kotlin DSL that you would use
in .scenario files:
suite.scenario("Using scenario file keywords") {
`when`("I request a pet") { // Same as whenever
call("getPetById") {
pathParam("petId", 1)
}
}
then("I receive the pet") { // Same as afterwards
statusCode(200)
bodyEquals("$.name", "Fluffy")
}
but("no error is returned") { // Same as otherwise
bodyEquals("$.error", null)
}
}
Note
The when keyword requires backticks (` `when` `) because it’s a Kotlin
reserved word. The primary methods (whenever, afterwards, otherwise)
don’t have this limitation.
Scenario Outlines
Create parameterized scenarios with multiple data sets:
suite.scenarioOutline("Filter pets by status: <status>") {
whenever("I filter pets") {
call("findPetsByStatus") {
queryParam("status", "<status>")
}
}
afterwards("I receive matching pets") {
statusCode(200)
}
examples(
row("status" to "available"),
row("status" to "pending"),
row("status" to "sold")
)
}
Fragments (Reusable Steps)
Define reusable step sequences:
// Define a fragment
val authenticateFragment = suite.fragment("authenticate") {
given("I have credentials") {
call("login") {
body(mapOf("username" to "test", "password" to "secret"))
}
extractTo("authToken", "$.token")
}
}
// Include in scenarios
suite.scenario("Access protected resource") {
include(authenticateFragment)
whenever("I access the protected endpoint") {
call("protectedResource") {
bearerToken($$"${authToken}")
}
}
afterwards("I get the data") {
statusCode(200)
}
}
JUnit Integration
Use BerryCrushExtension for JUnit integration:
import org.berrycrush.junit.BerryCrushExtension
import org.berrycrush.junit.BerryCrushSpec
import org.berrycrush.dsl.BerryCrushSuite
import org.berrycrush.executor.BerryCrushScenarioExecutor
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@ExtendWith(BerryCrushExtension::class)
@BerryCrushSpec("petstore.yaml")
class PetstoreKotlinScenarios {
@Test
fun `list all pets`(suite: BerryCrushSuite, executor: BerryCrushScenarioExecutor) {
suite.configure {
baseUrl = "https://api.example.com"
}
val scenario = suite.scenario("List all pets") {
whenever("I request pets") {
call("listPets")
}
afterwards("I get a list") {
statusCode(200)
}
}
executor.execute(scenario)
}
}
Complete Example
@ExtendWith(BerryCrushExtension::class)
@BerryCrushSpec("petstore.yaml")
class PetstoreApiTests {
@Test
fun `list available pets`(suite: BerryCrushSuite, executor: BerryCrushScenarioExecutor) {
suite.configure {
baseUrl = "https://petstore.swagger.io/v2"
timeout(30)
header("Accept", "application/json")
}
val scenario = suite.scenario("List available pets") {
whenever("I request available pets") {
call("findPetsByStatus") {
queryParam("status", "available")
}
}
afterwards("I receive a list") {
statusCode(200)
bodyArrayNotEmpty("$")
}
}
executor.execute(scenario)
}
}
API Reference
BerryCrushSuite
Method |
Description |
|---|---|
|
Register a single OpenAPI spec |
|
Register a named spec (multi-spec) |
|
Configure the test suite |
|
|
|
|
|
Define a reusable fragment |
ScenarioScope
StepScope
Method |
Description |
|---|---|
|
Call an API operation |
|
Switch to a named spec |
|
|
|
Assert status code |
|
Assert body contains text |
|
Assert JSONPath value |
|
Assert JSONPath matches regex |
|
Assert array size |
|
Assert array is not empty |
|
Assert header exists |
|
Assert header value |
|
Assert response matches OpenAPI schema |
CallScope
Method |
Description |
|---|---|
|
Set a path parameter |
|
Set a query parameter |
|
Set a header |
|
Set request body (String or Map) |
|
Add Bearer authentication |
|
Add Basic authentication |
|
Add API key header |
|
Enable/disable auto-assertions |
|
Enable auto-test generation (INVALID, SECURITY, MULTI) |
|
Enable auto-tests using boolean flags |
|
Exclude specific test categories |
JUnit 5 Integration with BerryCrushExtension
For JUnit 5 tests, BerryCrushExtension provides dependency injection of the suite,
configuration, and executor. This is the recommended approach for integrating with JUnit 5.
Basic Usage
import org.berrycrush.junit.BerryCrushExtension
import org.berrycrush.junit.BerryCrushSpec
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@ExtendWith(BerryCrushExtension::class)
@BerryCrushSpec("petstore.yaml", baseUrl = "http://localhost:8080/api")
class PetApiTest {
@Test
fun `list all pets`(
suite: BerryCrushSuite,
executor: BerryCrushScenarioExecutor,
) {
val scenario = suite.scenario("List all pets") {
whenever("I request all pets") {
call("listPets")
}
afterwards("I get a successful response") {
statusCode(200)
}
}
val result = executor.execute(scenario)
assertEquals(ResultStatus.PASSED, result.status)
}
}
Spring Boot with Dynamic Port
When using Spring Boot’s random port, inject BerryCrushConfiguration in @BeforeEach
to set the baseUrl dynamically. Inject BerryCrushSuite and BerryCrushScenarioExecutor
directly in @Test methods:
import org.berrycrush.config.BerryCrushConfiguration
import org.berrycrush.dsl.BerryCrushSuite
import org.berrycrush.executor.BerryCrushScenarioExecutor
import org.berrycrush.junit.BerryCrushExtension
import org.berrycrush.junit.BerryCrushSpec
import org.junit.jupiter.api.*
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(BerryCrushExtension::class)
@BerryCrushSpec("classpath:/petstore.yaml")
class PetApiTest {
@LocalServerPort
private var port: Int = 0
@BeforeEach
fun setup(config: BerryCrushConfiguration) {
// Set dynamic port - Configuration is shared, changes affect executor
config.baseUrl = "http://localhost:$port/api"
}
@Test
fun `list all pets`(
suite: BerryCrushSuite,
executor: BerryCrushScenarioExecutor,
) {
val scenario = suite.scenario("List all pets") {
whenever("I request all pets") {
call("listPets")
}
afterwards("I get a successful response") {
statusCode(200)
}
}
val result = executor.execute(scenario)
assertEquals(ResultStatus.PASSED, result.status)
}
}
Nested Test Classes
BerryCrushExtension automatically shares the suite with @Nested inner classes.
Configuration set in the outer class’s @BeforeEach is inherited by nested classes:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(BerryCrushExtension::class)
@BerryCrushSpec("classpath:/petstore.yaml")
class PetApiTest {
@LocalServerPort
private var port: Int = 0
@BeforeEach
fun setup(config: BerryCrushConfiguration) {
config.baseUrl = "http://localhost:$port/api"
}
@Nested
inner class GetPets {
@Test
fun `should return list`(
suite: BerryCrushSuite,
executor: BerryCrushScenarioExecutor,
) {
val scenario = suite.scenario("List pets") { ... }
val result = executor.execute(scenario)
assertEquals(ResultStatus.PASSED, result.status)
}
}
}
Supported Parameter Types
The extension can inject the following types into @BeforeEach or @Test methods:
BerryCrushSuite: The test suite containing the OpenAPI specBerryCrushConfiguration: Configuration object for settingbaseUrl,timeout, etc.BerryCrushScenarioExecutor: Executor for running scenarios
Note
The BerryCrushConfiguration object is shared between the suite and executor.
Changes to the configuration affect the executor, even after the executor is created.
@ScenarioTest Annotation
The @ScenarioTest annotation provides a cleaner way to define scenarios
as JUnit test methods. Instead of manually calling executor.execute(), you can
simply annotate a method that returns a Scenario and BerryCrush will automatically
discover and execute it.
Basic Usage
import org.berrycrush.dsl.BerryCrushSuite
import org.berrycrush.junit.BerryCrushSpec
import org.berrycrush.junit.ScenarioTest
import org.berrycrush.model.Scenario
@BerryCrushSpec("petstore.yaml", baseUrl = "http://localhost:8080/api")
class PetApiTest {
@ScenarioTest
fun `list all pets`(): Scenario =
BerryCrushSuite.create().scenario("List all pets") {
whenever("I request all pets") {
call("listPets")
}
afterwards("I get a successful response") {
statusCode(200)
}
}
@ScenarioTest
fun createPet(): Scenario =
BerryCrushSuite.create().scenario("Create a new pet") {
whenever("I create a pet") {
call("createPet") {
body(mapOf("name" to "Fluffy"))
}
}
afterwards("the pet is created") {
statusCode(201)
}
}
}
How It Works
The BerryCrush test engine discovers classes annotated with
@BerryCrushSpecMethods annotated with
@ScenarioTestthat returnScenarioare automatically discoveredEach
@ScenarioTestmethod becomes a separate test in the JUnit test treeThe test engine executes each scenario and reports results to JUnit
Comparison with BerryCrushExtension
Using @ScenarioTest (recommended for most cases):
@BerryCrushSpec("petstore.yaml")
class PetApiTest {
@ScenarioTest
fun `list pets`(): Scenario =
BerryCrushSuite.create().scenario("List pets") { ... }
}
Using BerryCrushExtension (for more control):
@ExtendWith(BerryCrushExtension::class)
@BerryCrushSpec("petstore.yaml")
class PetApiTest {
@Test
fun `list pets`(suite: BerryCrushSuite, executor: BerryCrushScenarioExecutor) {
val scenario = suite.scenario("List pets") { ... }
val result = executor.execute(scenario)
assertEquals(ResultStatus.PASSED, result.status)
}
}
Use @ScenarioTest when you want:
Simpler, more concise test code
Automatic test discovery and execution
Less boilerplate code
Use BerryCrushExtension when you need:
Access to the
ScenarioResultfor custom assertionsDynamic configuration per test (e.g., Spring Boot’s random port)
Complex test setup that requires injected parameters
Method Naming Conventions
The method name is converted to a display name in the JUnit test tree:
createPet→ “Create Pet”listAllPets→ “List All Pets”` `list all pets` `→ “List all pets” (backtick-wrapped names)
Note
Methods annotated with @ScenarioTest must:
- Return Scenario or a subtype
- Have no parameters (or only BerryCrushSuite if needed)
- Be public
Auto-Test Generation
The Kotlin DSL supports automatic generation of invalid, security, and multi (idempotency) tests directly in code.
Enabling Auto-Tests
Use the autoTest() method in a call block to enable auto-test generation:
import org.berrycrush.scenario.AutoTestType
scenario("Create pet with auto-tests") {
whenever("I create a pet") {
call("createPet") {
body("""{"name": "Fluffy", "category": "cat"}""")
// Enable specific auto-test types
autoTest(AutoTestType.INVALID, AutoTestType.SECURITY)
}
}
}
You can also use the boolean-based API:
call("createPet") {
autoTest(invalid = true, security = true, multi = false)
}
Auto-Test Types
Type |
Description |
|---|---|
|
Tests that violate OpenAPI schema constraints (minLength, maxLength, pattern, required, etc.) |
|
Security vulnerability tests (SQL injection, XSS, path traversal, command injection, etc.) |
|
Idempotency tests that send multiple requests (sequential and concurrent) |
Multi (Idempotency) Tests
Multi-tests verify API consistency by sending multiple requests:
scenario("List pets - Idempotency test") {
whenever("I send multiple requests") {
call("listPets") {
autoTest(AutoTestType.MULTI)
}
}
afterwards("The requests complete successfully") {
statusCode(200..599)
}
}
Configure request counts at the suite level:
suite.configuration.multiTestSequentialCount = 5
suite.configuration.multiTestConcurrentCount = 10
Excluding Test Categories
Exclude specific test categories from auto-generation:
call("createPet") {
autoTest(AutoTestType.INVALID, AutoTestType.SECURITY)
excludes("XSS", "minLength")
}
Test Display Names
Auto-tests appear in reports with these formats:
[invalid - minLength]- Invalid request tests[security - SQL Injection]- Security tests[multi:sequential] 3 requests- Sequential idempotency tests[multi:concurrent] 5 requests- Concurrent idempotency tests
See Also
Quick Start - Getting started with BerryCrush
Standalone Runner - Running scenarios without JUnit
Fragments - Creating reusable step sequences
Custom Steps - Defining custom step implementations