BDD doesn't stop at the browser. The same Gherkin feature files that describe UI behaviour can describe API behaviour — and the non-technical readability benefit applies just as strongly. A product owner can review When I create a user via POST /users and verify it matches their understanding of the API contract, without knowing what RequestSpecification means.
This lesson wires Cucumber to Rest Assured. The feature files and runner are identical to UI scenarios. The step definitions call Rest Assured instead of Selenium.
API BDD feature files
@api
Feature: User Management API
Background:
Given the API is available at the base URL
@smoke
Scenario: Create a new user successfully
When I send a POST request to "/users" with:
| name | Alice Smith |
| email | alice@test.com |
| role | admin |
Then the response status code should be 201
And the response should contain field "id"
And the response field "name" should equal "Alice Smith"
Scenario: Get a user by ID
Given a user exists with name "Bob" and email "bob@test.com"
When I send a GET request to "/users/{userId}"
Then the response status code should be 200
And the response field "name" should equal "Bob"
Scenario: Delete a user
Given a user exists with name "Carol" and email "carol@test.com"
When I send a DELETE request to "/users/{userId}"
Then the response status code should be 204Notice the {userId} in the path. The Given a user exists... step creates the user, stores the returned ID in TestContext, and the subsequent When step retrieves it. This is the scenario-context pattern from the DI lesson applied to API testing.
The TestContext for API state
public class TestContext {
// ... existing driver, authToken fields ...
private Response lastResponse;
private String lastCreatedId;
public Response getLastResponse() { return lastResponse; }
public void setLastResponse(Response response) { this.lastResponse = response; }
public String getLastCreatedId() { return lastCreatedId; }
public void setLastCreatedId(String id) { this.lastCreatedId = id; }
}Storing the Response object in context lets any subsequent step access the status code, headers, or body — regardless of which step definition class makes the assertion.
API step definitions with Rest Assured
package stepdefinitions;
import context.TestContext;
import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.And;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
import java.util.Map;
import static io.restassured.RestAssured.*;
import static org.junit.jupiter.api.Assertions.*;
public class UserApiSteps {
private final TestContext context;
public UserApiSteps(TestContext context) {
this.context = context;
}
@Given("the API is available at the base URL")
public void apiIsAvailable() {
baseURI = System.getProperty("apiBaseUrl", "https://reqres.in/api");
}
@When("I send a POST request to {string} with:")
public void postWithBody(String endpoint, DataTable dataTable) {
Map<String, String> body = dataTable.asMap();
Response response = given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(endpoint);
context.setLastResponse(response);
if (response.getStatusCode() == 201) {
context.setLastCreatedId(response.jsonPath().getString("id"));
}
}
@When("I send a GET request to {string}")
public void getRequest(String endpoint) {
String resolvedPath = endpoint.replace("{userId}", context.getLastCreatedId());
Response response = given()
.when()
.get(resolvedPath);
context.setLastResponse(response);
}
@When("I send a DELETE request to {string}")
public void deleteRequest(String endpoint) {
String resolvedPath = endpoint.replace("{userId}", context.getLastCreatedId());
Response response = given()
.when()
.delete(resolvedPath);
context.setLastResponse(response);
}
@Given("a user exists with name {string} and email {string}")
public void createUserForTest(String name, String email) {
Response response = given()
.contentType(ContentType.JSON)
.body(Map.of("name", name, "email", email))
.when()
.post("/users");
context.setLastCreatedId(response.jsonPath().getString("id"));
}
@Then("the response status code should be {int}")
public void verifyStatusCode(int expected) {
assertEquals(expected, context.getLastResponse().getStatusCode(),
"Unexpected status code. Body: " + context.getLastResponse().getBody().asString());
}
@And("the response should contain field {string}")
public void responseContainsField(String fieldName) {
assertNotNull(context.getLastResponse().jsonPath().get(fieldName),
"Field '" + fieldName + "' was not found in response: "
+ context.getLastResponse().getBody().asString());
}
@And("the response field {string} should equal {string}")
public void responseFieldEquals(String field, String expected) {
String actual = context.getLastResponse().jsonPath().getString(field);
assertEquals(expected, actual,
"Field '" + field + "' expected '" + expected + "' but was '" + actual + "'");
}
}Mixing UI and API steps in one scenario
The real power of a unified BDD framework: you can combine UI and API steps in a single scenario when the test requires it:
@ui @api
Scenario: User created via API appears in the admin dashboard
Given a user is created via API with name "Alice" and email "alice@test.com"
When the admin navigates to the user management page
Then "Alice" should appear in the user listThe Given step runs a Rest Assured POST. The When and Then steps use Selenium. Both step definition classes receive the same TestContext. The @Before("@ui") hook starts the browser; the API step fires without browser overhead. Tags @ui @api ensure both setup hooks run.
This pattern is particularly useful for test data setup — creating the API state programmatically is faster and more reliable than clicking through a UI to create it.
UI BDD vs API BDD — same Gherkin, different step implementations
Cucumber + Selenium (UI BDD)
Tag: @ui
@Before: starts ChromeDriver
Step definitions call Page Objects
Page Objects call WebDriver APIs
driver.findElement(), sendKeys(), click()
WebDriverWait for async content
Screenshot on failure via Scenario.attach()
Slower (seconds per scenario)
Cucumber + Rest Assured (API BDD)
Tag: @api
@Before: sets RestAssured.baseURI
Step definitions call given().when().then()
Response stored in TestContext
jsonPath().getString(), statusCode()
No waits needed — synchronous HTTP
Response body logged on failure
Faster (milliseconds per scenario)
⚠️ Common mistakes
- Storing
Responseas a local variable in the@Whenstep, then trying to access it in@Then. Local variables don't survive across step method calls — storeResponseinTestContextand retrieve it in subsequent steps. - Hardcoding the base URL in each step definition.
given().baseUri("https://prod.api.myapp.com")couples tests to a specific environment. Read the base URL from a system property (System.getProperty("apiBaseUrl")) or an environment variable, passed via Maven-DapiBaseUrl=...or CI environment config. - Not enabling failure logging.
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails()should be in the@Before("@api")hook. Without it, a failed status code assertion gives you "Expected 200 but was 500" with no body — not enough to debug in CI. - Sharing a
RequestSpecificationstatic field. Like the driver, a staticRequestSpecpersists across scenarios. Build it fresh per scenario (in the@Beforehook or the firstGivenstep) and store it inTestContext.
🎯 Practice task
Write a complete API BDD suite against a public test API. 40–50 minutes. Use reqres.in — a free API that supports GET, POST, PUT, DELETE.
- Add
rest-assuredto yourpom.xml. CreateUserApiSteps.javausing the code from this lesson. - Write
users-api.featurewith 4 scenarios: create user, get user, update user (PUT), delete user. Tag them all@api. - Add
@Before("@api")toHooks.javathat setsRestAssured.baseURI = "https://reqres.in/api"and enables failure logging. - Run
mvn test -Dcucumber.filter.tags="@api"— all 4 scenarios should pass. - Deliberately assert the wrong status code (e.g., expect
200for a201response). Run again — confirm the failure message includes the response body. - Write a fifth scenario that chains two API calls:
Given a user exists(POST) →When I get the user by ID(GET) →Then the name should match. Confirm the ID is passed correctly throughTestContext. - Stretch: write a scenario tagged both
@uiand@api: create a user via the API inGiven, then navigate to a hypothetical admin page inWhen/Thenusing a page object. Even if the admin UI doesn't exist, verify that both the API hook (@Before("@api")) and the UI hook (@Before("@ui")) both fire for a scenario with both tags.
Next lesson: running Cucumber scenarios in parallel for faster suite execution.