Anıl Şenocak

Cucumber enables writing human-readable feature specifications that directly map to automated tests. This approach:
src/test/
├── kotlin/com/github/senocak/boilerplate/
│ ├── config/
│ │ ├── CucumberTestConfig.kt # Main test configuration
│ │ └── initializer/
│ │ └── PostgresqlInitializer.kt # Database setup
│ └── stepdefs/
│ ├── CucumberBase.kt # Base class with HTTP utilities
│ └── AuthSteps.kt # Authentication step definitions
├── resources/
│ ├── application-cucumber-test.yml # Test-specific configuration
│ └── features/
│ └── Auth.feature # Gherkin feature files
Let’s walk through the complete flow of how Cucumber integration testing works in this project:
Feature files are written in Gherkin syntax to describe test scenarios in plain English. This makes them readable by non-technical stakeholders while providing structure for test automation. They are stored in src/test/resources/features/:
Feature: Feature: User Authentication
Scenario: client makes call to POST to login
When the client calls "/login" with username "asenocakAdmin" and password "asenocak" and cast to "com.github.senocak.boilerplate.domain.dto.UserWrapperResponse"
Then the client receives status code of 200
Then response has field "token"
This feature file describes a scenario where:
The CucumberTestConfig annotation consolidates all the necessary configuration for our tests:
@Tag(value = "cucumber")
@Target(allowedTargets = \[AnnotationTarget.ANNOTATION\_CLASS, AnnotationTarget.CLASS\])
@Retention(value = AnnotationRetention.RUNTIME)
@ActiveProfiles(value = \["cucumber-test"\])
@TestClassOrder(value = ClassOrderer.OrderAnnotation::class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM\_PORT)
@Transactional(propagation = Propagation.NOT\_SUPPORTED)
@ContextConfiguration(initializers = \[
PostgresqlInitializer::class,
\])
@TestPropertySource(value = \["/application-cucumber-test.yml"\])
@Suite
@CucumberContextConfiguration
@IncludeEngines(value = \["cucumber"\])
@SelectClasspathResource(value = "features")
@ConfigurationParameter(key = GLUE\_PROPERTY\_NAME, value = "com.github.senocak.boilerplate.stepdefs")
@ConfigurationParameter(key = PLUGIN\_PROPERTY\_NAME, value = "pretty")
annotation class CucumberTestConfig
Key annotations:
Database Setup with Testcontainers
The PostgresqlInitializer class creates and initializes an isolated PostgreSQL container:
@TestConfiguration
class PostgresqlInitializer: ApplicationContextInitializer<ConfigurableApplicationContext\> {
private val postgresContainer: PostgreSQLContainer<Nothing\> = PostgreSQLContainer<Nothing\>("postgres:14").apply {
withDatabaseName("testdb")
withUsername("test")
withPassword("test")
withInitScripts("migration/V1\_\_init.sql", "migration/V2\_\_populate.sql")
withStartupTimeout(TestConstants.CONTAINER\_WAIT\_TIMEOUT)
}
init {
postgresContainer.start()
}
override fun initialize(configurableApplicationContext: ConfigurableApplicationContext) {
TestPropertyValues.of(
"spring.datasource.url=" + postgresContainer.jdbcUrl,
"spring.datasource.username=" + postgresContainer.username,
"spring.datasource.password=" + postgresContainer.password
).applyTo(configurableApplicationContext.environment)
}
}
This provides:
The CucumberBase class provides the core HTTP testing functionality:
@CucumberTestConfig
open class CucumberBase {
@LocalServerPort protected var localPort: Int = 0
@Autowired protected lateinit var objectMapper: ObjectMapper
@Autowired protected lateinit var restTemplateBuilder: RestTemplateBuilder
protected lateinit var restTemplate: RestTemplate
protected lateinit var latestResponse: ResponseResults
val headers: MutableMap<String, String> = hashMapOf("Accept" to "application/json")
// HTTP request methods (GET, POST, PUT, DELETE)
// Response handling and parsing
}
Key Features:
HTTP Request Methods
The base class provides methods for all HTTP verbs:
@Throws(exceptionClasses = \[IOException::class\])
fun executePost(url: String, entries: MutableMap<String, Any>, classToCast: Class<\*>) {
headers.put("Content-Type", "application/json")
val requestCallback = HeaderSettingRequestCallback(requestHeaders = headers)
requestCallback.setBody(JSONObject(entries).toString())
val errorHandler = ResponseResultErrorHandler()
restTemplate.setErrorHandler(errorHandler)
latestResponse = restTemplate.execute(url, HttpMethod.POST, requestCallback,
{ response: ClientHttpResponse ->
when {
errorHandler.hadError -> errorHandler.results
else -> ResponseResults(theResponse = response, classToCast = classToCast)
}
}) ?: throw IOException("Response was null for URL: $url")
}
Like above the get request method, other methods can be implemented straight-forward. These methods:
Custom Error Handling
This captures both successful and error responses for comprehensive testing.
class ResponseResultErrorHandler: ResponseErrorHandler {
var results: ResponseResults? = null
var hadError = false
override fun hasError(response: ClientHttpResponse): Boolean {
hadError = response.statusCode.value() >= 500
return hadError
}
}
Custom Response Handling
The ResponseResults class captures and processes HTTP responses:
class ResponseResults internal constructor(
val theResponse: ClientHttpResponse,
val classToCast: Class<\*>? = null,
) {
var body: String = toString(inputStream = theResponse.body)
private fun toString(inputStream: InputStream): String {
val reader = BufferedReader(inputStream.reader())
val content = StringBuilder()
try {
var line = reader.readLine()
while (line != null) {
content.append(line)
line = reader.readLine()
}
} finally {
reader.close()
}
return content.toString()
}
}
This class:
Step definitions connect the Gherkin language in feature files to executable Kotlin code:
class AuthSteps: CucumberBase() {
@Before
fun setup() {
restTemplate = restTemplateBuilder.rootUri("http://localhost:$localPort/api/v1/auth").build()
}
@Then(value = "the client calls {string} with username {string} and password {string} and cast to {string}")
fun theClientCallsWithCredentialsAndCastsToClass(url: String, username: String, password: String, classToCast: String) {
executePost(
url = url,
entries = mutableMapOf("username" to username, "password" to password),
classToCast = Class.forName(classToCast) as Class<\*>
)
}
@Then(value = "^the client receives status code of (\\\\d+)$")
fun theClientReceivesStatusCode(statusCode: Int) {
val currentStatusCode: HttpStatusCode = latestResponse.theResponse.statusCode
assertEquals(expected = statusCode.toLong(), actual = currentStatusCode.value().toLong())
}
@Then(value = "response has field {string}")
fun responseHasField(field: String) {
val readTree = objectMapper.readTree(latestResponse.body)
assertTrue { readTree.get(field) != null }
}
}
Key aspects of step definitions:
Here’s a diagram illustrating the flow of test execution:
┌────────────────┐ ┌─────────────────┐ ┌───────────────────┐
│ │ │ │ │ │
│ Auth.feature ├────▶│ AuthSteps.kt ├────▶│ CucumberBase │
│ │ │ │ │ │
└────────────────┘ └─────────────────┘ └─────────┬─────────┘
│
┌────────────────┐ ┌─────────────────┐ ┌─────────▼─────────┐
│ │ │ │ │ │
│ Spring Context │◀────┤ PostgreSQL │◀────┤ REST Template │
│ │ │ (Testcontainers)│ │ │
└────────────────┘ └─────────────────┘ └───────────────────
🔧Gradle Setup for Testing
The build.gradle.kts file includes specific configurations for running Cucumber tests:
dependencies {
// Other dependencies...
testImplementation("org.junit.platform:junit-platform-suite")
testImplementation("io.cucumber:cucumber-java:7.23.0")
testImplementation("io.cucumber:cucumber-junit-platform-engine:7.23.0")
testImplementation("io.cucumber:cucumber-spring:7.23.0")
testImplementation("org.testcontainers:testcontainers:1.21.3")
testImplementation("org.testcontainers:junit-jupiter:1.21.3")
testImplementation("org.testcontainers:postgresql:1.21.3")
}
tasks.withType<Test> {
systemProperty("cucumber.junit-platform.naming-strategy", "long")
// ...
}
tasks.register<Test>(name = "integrationTest") {
description = "Runs the integration tests"
group = "Verification"
include("\*\*/\*IT.\*")
useJUnitPlatform()
}
Key configuration elements
🧪Command Line Execution
# Run all tests including Cucumber
./gradlew test
# Run only integration tests
./gradlew integrationTest
# Skip specific test types
./gradlew test -PskipTests=integration
./gradlew test -Dspring.profiles.active=cucumber-test
# Skip all tests
./gradlew test -PskipTests=all
Debugging Tips
@ConfigurationParameter(key = PLUGIN\_PROPERTY\_NAME, value = "pretty")
private val log: Logger by logger()
log.debug("Request body: ${requestCallback.body}")
assertEquals(
expected = statusCode.toLong(),
actual = currentStatusCode.value().toLong(),
message = "Expected status ${statusCode} but got ${currentStatusCode.value()}"
)
Separation of Concerns:
Reusable Components
Robust Error Handling
Database Isolation
Configuration Management
Maintainable Test Suite
Clean Separation of Concerns
Reliable
Reusable Components
Readable
Realistic Testing Environment
This Cucumber integration testing architecture provides a powerful and solid foundation for BDD-style integration testing in a Spring Boot. By combining the readability of Gherkin with the power of Spring’s testing framework and the isolation of Testcontainers, you can build a comprehensive, maintainable test suite that validates your application’s behavior from end to end.
The approach demonstrated here ensures that your tests:
By adopting these patterns, you can build confidence in your application’s behavior while creating a valuable resource for understanding its requirements and functionality.
Now that you have a solid foundation for Cucumber integration testing with Spring Boot, here are some ways to enhance your testing suite:
Expand Test Coverage
Advanced Cucumber Features
Scenario Outline: Login with various credentials
When the client calls "/login" with username "<username>" and password "<password>"
Then the client receives status code of <statusCode>
Examples:
| username | password | statusCode |
| validUser | validPass | 200 |
| invalidUser | validPass | 401 |
| validUser | wrongPass | 401 |
Feature: User management
Background:
Given the database contains a user with username "admin"
Scenario: Get user details
When the client authenticates as "admin"
And the client calls "/users/me"
Then the client receives status code of 200
Enhance Reporting
@ConfigurationParameter(
key = PLUGIN\_PROPERTY\_NAME,
value = "pretty, html:build/reports/cucumber/report.html"
)
dependencies {
// existing dependencies...
testImplementation("io.qameta.allure:allure-cucumber7-jvm:2.24.0")
}
Parallel Test Execution
@ConfigurationParameter(
key = "cucumber.execution.parallel.enabled",
value = "true"
)
@ConfigurationParameter(
key = "cucumber.execution.parallel.config.strategy",
value = "dynamic"
)
CI/CD Integration
name: Cucumber Tests
on: \[push, pull\_request\]
jobs:
test:
runs-on: ubuntu-latest
steps:
\- uses: actions/checkout@v3
\- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
\- name: Run tests
run: ./gradlew integrationTest
\- name: Publish Test Report
uses: mikepenz/action-junit-report@v3
if: always()
with:
report\_paths: 'build/test-results/test/TEST-\*.xml'
Custom Step Libraries
class UserManagementSteps: CucumberBase() {
// User-specific step definitions
}
class PaymentSteps: CucumberBase() {
// Payment-specific step definitions
}
Visual Testing Integration
@Then("take screenshot")
fun takeScreenshot() {
// Screenshot logic for Selenium or similar
}
By implementing these enhancements, you’ll create an even more robust and maintainable testing framework that scales with your application’s complexity.
This guide demonstrates how to implement robust Behavior-Driven Development (BDD) integration tests using Cucumber in a Spring Boot. The setup leverages Testcontainers for database isolation and provides a clean, maintainable testing architecture.
Full code example can be found in github