Home Java REST API Testing with Cucumber and Spring: BDD Style Integration Tests
Advanced 4 min · July 14, 2026

REST API Testing with Cucumber and Spring: BDD Style Integration Tests

Learn BDD-style integration testing for Spring Boot REST APIs using Cucumber.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Java 17+
  • Spring Boot 3.2+
  • Maven or Gradle
  • Basic knowledge of REST APIs and Spring Boot
  • Familiarity with JUnit 5
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use Cucumber with Spring Boot 3.2+ for BDD-style integration tests that bridge communication gaps.
  • Avoid @SpringBootTest in every scenario; prefer lightweight slices to keep tests fast.
  • Manage test state with shared Spring contexts and Scenario hooks for cleanup.
  • WireMock is your friend for external service stubs; never call real APIs in integration tests.
✦ Definition~90s read
What is REST API Testing with Cucumber and Spring?

Cucumber is a BDD framework that allows you to write executable specifications in plain language, and Spring Boot integration testing with Cucumber lets you validate your REST APIs against those specifications using the full application context.

Imagine you're a restaurant manager writing down exactly how a waiter should take an order, how the kitchen should cook it, and how the bill is presented.
Plain-English First

Imagine you're a restaurant manager writing down exactly how a waiter should take an order, how the kitchen should cook it, and how the bill is presented. Cucumber lets you write those instructions in plain English, and then Spring Boot acts as the kitchen that follows them. This way, everyone—managers, waiters, chefs—can agree on how the restaurant works before a single dish is served.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

If you've ever been in a meeting where a product manager says 'the API should return a 404 when the user doesn't exist' and the developer implements it as a 400, you know why BDD exists. Cucumber bridges that gap with plain-English scenarios that double as living documentation and automated tests.

But here's the hard truth: most teams get Cucumber + Spring integration wrong. They either make tests painfully slow by spinning up the full application context for every scenario, or they leak state between scenarios and end up with flaky tests that fail on CI but pass locally. I've seen both—and I've debugged a production outage caused by a Cucumber test that didn't clean up database state, leading to cascading failures.

In this article, I'll show you the right way to wire Cucumber with Spring Boot 3.2 for REST API testing. We'll cover: - Setting up Cucumber with Spring Boot using @SpringBootTest and @AutoConfigureMockMvc. - Writing feature files that business folks can actually read. - Managing test data and cleanup with Scenario hooks. - Stubbing external services with WireMock so you don't accidentally call production. - A production incident that taught me why test isolation matters.

By the end, you'll have a solid, battle-tested approach for BDD-style integration tests that are fast, reliable, and actually useful.

Setting Up Cucumber with Spring Boot 3.2

Let's get the basics right. You need the following dependencies in your pom.xml:

``xml <dependency> <groupId>io.cucumber</groupId> <artifactId>cucumber-spring</artifactId> <version>7.14.0</version> <scope>test</scope> </dependency> <dependency> <groupId>io.cucumber</groupId> <artifactId>cucumber-junit-platform-engine</artifactId> <version>7.14.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> ``

Now, create a runner class. I prefer JUnit 5 with the @Suite annotation:

```java import org.junit.platform.suite.api.IncludeEngines; import org.junit.platform.suite.api.SelectClasspathResource; import org.junit.platform.suite.api.Suite;

@Suite @IncludeEngines("cucumber") @SelectClasspathResource("features") @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.steps") public class CucumberRunnerTest { } ```

Production Insight: Don't use @RunWith(Cucumber.class) from JUnit 4 anymore. It's deprecated and doesn't integrate well with Spring Boot 3.x. Stick with JUnit 5 platform.

CucumberRunnerTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
import org.junit.platform.suite.api.IncludeEngines;
import org.junit.platform.suite.api.SelectClasspathResource;
import org.junit.platform.suite.api.Suite;
import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.steps")
public class CucumberRunnerTest {
}
⚠ Don't Use JUnit 4 Runner
📊 Production Insight
I once saw a team use both JUnit 4 and 5 runners in the same project, causing tests to run twice. Pick one and stick with it.
🎯 Key Takeaway
Use JUnit 5 @Suite for Cucumber runner; avoid JUnit 4 runners.

Writing Feature Files That Don't Suck

Feature files are the heart of BDD. They should be readable by non-developers. Here's an example for a payment API:

```gherkin Feature: Payment Processing As a merchant I want to process payments So that I can charge customers

Background: Given the payment API is available And a merchant exists with id "merchant-123"

Scenario: Successful payment When I create a payment of $50.00 for merchant "merchant-123" Then the response status should be 201 And the payment should have status "COMPLETED"

Scenario: Payment with insufficient funds When I create a payment of $999999.00 for merchant "merchant-123" Then the response status should be 402 And the error message should contain "insufficient funds" ```

What the Official Docs Won't Tell You: Don't use Scenario Outline with Example tables for every scenario. It makes feature files hard to read. Use explicit scenarios for different cases. Only use Scenario Outline when you have a large number of similar test cases (e.g., boundary testing).

payment.featureJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Feature: Payment Processing
  As a merchant
  I want to process payments
  So that I can charge customers

  Background:
    Given the payment API is available
    And a merchant exists with id "merchant-123"

  Scenario: Successful payment
    When I create a payment of $50.00 for merchant "merchant-123"
    Then the response status should be 201
    And the payment should have status "COMPLETED"

  Scenario: Payment with insufficient funds
    When I create a payment of $999999.00 for merchant "merchant-123"
    Then the response status should be 402
    And the error message should contain "insufficient funds"
💡Keep Feature Files Business-Facing
🎯 Key Takeaway
Feature files should be business-readable; avoid technical jargon.

Wiring Spring Context with Cucumber Steps

Now you need step definitions that use Spring beans. Annotate your step class with @SpringBootTest and inject dependencies via constructor injection:

```java import io.cucumber.java.en.Given; import io.cucumber.java.en.When; import io.cucumber.java.en.Then; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.ResponseEntity; import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class PaymentSteps {

@Autowired private TestRestTemplate restTemplate;

private ResponseEntity<String> response;

@Given("the payment API is available") public void thePaymentApiIsAvailable() { // Just a sanity check; the context is already loaded }

@When("I create a payment of ${double} for merchant {string}") public void iCreateAPayment(Double amount, String merchantId) { String url = "/api/payments?merchantId=" + merchantId; PaymentRequest request = new PaymentRequest(amount); response = restTemplate.postForEntity(url, request, String.class); }

@Then("the response status should be {int}") public void theResponseStatusShouldBe(int expectedStatus) { assertThat(response.getStatusCodeValue()).isEqualTo(expectedStatus); }

@Then("the payment should have status {string}") public void thePaymentShouldHaveStatus(String status) { // Parse JSON response and check status } } ```

Production Insight: Use TestRestTemplate over MockMvc for integration tests. MockMvc doesn't test the full HTTP stack (e.g., serialization, filters). TestRestTemplate hits the real HTTP server on a random port, which catches more issues.

PaymentSteps.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import io.cucumber.java.en.Given;
import io.cucumber.java.en.When;
import io.cucumber.java.en.Then;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PaymentSteps {

    @Autowired
    private TestRestTemplate restTemplate;

    private ResponseEntity<String> response;

    @Given("the payment API is available")
    public void thePaymentApiIsAvailable() {
    }

    @When("I create a payment of ${double} for merchant {string}")
    public void iCreateAPayment(Double amount, String merchantId) {
        String url = "/api/payments?merchantId=" + merchantId;
        PaymentRequest request = new PaymentRequest(amount);
        response = restTemplate.postForEntity(url, request, String.class);
    }

    @Then("the response status should be {int}")
    public void theResponseStatusShouldBe(int expectedStatus) {
        assertThat(response.getStatusCodeValue()).isEqualTo(expectedStatus);
    }

    @Then("the payment should have status {string}")
    public void thePaymentShouldHaveStatus(String status) {
        // parse JSON and assert
    }
}
🔥TestRestTemplate vs MockMvc
🎯 Key Takeaway
Use TestRestTemplate with random port for full HTTP stack testing.

Managing Test Data and Cleanup with Hooks

State leakage is the #1 cause of flaky Cucumber tests. You must clean up after each scenario. Use @Before and @After hooks from Cucumber, not JUnit:

```java import io.cucumber.java.After; import io.cucumber.java.Before; import io.cucumber.java.Scenario; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate;

public class CleanupHooks {

@Autowired private JdbcTemplate jdbcTemplate;

@Before public void beforeScenario(Scenario scenario) { // Optionally set up common data }

@After public void afterScenario(Scenario scenario) { // Truncate tables to reset state jdbcTemplate.execute("TRUNCATE TABLE payments RESTART IDENTITY CASCADE"); jdbcTemplate.execute("TRUNCATE TABLE merchants RESTART IDENTITY CASCADE"); } } ```

Warning: DELETE FROM table does not reset auto-increment sequences. Always use TRUNCATE ... RESTART IDENTITY if your database supports it (PostgreSQL, H2, etc.). For MySQL, use TRUNCATE table which does reset auto-increment.

CleanupHooks.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.cucumber.java.Scenario;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;

public class CleanupHooks {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Before
    public void beforeScenario(Scenario scenario) {
    }

    @After
    public void afterScenario(Scenario scenario) {
        jdbcTemplate.execute("TRUNCATE TABLE payments RESTART IDENTITY CASCADE");
        jdbcTemplate.execute("TRUNCATE TABLE merchants RESTART IDENTITY CASCADE");
    }
}
⚠ TRUNCATE vs DELETE
📊 Production Insight
I once saw a test suite that used DELETE and then inserted with explicit IDs. They eventually hit duplicate key errors. TRUNCATE is the way.
🎯 Key Takeaway
Use @After hooks with TRUNCATE to reset database state between scenarios.

Stubbing External Services with WireMock

Your API probably calls external services. Never call real ones in integration tests. Use WireMock to stub them:

Add dependency: ``xml <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> ``

```java import com.github.tomakehurst.wiremock.WireMockServer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;

@Configuration public class WireMockConfig {

@Bean public WireMockServer wireMockServer() { WireMockServer server = new WireMockServer(options().port(8089)); server.start(); return server; } } ```

``java @Given("the payment gateway returns success") public void thePaymentGatewayReturnsSuccess() { stubFor(post(urlEqualTo("/gateway/charge")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody("{\"transactionId\":\"txn-123\"}"))); } ``

Production Insight: Don't use @AutoConfigureWireMock if you're also using @SpringBootTest. It can conflict. Instead, create a separate @TestConfiguration that starts WireMock on a random port and override the external service URL in application-test.properties.

WireMockConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import com.github.tomakehurst.wiremock.WireMockServer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;

@Configuration
public class WireMockConfig {

    @Bean
    public WireMockServer wireMockServer() {
        WireMockServer server = new WireMockServer(options().port(8089));
        server.start();
        return server;
    }
}
💡Use Random Ports for WireMock
🎯 Key Takeaway
Stub external services with WireMock on dynamic ports to avoid flakiness.

What the Official Docs Won't Tell You

1. Cucumber Spring Context Caching

Cucumber shares the Spring context across scenarios by default. This is good for speed, but it means beans are singletons. If you have stateful beans (e.g., a cache), they persist across scenarios. Use @DirtiesContext only when necessary, but prefer to design stateless beans.

2. Parallel Execution

Cucumber 7 supports parallel execution via cucumber.execution.parallel.enabled=true. But if you share a Spring context, parallel scenarios will step on each other. Either disable parallel execution or use separate contexts per thread (which is slow).

3. @SpringBootTest with webEnvironment

Using webEnvironment = WebEnvironment.RANDOM_PORT starts an embedded server. This is slow for many scenarios. Consider using WebEnvironment.MOCK with MockMvc if you don't need full HTTP stack. But then you lose realistic testing. Trade-off.

4. Step Definition Injection

Cucumber-Spring uses @Autowired injection. But you can also use constructor injection if you configure Cucumber to use Spring's dependency injection. Add cucumber-spring to your classpath and it works automatically. I prefer constructor injection for immutability.

5. Reporting

Cucumber generates HTML reports by default. But if you run on CI, you might want JSON or XML. Add cucumber.plugin=pretty,json:target/cucumber.json to your configuration.

🔥Context Caching Gotcha
📊 Production Insight
I once had a test that mutated a shared cache in one scenario and caused the next scenario to fail. Took hours to find because the cache wasn't obvious.
🎯 Key Takeaway
Understand Spring context caching and its impact on test isolation.

Running Cucumber Tests in CI/CD

Your CI pipeline should run Cucumber tests as part of the build. Use Maven Surefire plugin with the JUnit 5 engine:

``xml <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.1.2</version> <configuration> <includes> <include>*/RunnerTest.java</include> </includes> </configuration> </plugin> ``

Make sure to exclude Cucumber tests from unit test phase if you have separate unit tests. Or just run everything together.

Production Insight: Use mvn verify to run integration tests after the package phase. This ensures your application is built and ready before tests run. Also, use Testcontainers to spin up databases and other services in CI, so you don't rely on external infrastructure.

pom.xml snippetJAVA
1
2
3
4
5
6
7
8
9
10
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.1.2</version>
    <configuration>
        <includes>
            <include>**/*RunnerTest.java</include>
        </includes>
    </configuration>
</plugin>
💡Use `mvn verify` for Integration Tests
🎯 Key Takeaway
Integrate Cucumber tests into CI using Maven Surefire or Failsafe.
● Production incidentPOST-MORTEMseverity: high

The Phantom 503: How a Cucumber Test Leaked State and Took Down a Fintech API

Symptom
Users saw '503 Service Unavailable' between 2 PM and 3 PM daily. The API was up but returning errors for legitimate payment requests.
Assumption
The team assumed a memory leak or a database connection pool exhaustion. They spent weeks tuning JVM heap and connection pool sizes.
Root cause
A Cucumber integration test for 'create payment' was not cleaning up the payment table after each scenario. Over time, the auto-increment primary key reached the maximum integer value (2^31-1) in the test database, which was shared with a staging environment that fed into production reporting. The sequence exhausted, and new payment inserts failed with a constraint violation that propagated as a 503.
Fix
Added @After hooks to truncate all test tables after each scenario. Changed the test database to use a separate schema. Set the primary key sequence to use BIGINT and added monitoring for sequence usage.
Key lesson
  • Always clean up test data after each scenario, even if you think the database is ephemeral.
  • Never share a database between test and staging environments. Use separate schemas or containers.
  • Monitor sequence usage in databases that use auto-increment columns.
  • Use @DirtiesContext sparingly—it cleans the Spring context but not the database.
  • Write Cucumber hooks that explicitly truncate tables, not just delete rows (sequences don't reset on DELETE).
Production debug guideSymptom to Action5 entries
Symptom · 01
Tests pass locally but fail on CI
Fix
Check for environment-specific configuration (e.g., application-ci.properties). Ensure WireMock mappings are committed.
Symptom · 02
Test fails intermittently with 'Connection refused'
Fix
Verify that WireMock server is started before Cucumber scenarios. Use @WireMockTest or a JUnit 5 extension.
Symptom · 03
Scenario fails because of stale data from previous scenario
Fix
Add a @After hook that truncates tables or resets the database. Use @DirtiesContext only if you must restart the context.
Symptom · 04
Step definition method not found
Fix
Ensure glue code package is correctly specified in @CucumberOptions. Check for typos in step annotations.
Symptom · 05
Very slow test suite
Fix
Use @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) with TestRestTemplate. Avoid @SpringBootTest for every scenario—share context across scenarios.
★ Quick Debug Cheat SheetCommon Cucumber + Spring integration issues and immediate fixes
Test fails with 'No qualifying bean of type'
Immediate action
Check that the Spring context is loaded with correct configuration classes.
Commands
mvn test -Dspring.profiles.active=test
Add @ContextConfiguration(classes = {TestConfig.class})
Fix now
Ensure your Cucumber runner is annotated with @SpringBootTest.
Cucumber steps not matching+
Immediate action
Verify regex patterns in step definitions.
Commands
mvn test -Dcucumber.options="--glue com.example.steps"
Check feature file for extra spaces.
Fix now
Use Cucumber Expressions instead of regex for clarity.
Database state leaks between scenarios+
Immediate action
Add @After hook to truncate tables.
Commands
TRUNCATE TABLE payments RESTART IDENTITY CASCADE;
Use @DirtiesContext(classMode = BEFORE_EACH_TEST_METHOD) as last resort.
Fix now
Implement a dedicated cleanup service called in @After hook.
FeatureCucumber + SpringREST AssuredMockMvc
LanguageGherkin (plain English)Java DSLJava DSL
Integration with SpringFull Spring contextManual setupBuilt-in
Test real HTTP stackYes (with RANDOM_PORT)YesNo (mock servlet)
Business readabilityHighLowLow
Setup complexityMediumLowLow
Best forAcceptance tests, BDDAPI contract testsController unit tests
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
CucumberRunnerTest.java@SuiteSetting Up Cucumber with Spring Boot 3.2
payment.featureFeature: Payment ProcessingWriting Feature Files That Don't Suck
PaymentSteps.java@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)Wiring Spring Context with Cucumber Steps
CleanupHooks.javapublic class CleanupHooks {Managing Test Data and Cleanup with Hooks
WireMockConfig.java@ConfigurationStubbing External Services with WireMock
pom.xml snippetRunning Cucumber Tests in CI/CD

Key takeaways

1
Use JUnit 5 @Suite for Cucumber runner with Spring Boot 3.x.
2
Write feature files in business language; keep technical details in step definitions.
3
Use TestRestTemplate with WebEnvironment.RANDOM_PORT for realistic integration tests.
4
Always clean up database state in @After hooks using TRUNCATE.
5
Stub external services with WireMock on dynamic ports to avoid flakiness.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between `@SpringBootTest` with `WebEnvironment.RA...
Q02SENIOR
How do you ensure test isolation in Cucumber tests that share a database...
Q03SENIOR
Explain how Cucumber integrates with Spring's dependency injection conta...
Q01 of 03SENIOR

What is the difference between `@SpringBootTest` with `WebEnvironment.RANDOM_PORT` and `WebEnvironment.MOCK`?

ANSWER
RANDOM_PORT starts an embedded server on a random port and tests the full HTTP stack, including filters, serialization, and network. MOCK uses a mock servlet environment (MockMvc) which is faster but doesn't test the actual HTTP layer. Use RANDOM_PORT for integration tests and MOCK for controller unit tests.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Should I use `@SpringBootTest` for every Cucumber step class?
02
How do I pass data between steps in Cucumber?
03
Can I use Cucumber with WebFlux?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

4 min read · try the examples if you haven't

Previous
Handling URL Encoded Form Data in Spring REST Controllers
109 / 121 · Spring Boot
Next
Getting and Verifying JSON Response Data with REST-assured