REST API Testing with Cucumber and Spring: BDD Style Integration Tests
Learn BDD-style integration testing for Spring Boot REST APIs using Cucumber.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Java 17+
- ✓Spring Boot 3.2+
- ✓Maven or Gradle
- ✓Basic knowledge of REST APIs and Spring Boot
- ✓Familiarity with JUnit 5
- Use Cucumber with Spring Boot 3.2+ for BDD-style integration tests that bridge communication gaps.
- Avoid
@SpringBootTestin every scenario; prefer lightweight slices to keep tests fast. - Manage test state with shared Spring contexts and
Scenariohooks for cleanup. - WireMock is your friend for external service stubs; never call real APIs in integration tests.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
@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).
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.
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.
DELETE and then inserted with explicit IDs. They eventually hit duplicate key errors. TRUNCATE is the way.@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> ``
Then configure WireMock in your test context:
```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; } } ```
In your step definitions, you can stub responses:
``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.
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.
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.
The Phantom 503: How a Cucumber Test Leaked State and Took Down a Fintech API
@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.- 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
@DirtiesContextsparingly—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).
application-ci.properties). Ensure WireMock mappings are committed.@WireMockTest or a JUnit 5 extension.@After hook that truncates tables or resets the database. Use @DirtiesContext only if you must restart the context.@CucumberOptions. Check for typos in step annotations.@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) with TestRestTemplate. Avoid @SpringBootTest for every scenario—share context across scenarios.mvn test -Dspring.profiles.active=testAdd @ContextConfiguration(classes = {TestConfig.class})| File | Command / Code | Purpose |
|---|---|---|
| CucumberRunnerTest.java | @Suite | Setting Up Cucumber with Spring Boot 3.2 |
| payment.feature | Feature: Payment Processing | Writing Feature Files That Don't Suck |
| PaymentSteps.java | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | Wiring Spring Context with Cucumber Steps |
| CleanupHooks.java | public class CleanupHooks { | Managing Test Data and Cleanup with Hooks |
| WireMockConfig.java | @Configuration | Stubbing External Services with WireMock |
| pom.xml snippet | Running Cucumber Tests in CI/CD |
Key takeaways
@Suite for Cucumber runner with Spring Boot 3.x.TestRestTemplate with WebEnvironment.RANDOM_PORT for realistic integration tests.@After hooks using TRUNCATE.Interview Questions on This Topic
What is the difference between `@SpringBootTest` with `WebEnvironment.RANDOM_PORT` and `WebEnvironment.MOCK`?
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.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Boot. Mark it forged?
4 min read · try the examples if you haven't