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"
}
val scenario = suite.scenario("List all pets") {
`when`("I request all pets") {
call("listPets")
}
then("I receive a list") {
statusCode(200)
bodyArrayNotEmpty("$.pets")
}
}
Creating a Test Suite
Single Spec
For APIs with a single OpenAPI specification:
val suite = berryCrush("petstore.yaml") {
baseUrl = "https://api.example.com"
timeout(30) // seconds
header("Accept", "application/json")
}
Multi-Spec
For APIs with multiple OpenAPI specifications:
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
Basic Structure
Scenarios follow the Given-When-Then pattern:
suite.scenario("Create and retrieve a pet") {
given("the API is available") {
// Optional setup
}
`when`("I create a new pet") {
call("createPet") {
body(mapOf(
"name" to "Fluffy",
"status" to "available"
))
}
}
then("the pet is created") {
statusCode(201)
bodyEquals("$.name", "Fluffy")
}
}
Note
The when keyword requires backticks (` `when` `) because it’s a Kotlin reserved word.
API Calls
Basic Call
Call an operation by its OpenAPI operationId:
`when`("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:
`when`("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:
`when`("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") {
`when`("I create a pet") {
call("createPet") { body(mapOf("name" to "Fluffy")) }
extractTo("petId", "$.id")
}
}
suite.scenario("Use the created pet") {
`when`("I get the pet from the previous scenario") {
call("getPetById") {
pathParam("petId", $$"${petId}")
}
}
}
Assertions
Status Code
then("the response is successful") {
statusCode(200) // Exact match
statusCode(200..299) // Range match
}
Body Assertions
then("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
then("the headers are correct") {
headerExists("Content-Type")
headerEquals("Content-Type", "application/json")
}
Schema Validation
then("the response matches the OpenAPI schema") {
matchesSchema()
}
Scenario Outlines
Create parameterized scenarios with multiple data sets:
suite.scenarioOutline("Filter pets by status: <status>") {
`when`("I filter pets") {
call("findPetsByStatus") {
queryParam("status", "<status>")
}
}
then("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)
`when`("I access the protected endpoint") {
call("protectedResource") {
bearerToken($$"${authToken}")
}
}
then("I get the data") {
statusCode(200)
}
}
JUnit Integration
Extend ScenarioTest for JUnit integration:
import org.berrycrush.berrycrush.junit.BerryCrushSpec
import org.berrycrush.berrycrush.junit.ScenarioTest
@BerryCrushSpec("petstore.yaml")
class PetstoreKotlinScenarios : ScenarioTest() {
override fun configureSuite() {
configure {
baseUrl = "https://api.example.com"
timeout(30)
}
}
override fun defineScenarios() {
scenario("List all pets") {
`when`("I request pets") {
call("listPets")
}
then("I get a list") {
statusCode(200)
}
}
}
}
Complete Example
@BerryCrushSpec("petstore.yaml")
class PetstoreApiTests : ScenarioTest() {
override fun configureSuite() {
configure {
baseUrl = "https://petstore.swagger.io/v2"
timeout(30)
header("Accept", "application/json")
shareVariablesAcrossScenarios = true
}
}
override fun defineScenarios() {
// Simple scenario
scenario("List available pets") {
`when`("I request available pets") {
call("findPetsByStatus") {
queryParam("status", "available")
}
}
then("I receive a list") {
statusCode(200)
bodyArrayNotEmpty("$")
}
}
// CRUD flow with variable extraction
scenario("Create a new pet") {
`when`("I create a pet") {
call("addPet") {
body(mapOf(
"name" to "TestPet",
"photoUrls" to listOf("https://example.com/photo.jpg"),
"status" to "available"
))
}
extractTo("createdPetId", "$.id")
}
then("the pet is created") {
statusCode(200)
bodyEquals("$.name", "TestPet")
}
}
scenario("Retrieve the created pet") {
`when`("I get the pet by ID") {
call("getPetById") {
pathParam("petId", $$"${createdPetId}")
}
}
then("I see the pet details") {
statusCode(200)
matchesSchema()
}
}
// Parameterized scenario
scenarioOutline("Filter pets by <status>") {
`when`("I filter") {
call("findPetsByStatus") {
queryParam("status", "<status>")
}
}
then("I get results") {
statusCode(200)
}
examples(
row("status" to "available"),
row("status" to "pending"),
row("status" to "sold")
)
}
}
}
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
Method |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
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") {
`when`("I request all pets") {
call("listPets")
}
then("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") {
`when`("I request all pets") {
call("listPets")
}
then("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.
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