REST Assured BDD Style: Given-When-Then for API Tests

REST Assured BDD Style: Given-When-Then for API Tests

REST Assured's API was designed around the Given-When-Then structure from Behavior-Driven Development. This isn't just a cosmetic choice — tests written in BDD style are executable documentation. They describe what an API is supposed to do in terms that developers, QA engineers, and product managers can all read.

This guide covers writing REST Assured tests in a fully idiomatic BDD style, and how to extend it to Cucumber for teams that want plain-English test scenarios.

The BDD Model for API Testing

BDD's Given-When-Then translates naturally to API tests:

  • Given — the preconditions: authentication, headers, request body, parameters
  • When — the action: the HTTP request (GET, POST, PUT, DELETE)
  • Then — the expected outcome: status code, response body, headers
given()                                         // Given
    .header("Authorization", "Bearer " + token)
    .contentType("application/json")
    .body("{\"name\": \"Alice\"}")
.when()                                         // When
    .post("/api/users")
.then()                                         // Then
    .statusCode(201)
    .body("name", equalTo("Alice"))
    .header("Location", containsString("/api/users/"));

This reads like a specification: Given a valid auth token and user data, when I create a user, then I get a 201 with the user's data and a Location header.

Naming Tests as Specifications

Test names should describe behavior, not implementation:

// Describes implementation — unclear intent
@Test
void postUsers201() { ... }

// Describes behavior — reads like a specification
@Test
void shouldCreateUserAndReturnLocationHeader() { ... }

@Test
void shouldReturn400WhenEmailIsInvalid() { ... }

@Test
void shouldReturn409WhenEmailAlreadyExists() { ... }

@Test
void shouldReturn401WhenTokenIsExpired() { ... }

This naming convention makes test output read as a specification document. A failing test immediately tells you what behavior broke.

Full BDD-Style Test Class

import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@DisplayName("User Management API")
class UserApiSpecification {

    private static String authToken;

    @BeforeAll
    static void setup() {
        RestAssured.baseURI = System.getenv("API_BASE_URL");
        authToken = obtainToken();
        RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
    }

    @Nested
    @DisplayName("GET /api/users/{id}")
    class GetUserById {

        @Test
        @DisplayName("should return user details for valid ID")
        void shouldReturnUserDetailsForValidId() {
            given()
                .header("Authorization", "Bearer " + authToken)
            .when()
                .get("/api/users/1")
            .then()
                .statusCode(200)
                .body("id", equalTo(1))
                .body("name", not(emptyString()))
                .body("email", containsString("@"));
        }

        @Test
        @DisplayName("should return 404 when user does not exist")
        void shouldReturn404WhenUserDoesNotExist() {
            given()
                .header("Authorization", "Bearer " + authToken)
            .when()
                .get("/api/users/99999")
            .then()
                .statusCode(404)
                .body("error", equalTo("User not found"))
                .body("id", equalTo(99999));
        }

        @Test
        @DisplayName("should return 401 when not authenticated")
        void shouldReturn401WhenNotAuthenticated() {
            given()
            .when()
                .get("/api/users/1")
            .then()
                .statusCode(401)
                .header("WWW-Authenticate", notNullValue());
        }
    }

    @Nested
    @DisplayName("POST /api/users")
    class CreateUser {

        @Test
        @DisplayName("should create user and return 201 with Location header")
        void shouldCreateUserAndReturnLocationHeader() {
            given()
                .header("Authorization", "Bearer " + authToken)
                .contentType(ContentType.JSON)
                .body("""
                    {
                        "name": "Alice Smith",
                        "email": "alice.smith@example.com"
                    }
                    """)
            .when()
                .post("/api/users")
            .then()
                .statusCode(201)
                .header("Location", matchesPattern(".*/api/users/\\d+"))
                .body("id", notNullValue())
                .body("name", equalTo("Alice Smith"))
                .body("email", equalTo("alice.smith@example.com"))
                .body("createdAt", notNullValue());
        }

        @Test
        @DisplayName("should return 400 when email is invalid")
        void shouldReturn400WhenEmailIsInvalid() {
            given()
                .header("Authorization", "Bearer " + authToken)
                .contentType(ContentType.JSON)
                .body("{\"name\": \"Alice\", \"email\": \"not-an-email\"}")
            .when()
                .post("/api/users")
            .then()
                .statusCode(400)
                .body("errors[0].field", equalTo("email"))
                .body("errors[0].message", containsString("valid email"));
        }

        @Test
        @DisplayName("should return 400 when name is blank")
        void shouldReturn400WhenNameIsBlank() {
            given()
                .header("Authorization", "Bearer " + authToken)
                .contentType(ContentType.JSON)
                .body("{\"name\": \"\", \"email\": \"valid@example.com\"}")
            .when()
                .post("/api/users")
            .then()
                .statusCode(400)
                .body("errors[0].field", equalTo("name"));
        }

        @Test
        @DisplayName("should return 409 when email already exists")
        void shouldReturn409WhenEmailAlreadyExists() {
            String email = "duplicate-" + System.currentTimeMillis() + "@example.com";
            String body = String.format("{\"name\":\"User\",\"email\":\"%s\"}", email);

            // Create first user
            given()
                .header("Authorization", "Bearer " + authToken)
                .contentType(ContentType.JSON)
                .body(body)
            .when()
                .post("/api/users")
            .then()
                .statusCode(201);

            // Attempt to create duplicate
            given()
                .header("Authorization", "Bearer " + authToken)
                .contentType(ContentType.JSON)
                .body(body)
            .when()
                .post("/api/users")
            .then()
                .statusCode(409)
                .body("error", containsString("already exists"));
        }
    }

    private static String obtainToken() {
        return given()
            .contentType(ContentType.JSON)
            .body(String.format("{\"email\":\"%s\",\"password\":\"%s\"}",
                System.getenv("TEST_EMAIL"),
                System.getenv("TEST_PASSWORD")))
        .when()
            .post("/api/auth/login")
        .then()
            .statusCode(200)
            .extract()
            .path("token");
    }
}

With JUnit 5's @DisplayName and @Nested, test output reads as structured documentation:

User Management API
  GET /api/users/{id}
    ✓ should return user details for valid ID
    ✓ should return 404 when user does not exist
    ✓ should return 401 when not authenticated
  POST /api/users
    ✓ should create user and return 201 with Location header
    ✓ should return 400 when email is invalid
    ✓ should return 400 when name is blank
    ✓ should return 409 when email already exists

Cucumber Integration

For teams that want tests in plain English, REST Assured integrates with Cucumber:

Dependencies

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>7.15.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit-platform-engine</artifactId>
    <version>7.15.0</version>
    <scope>test</scope>
</dependency>

Feature File

# src/test/resources/features/users.feature

Feature: User Management API
  
  Scenario: Get existing user
    Given I have a valid authentication token
    When I request user with ID 1
    Then the response status should be 200
    And the response should contain "name" field
    And the response should contain a valid email address

  Scenario: Create a new user
    Given I have a valid authentication token
    And I have the following user data:
      | name  | email               |
      | Alice | alice@example.com   |
    When I create a new user
    Then the response status should be 201
    And the response should contain a Location header
    And the user should be retrievable at that location

  Scenario Outline: Handle invalid user creation
    Given I have a valid authentication token
    When I create a user with name "<name>" and email "<email>"
    Then the response status should be <status>
    And the error should mention "<field>"

    Examples:
      | name  | email         | status | field |
      |       | a@example.com | 400    | name  |
      | Alice | invalid-email | 400    | email |
      | Alice |               | 400    | email |

Step Definitions

// src/test/java/steps/UserApiSteps.java
import io.cucumber.java.en.*;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;

import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
import static org.assertj.core.api.Assertions.*;

public class UserApiSteps {

    private String authToken;
    private Response lastResponse;
    private String userName;
    private String userEmail;

    @Given("I have a valid authentication token")
    public void iHaveAValidAuthenticationToken() {
        authToken = given()
            .contentType("application/json")
            .body("{\"email\":\"" + System.getenv("TEST_EMAIL") + 
                  "\",\"password\":\"" + System.getenv("TEST_PASSWORD") + "\"}")
        .when()
            .post("/api/auth/login")
        .then()
            .statusCode(200)
            .extract()
            .path("token");
    }

    @When("I request user with ID {int}")
    public void iRequestUserWithId(int userId) {
        lastResponse = given()
            .header("Authorization", "Bearer " + authToken)
        .when()
            .get("/api/users/" + userId)
        .andReturn();
    }

    @When("I create a new user")
    public void iCreateANewUser() {
        lastResponse = given()
            .header("Authorization", "Bearer " + authToken)
            .contentType("application/json")
            .body(String.format("{\"name\":\"%s\",\"email\":\"%s\"}", userName, userEmail))
        .when()
            .post("/api/users")
        .andReturn();
    }

    @When("I create a user with name {string} and email {string}")
    public void iCreateAUserWithNameAndEmail(String name, String email) {
        lastResponse = given()
            .header("Authorization", "Bearer " + authToken)
            .contentType("application/json")
            .body(String.format("{\"name\":\"%s\",\"email\":\"%s\"}", name, email))
        .when()
            .post("/api/users")
        .andReturn();
    }

    @Given("I have the following user data:")
    public void iHaveTheFollowingUserData(io.cucumber.datatable.DataTable dataTable) {
        var data = dataTable.asMaps().get(0);
        userName = data.get("name");
        userEmail = data.get("email");
    }

    @Then("the response status should be {int}")
    public void theResponseStatusShouldBe(int expectedStatus) {
        assertThat(lastResponse.getStatusCode())
            .describedAs("Response status code")
            .isEqualTo(expectedStatus);
    }

    @Then("the response should contain {string} field")
    public void theResponseShouldContainField(String fieldName) {
        assertThat(lastResponse.jsonPath().get(fieldName))
            .describedAs("Response field: " + fieldName)
            .isNotNull();
    }

    @Then("the response should contain a valid email address")
    public void theResponseShouldContainAValidEmailAddress() {
        String email = lastResponse.jsonPath().getString("email");
        assertThat(email)
            .matches(".*@.*\\..*")
            .isNotBlank();
    }

    @Then("the response should contain a Location header")
    public void theResponseShouldContainALocationHeader() {
        assertThat(lastResponse.getHeader("Location"))
            .isNotNull()
            .contains("/api/users/");
    }

    @Then("the error should mention {string}")
    public void theErrorShouldMention(String fieldName) {
        String errorBody = lastResponse.getBody().asString();
        assertThat(errorBody).contains(fieldName);
    }

    @Then("the user should be retrievable at that location")
    public void theUserShouldBeRetrievableAtThatLocation() {
        String location = lastResponse.getHeader("Location");
        assertThat(location).isNotNull();

        given()
            .header("Authorization", "Bearer " + authToken)
        .when()
            .get(location)
        .then()
            .statusCode(200)
            .body("name", equalTo(userName))
            .body("email", equalTo(userEmail));
    }
}

Runner Class

// src/test/java/CucumberRunner.java
import io.cucumber.junit.platform.engine.Constants;
import org.junit.platform.suite.api.*;

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(
    key = Constants.PLUGIN_PROPERTY_NAME,
    value = "pretty, junit:target/cucumber-reports/Cucumber.xml"
)
public class CucumberRunner {}

Run Cucumber tests:

mvn test -Dtest=CucumberRunner

Documenting API Behavior

BDD-style tests become living documentation when:

  1. Test names describe behavior, not technical actions
  2. Failure messages explain intent — "should return 409 when email already exists" tells you the expected behavior when a test fails
  3. Scenarios cover edge cases — each scenario documents a decision about how the API should behave
  4. Tests run in CI — if a test breaks, you know immediately which documented behavior is violated

Generate HTML documentation from test results:

<!-- pom.xml — Surefire report -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-report-plugin</artifactId>
    <version>3.2.5</version>
    <executions>
        <execution>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

For Cucumber tests, the HTML report at target/cucumber-reports/ provides scenario-by-scenario pass/fail with readable descriptions.

Summary

REST Assured's Given-When-Then syntax isn't just syntactic sugar — it's a deliberate design that encourages writing tests as specifications. When tests read like requirements, they serve two purposes: verifying behavior and documenting it.

Key BDD practices for REST Assured:

  1. Name tests as behavior specifications ("should return 409 when email exists")
  2. Use @Nested + @DisplayName to organize by endpoint and behavior
  3. Group scenarios by HTTP method and expected outcome
  4. Write one test per behavior — not one test per field
  5. Make assertions describe intent, not just match values

Combine this approach with REST Assured's TestNG integration for group-based test organization, and Spring Boot integration for testing your own APIs at the controller layer.

Read more