Building scalable and maintainable acceptance tests requires more than just writing Gherkin scenarios – it demands thoughtful automation architecture. In our BDD blog series (part 1, part 2), we’ve walked through how to structure feature files, apply logic in the step definitions file, and extend the test framework with reusable utilities.
One piece that sparked a lot of feedback was our HTTP utility class. While functional, it was tightly coupled and heavily static, making it hard to extend or support security. Readers consistently asked:
How do we handle real-world security concerns like basic auth and bearer tokens in our tests?
In this post, we answer that question and extend our series with this blog post as a bonus read. We will explore the changes done in the application under test and walk through the refactored HTTP utility class into a clean and a more scalable approach for testing RESTful APIs with Basic Authentication.
The Application Under Test
The application under test in this blog post is the same as before — a simple Spring Boot application that manages bookings. You can check it out here.
Based on the feedback we received, we’ll now implement authentication and authorization in the booking app to align with the purpose of this post. But before we dive into the Java code, let’s take a moment to explore the difference between these two important concepts.
Authentication vs Authorization
We often hear these terms used together, but they serve distinct purposes. Authentication is all about verifying who the user is, essentially, proving their identity. On the other hand, Authorization determines what that authenticated user is allowed to do within the system. A helpful way to visualize this is to think of authentication as showing your ID badge at a building entrance, while authorization is what lets you access certain rooms based on your role or clearance level.
Here’s a quick summary:
| Concept | Description | Analogy |
|---|---|---|
| Authentication | Who are you? Verifying a user’s identity. | Showing your paid ticket to the stadium. |
| Authorization | What can you do? Granting access based on roles. | Getting access to a conference rooms, players rooms and historic club museums. No Stepping onto the pitch. |

Code Changes in the Booking App
Within the booking app, our application under test, we made three key changes to implement both authentication and authorization. Let’s walk through each of these changes in the order we developed them.
Maven Changes – pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
To enable security features in our Spring Boot application, we’ve added the spring-boot-starter-security maven dependency to our pom.xml. This brings in everything we need to get up and running with Spring Security, including authentication, authorization, and a default security filter chain. By including this dependency, we unlock the ability to secure our endpoints with minimal configuration, while still having the flexibility to customize the security behavior to fit our needs.
Basic Authentication – application.properties
spring.security.user.name=admin
spring.security.user.password=secret
spring.security.user.roles=USER
To keep things simple for our demo, we’ve configured an in-memory user directly in the application.properties file. By the above settings, we’re telling Spring Security to create a default user with the username admin, the password secret, and the role USER. This is where authentication is implemented. This allows us to quickly test authentication and role-based access control without setting up a custom user service or connecting to a database.
Authorization Setup – SecurityConfig.java
package io.qualitymatters.restfulapi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/bookings/**").hasRole("USER")
.requestMatchers(HttpMethod.POST, "/bookings/**").hasRole("USER")
.requestMatchers(HttpMethod.PUT, "/bookings/**").hasRole("USER")
.requestMatchers(HttpMethod.DELETE, "/bookings/**").hasRole("USER")
.anyRequest().authenticated()
)
.httpBasic(withDefaults())
.csrf(csrf -> csrf.disable())
.build();
}
}
To enforce role-based access control in our booking application, we created a SecurityConfig.java class that defines our security rules using Spring Security’s Java configuration style. This is where authorization is implemented. In this class, we expose a SecurityFilterChain bean that customizes how HTTP requests are authorized. Specifically, we restrict all GET, POST, PUT, and DELETE requests to the /bookings/** endpoints, allowing access only to users with the USER role. Any other request must also be authenticated. For simplicity, we use HTTP Basic authentication (httpBasic()), which prompts users to enter a username and password. We’ve also explicitly disabled CSRF protection since our API is stateless and doesn’t use cookies. This is a common best practice for RESTful services.
The Behavior-Driven Development (BDD) Tool
Our BDD Tool (also referred to as the automation framework) needs to accommodate the changes we’ve introduced in the booking app. As a result, during our first test run, all scenarios will fail with an HTTP response code 401, and that’s expected as no authentication is being handled within the tests.
Ideally, in a Scrum-based setup, tests should be written or updated before development begins. That’s the essence of Behavior-Driven Development (BDD). However, for the purpose of this example, we’ve intentionally reversed the order to walk our readers through the process of updating the HTTP utility class in response to the new authentication requirements.
All Scenarios are failed with a 401 due to the lack of Authentication Handling in the codebase, specifically the HTTP Utility class.
In order to mimic the above, you can checkout the below commit:
git checkout 0e82ea22c8ecfafaf6be17ef5efc74877204f684
Once tested and reproduced all failed tests, you can checkout back to main:
git checkout main
Refactoring the HTTPHelper.java file
The main change within the HTTPHelper.java file is that it is no longer a class, but an Interface!
As part of our automation framework, we’ve defined an HTTP Helper interface to standardize how we interact with the booking API in our acceptance tests. This utility acts as an abstraction layer for making HTTP requests, allowing us to send GET, POST, PUT, and DELETE calls, either with or without basic authentication credentials.
By overloading each method, we maintain flexibility: we can easily test both secured and unsecured endpoints without duplicating logic. We’ve also included a logResponseTime() method to capture and report performance metrics, which helps us monitor API responsiveness over time. Implementing this interface lets us centralize and manage our HTTP interactions in one place, promoting reusability and cleaner test code.
Implementing the HTTPHelper.java Interface
To implement our HTTPHelper interface, we created the OkHTTPHelper.java class using the popular OkHttp client library. This utility class gives us a flexible and reusable way to make HTTP calls in our acceptance tests. For each HTTP method, we’ve provided overloaded versions that support unauthenticated calls, basic authentication, or Bearer tokens. We can even set a global Bearer token when needed across multiple requests. A key method here is createRequestBuilder(), which takes care of setting the correct Authorization header based on the credentials or token provided.
All request execution logic is centralized in executeRequest() method, where we handle response validation and error reporting consistently. This design helps us maintain cleaner test code while staying adaptable to different security mechanisms.
OkHTTPHelper.java – Usage in BookingActions.java
The BookingActions.java class encapsulates all actions related to bookings — in other words, it’s where we handle all CRUD operations. Before refactoring, there was no need to instantiate the HTTPHelper class because all its methods were static. Let’s look at the original version of the getBookings() method in BookingActions.java. As you can see below, we used HTTPHelper directly without creating an instance:
public NestedBookingPojo getBookings() {
try {
String response = HTTPHelper.get(getBaseUrl() + BookingsUrl);
return mapper.readValue(response, NestedBookingPojo.class);
} catch (IOException e) {
System.err.println("Error deserializing response: " + e.getMessage());
throw new RuntimeException("Failed to deserialize bookings data", e);
}
}
With the introduction of the HTTPHelper interface and the new OkHTTPHelper implementation, we refactored this method to support instance-based calls, which also aligns better with dependency injection and future extensibility. Here’s the updated version:
private final OkHTTPHelper httpHelper = new OkHTTPHelper();
public NestedBookingPojo getBookings() {
try {
String response = httpHelper.get(getBaseUrl() + BookingsUrl, getUsername(), getPassword());
return mapper.readValue(response, NestedBookingPojo.class);
} catch (IOException e) {
System.err.println("Error deserializing response: " + e.getMessage());
throw new RuntimeException("Failed to deserialize bookings data", e);
}
}
With the implementation complete and our automated acceptance scenarios now capable of handling authentication, we can confidently re-run the tests — and see them pass as expected.
All Scenarios are now passing with a 200 since authentication is being handled gracefully.
Acceptance Test for Unauthorised Access
When it comes to acceptance tests, it’s not enough to focus solely on what should work, but we must also validate what shouldn’t work. That’s why including acceptance tests that cover unauthorised or unauthenticated access is it’s essential. These negative tests play a critical role in strengthening our authentication and authorisation mechanisms by helping us catch gaps that could otherwise be exploited.
To that end, we’ve introduced a dedicated scenario that specifically verifies the system’s ability to block access when no authentication credentials are provided. This test ensures that our booking endpoint enforces the expected security constraints, preventing unauthenticated users from retrieving sensitive data.
Here’s the scenario we’ve added:
@security
Scenario: Block Unauthenticated Access
When an unauthenticated user retrieves booking list
Then the user should be blocked with an error 401
By validating this failure path, we gain confidence that our authentication layer is doing its job, i.e. rejecting requests that lack valid credentials and returning a clear 401 Unauthorized status. It may seem like a small addition, but these types of checks provide immense value and are a cornerstone of building secure, reliable applications.
Conclusion
As we’ve seen throughout this blog post, building robust and adaptable acceptance tests goes beyond simply validating expected outcomes, as it requires us to think critically about how we structure our test architecture, especially when real-world concerns like authentication and authorization come into play.
By refactoring our static HTTPHelper into a proper interface and implementing it using OkHTTPHelper, we’ve laid the groundwork for a cleaner, more scalable approach to testing secured RESTful APIs. This not only improves test readability and maintainability but also gives us the flexibility to support multiple authentication strategies as our application evolves.
We hope this deep dive has given you practical insights into how to align your BDD test framework with evolving application requirements. As always, we look forward to hearing your feedback and continuing the conversation on how to build quality into every layer of our development process.

Leave a comment