Saltar al contenido principal

Comprehensive REST API Testing with Serenity BDD

This tutorial provides a complete guide to testing REST APIs with Serenity BDD, covering everything from basic requests to advanced patterns like authentication, error handling, schema validation, and hybrid UI+API testing.

All examples use real, publicly available testing APIs that you can use to practice.

Testing APIs Used

APIURLPurpose
JSONPlaceholderhttps://jsonplaceholder.typicode.comFree fake API for basic CRUD
ReqReshttps://reqres.inUser management with auth examples
Restful-Bookerhttps://restful-booker.herokuapp.comHotel bookings with token auth

Project Setup

Maven Dependencies

<properties>
<serenity.version>5.2.2</serenity.version>
</properties>

<dependencies>
<!-- Core Serenity -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-core</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- Serenity REST Assured integration -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-rest-assured</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- Serenity Screenplay REST (for Screenplay pattern) -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-screenplay-rest</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- JUnit 5 -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-junit5</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- JSON Schema validation -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-schema-validator</artifactId>
<version>5.3.2</version>
<scope>test</scope>
</dependency>
</dependencies>

Gradle Dependencies

dependencies {
testImplementation "net.serenity-bdd:serenity-core:5.2.2"
testImplementation "net.serenity-bdd:serenity-rest-assured:5.2.2"
testImplementation "net.serenity-bdd:serenity-screenplay-rest:5.2.2"
testImplementation "net.serenity-bdd:serenity-junit5:5.2.2"
testImplementation "io.rest-assured:json-schema-validator:5.3.2"
}

Configuration (serenity.conf)

# Base URLs for different environments
restapi {
jsonplaceholder = "https://jsonplaceholder.typicode.com"
reqres = "https://reqres.in/api"
booker = "https://restful-booker.herokuapp.com"
}

# Request logging
serenity {
logging = VERBOSE
}

# REST Assured settings
restassured {
# Log all requests and responses
log.all = true
}

Part 1: Basic REST Operations

Simple GET Request

Let's start with the simplest possible API test - fetching a resource:

package com.example.api;

import net.serenitybdd.junit5.SerenityJUnit5Extension;
import net.serenitybdd.rest.SerenityRest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import static net.serenitybdd.rest.SerenityRest.given;
import static org.hamcrest.Matchers.*;

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("Basic REST Operations")
class BasicRestTest {

private static final String BASE_URL = "https://jsonplaceholder.typicode.com";

@Test
@DisplayName("Should fetch a single user")
void shouldFetchSingleUser() {
given()
.baseUri(BASE_URL)
.when()
.get("/users/1")
.then()
.statusCode(200)
.body("id", equalTo(1))
.body("name", equalTo("Leanne Graham"))
.body("email", equalTo("Sincere@april.biz"));
}

@Test
@DisplayName("Should fetch all users")
void shouldFetchAllUsers() {
given()
.baseUri(BASE_URL)
.when()
.get("/users")
.then()
.statusCode(200)
.body("size()", equalTo(10))
.body("name", hasItems("Leanne Graham", "Ervin Howell"));
}
}

POST Request - Creating Resources

@Test
@DisplayName("Should create a new post")
void shouldCreateNewPost() {
String requestBody = """
{
"title": "My New Post",
"body": "This is the content of my post",
"userId": 1
}
""";

given()
.baseUri(BASE_URL)
.contentType("application/json")
.body(requestBody)
.when()
.post("/posts")
.then()
.statusCode(201)
.body("title", equalTo("My New Post"))
.body("id", notNullValue());
}

Using POJOs for Request/Response

Create model classes for cleaner code:

// Post.java
public class Post {
private Integer id;
private String title;
private String body;
private Integer userId;

// Default constructor for JSON deserialization
public Post() {}

public Post(String title, String body, Integer userId) {
this.title = title;
this.body = body;
this.userId = userId;
}

// Getters and setters
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getBody() { return body; }
public void setBody(String body) { this.body = body; }
public Integer getUserId() { return userId; }
public void setUserId(Integer userId) { this.userId = userId; }
}
@Test
@DisplayName("Should create post using POJO")
void shouldCreatePostUsingPojo() {
Post newPost = new Post("POJO Title", "Content from POJO", 1);

Post createdPost = given()
.baseUri(BASE_URL)
.contentType("application/json")
.body(newPost)
.when()
.post("/posts")
.then()
.statusCode(201)
.extract()
.as(Post.class);

assertThat(createdPost.getTitle()).isEqualTo("POJO Title");
assertThat(createdPost.getId()).isNotNull();
}

PUT and PATCH Requests

@Test
@DisplayName("Should update entire post with PUT")
void shouldUpdatePostWithPut() {
Post updatedPost = new Post("Updated Title", "Updated Body", 1);

given()
.baseUri(BASE_URL)
.contentType("application/json")
.body(updatedPost)
.when()
.put("/posts/1")
.then()
.statusCode(200)
.body("title", equalTo("Updated Title"));
}

@Test
@DisplayName("Should partially update post with PATCH")
void shouldPartiallyUpdateWithPatch() {
given()
.baseUri(BASE_URL)
.contentType("application/json")
.body("{\"title\": \"Patched Title\"}")
.when()
.patch("/posts/1")
.then()
.statusCode(200)
.body("title", equalTo("Patched Title"))
.body("body", notNullValue()); // Original body preserved
}

DELETE Request

@Test
@DisplayName("Should delete a post")
void shouldDeletePost() {
given()
.baseUri(BASE_URL)
.when()
.delete("/posts/1")
.then()
.statusCode(200);
}

Part 2: Screenplay Pattern for API Testing

The Screenplay pattern provides a more maintainable, actor-centric approach:

Setting Up Actors with API Abilities

package com.example.api.screenplay;

import net.serenitybdd.junit5.SerenityJUnit5Extension;
import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.rest.abilities.CallAnApi;
import net.serenitybdd.screenplay.rest.interactions.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;

import static net.serenitybdd.screenplay.rest.questions.ResponseConsequence.seeThatResponse;
import static org.hamcrest.Matchers.*;

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("Screenplay REST API Tests")
class ScreenplayRestTest {

private static final String BASE_URL = "https://jsonplaceholder.typicode.com";

Actor alex;

@BeforeEach
void setUp() {
alex = Actor.named("Alex the API tester")
.whoCan(CallAnApi.at(BASE_URL));
}

@Test
@DisplayName("Should fetch user with Screenplay")
void shouldFetchUserWithScreenplay() {
alex.attemptsTo(
Get.resource("/users/1")
);

alex.should(
seeThatResponse("User details are correct",
response -> response
.statusCode(200)
.body("name", equalTo("Leanne Graham"))
)
);
}

@Test
@DisplayName("Should create post with Screenplay")
void shouldCreatePostWithScreenplay() {
alex.attemptsTo(
Post.to("/posts")
.with(request -> request
.contentType("application/json")
.body(new Post("Screenplay Post", "Content", 1))
)
);

alex.should(
seeThatResponse("Post was created",
response -> response
.statusCode(201)
.body("title", equalTo("Screenplay Post"))
)
);
}
}

Creating Reusable Tasks

Encapsulate API operations in reusable tasks:

// tasks/CreatePost.java
package com.example.api.tasks;

import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.Task;
import net.serenitybdd.screenplay.rest.interactions.Post;
import net.thucydides.core.annotations.Step;
import com.example.api.model.Post;

import static net.serenitybdd.screenplay.Tasks.instrumented;

public class CreatePost implements Task {

private final Post post;

public CreatePost(Post post) {
this.post = post;
}

public static CreatePost withDetails(String title, String body, int userId) {
return instrumented(CreatePost.class, new Post(title, body, userId));
}

public static CreatePost withTitle(String title) {
return instrumented(CreatePost.class, new Post(title, "Default body", 1));
}

@Override
@Step("{0} creates a new post titled '#post.title'")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Post.to("/posts")
.with(request -> request
.contentType("application/json")
.body(post)
)
);
}
}
// tasks/FetchUser.java
package com.example.api.tasks;

import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.Task;
import net.serenitybdd.screenplay.rest.interactions.Get;
import net.thucydides.core.annotations.Step;

import static net.serenitybdd.screenplay.Tasks.instrumented;

public class FetchUser implements Task {

private final int userId;

public FetchUser(int userId) {
this.userId = userId;
}

public static FetchUser withId(int userId) {
return instrumented(FetchUser.class, userId);
}

@Override
@Step("{0} fetches user with ID #userId")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Get.resource("/users/{id}")
.with(request -> request.pathParam("id", userId))
);
}
}

Using the tasks:

@Test
@DisplayName("Should use custom tasks for API operations")
void shouldUseCustomTasks() {
alex.attemptsTo(
CreatePost.withDetails("Task-based Post", "Content from task", 1)
);

alex.should(
seeThatResponse("Post was created successfully",
response -> response.statusCode(201)
)
);

alex.attemptsTo(
FetchUser.withId(1)
);

alex.should(
seeThatResponse("User was retrieved",
response -> response
.statusCode(200)
.body("name", notNullValue())
)
);
}

Creating Reusable Questions

// questions/TheUser.java
package com.example.api.questions;

import net.serenitybdd.rest.SerenityRest;
import net.serenitybdd.screenplay.Question;
import com.example.api.model.User;

public class TheUser {

public static Question<User> details() {
return actor -> SerenityRest.lastResponse()
.jsonPath()
.getObject("", User.class);
}

public static Question<String> name() {
return actor -> SerenityRest.lastResponse()
.jsonPath()
.getString("name");
}

public static Question<String> email() {
return actor -> SerenityRest.lastResponse()
.jsonPath()
.getString("email");
}
}
@Test
@DisplayName("Should use questions to query responses")
void shouldUseQuestionsToQueryResponses() {
alex.attemptsTo(
FetchUser.withId(1)
);

String userName = alex.asksFor(TheUser.name());
assertThat(userName).isEqualTo("Leanne Graham");

User user = alex.asksFor(TheUser.details());
assertThat(user.getEmail()).isEqualTo("Sincere@april.biz");
}

Part 3: Authentication Patterns

Basic Authentication

@Test
@DisplayName("Should authenticate with Basic Auth")
void shouldAuthenticateWithBasicAuth() {
given()
.baseUri("https://httpbin.org")
.auth().basic("user", "passwd")
.when()
.get("/basic-auth/user/passwd")
.then()
.statusCode(200)
.body("authenticated", equalTo(true));
}

Bearer Token Authentication

Using the Restful-Booker API for token-based auth:

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("Token Authentication Tests")
class TokenAuthTest {

private static final String BOOKER_URL = "https://restful-booker.herokuapp.com";
private String authToken;

@BeforeEach
void authenticate() {
// Get authentication token
authToken = given()
.baseUri(BOOKER_URL)
.contentType("application/json")
.body("""
{
"username": "admin",
"password": "password123"
}
""")
.when()
.post("/auth")
.then()
.statusCode(200)
.extract()
.path("token");

assertThat(authToken).isNotNull();
}

@Test
@DisplayName("Should use token for authenticated requests")
void shouldUseTokenForAuthenticatedRequests() {
// Create a booking
String bookingBody = """
{
"firstname": "John",
"lastname": "Doe",
"totalprice": 150,
"depositpaid": true,
"bookingdates": {
"checkin": "2024-01-01",
"checkout": "2024-01-05"
},
"additionalneeds": "Breakfast"
}
""";

int bookingId = given()
.baseUri(BOOKER_URL)
.contentType("application/json")
.body(bookingBody)
.when()
.post("/booking")
.then()
.statusCode(200)
.extract()
.path("bookingid");

// Update booking using token
given()
.baseUri(BOOKER_URL)
.contentType("application/json")
.cookie("token", authToken) // Token in cookie
.body("""
{
"firstname": "Jane",
"lastname": "Doe",
"totalprice": 200,
"depositpaid": true,
"bookingdates": {
"checkin": "2024-01-01",
"checkout": "2024-01-05"
},
"additionalneeds": "Dinner"
}
""")
.when()
.put("/booking/" + bookingId)
.then()
.statusCode(200)
.body("firstname", equalTo("Jane"));
}

@Test
@DisplayName("Should use Authorization header for token")
void shouldUseAuthorizationHeader() {
given()
.baseUri(BOOKER_URL)
.header("Authorization", "Bearer " + authToken)
.when()
.get("/booking")
.then()
.statusCode(200);
}
}

API Key Authentication

@Test
@DisplayName("Should authenticate with API key in header")
void shouldAuthenticateWithApiKey() {
given()
.baseUri("https://api.example.com")
.header("X-API-Key", "your-api-key-here")
.when()
.get("/protected-resource")
.then()
.statusCode(200);
}

@Test
@DisplayName("Should authenticate with API key in query parameter")
void shouldAuthenticateWithApiKeyInQuery() {
given()
.baseUri("https://api.example.com")
.queryParam("api_key", "your-api-key-here")
.when()
.get("/protected-resource")
.then()
.statusCode(200);
}

Screenplay Authentication Task

// tasks/Authenticate.java
package com.example.api.tasks;

import net.serenitybdd.rest.SerenityRest;
import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.Task;
import net.serenitybdd.screenplay.rest.interactions.Post;
import net.thucydides.core.annotations.Step;

import static net.serenitybdd.screenplay.Tasks.instrumented;

public class Authenticate implements Task {

private final String username;
private final String password;

public Authenticate(String username, String password) {
this.username = username;
this.password = password;
}

public static Authenticate withCredentials(String username, String password) {
return instrumented(Authenticate.class, username, password);
}

public static Authenticate asAdmin() {
return instrumented(Authenticate.class, "admin", "password123");
}

@Override
@Step("{0} authenticates as #username")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Post.to("/auth")
.with(request -> request
.contentType("application/json")
.body(String.format("""
{"username": "%s", "password": "%s"}
""", username, password))
)
);

String token = SerenityRest.lastResponse().path("token");
actor.remember("authToken", token);
}
}

Using the authentication task:

@Test
@DisplayName("Should authenticate and use token in subsequent requests")
void shouldAuthenticateAndUseToken() {
Actor admin = Actor.named("Admin")
.whoCan(CallAnApi.at(BOOKER_URL));

admin.attemptsTo(
Authenticate.asAdmin()
);

String token = admin.recall("authToken");
assertThat(token).isNotNull();

// Use token in subsequent request
admin.attemptsTo(
Get.resource("/booking")
.with(request -> request.cookie("token", token))
);

admin.should(
seeThatResponse(response -> response.statusCode(200))
);
}

Part 4: Error Handling and Validation

Testing Error Responses

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("Error Handling Tests")
class ErrorHandlingTest {

private static final String BASE_URL = "https://jsonplaceholder.typicode.com";

@Test
@DisplayName("Should handle 404 Not Found")
void shouldHandle404NotFound() {
given()
.baseUri(BASE_URL)
.when()
.get("/users/99999")
.then()
.statusCode(404);
}

@Test
@DisplayName("Should handle invalid request body")
void shouldHandleInvalidRequestBody() {
given()
.baseUri("https://reqres.in/api")
.contentType("application/json")
.body("{invalid json}")
.when()
.post("/users")
.then()
.statusCode(anyOf(equalTo(400), equalTo(500)));
}

@Test
@DisplayName("Should verify error message content")
void shouldVerifyErrorMessageContent() {
given()
.baseUri("https://reqres.in/api")
.when()
.post("/login") // Missing required fields
.then()
.statusCode(400)
.body("error", equalTo("Missing email or username"));
}
}

Testing Multiple Status Codes

@ParameterizedTest
@CsvSource({
"1, 200",
"2, 200",
"99999, 404"
})
@DisplayName("Should return correct status for user ID")
void shouldReturnCorrectStatusForUserId(int userId, int expectedStatus) {
given()
.baseUri("https://reqres.in/api")
.when()
.get("/users/" + userId)
.then()
.statusCode(expectedStatus);
}

JSON Schema Validation

Create a schema file at src/test/resources/schemas/user-schema.json:

{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["data"],
"properties": {
"data": {
"type": "object",
"required": ["id", "email", "first_name", "last_name", "avatar"],
"properties": {
"id": {
"type": "integer"
},
"email": {
"type": "string",
"format": "email"
},
"first_name": {
"type": "string",
"minLength": 1
},
"last_name": {
"type": "string",
"minLength": 1
},
"avatar": {
"type": "string",
"format": "uri"
}
}
}
}
}
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;

@Test
@DisplayName("Should match JSON schema")
void shouldMatchJsonSchema() {
given()
.baseUri("https://reqres.in/api")
.when()
.get("/users/1")
.then()
.statusCode(200)
.body(matchesJsonSchemaInClasspath("schemas/user-schema.json"));
}

Response Time Validation

import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.concurrent.TimeUnit.MILLISECONDS;

@Test
@DisplayName("Should respond within acceptable time")
void shouldRespondWithinAcceptableTime() {
given()
.baseUri(BASE_URL)
.when()
.get("/users")
.then()
.statusCode(200)
.time(lessThan(5L), SECONDS);
}

@Test
@DisplayName("Should track response time")
void shouldTrackResponseTime() {
long responseTime = given()
.baseUri(BASE_URL)
.when()
.get("/users")
.then()
.extract()
.time();

System.out.println("Response time: " + responseTime + "ms");
assertThat(responseTime).isLessThan(5000);
}

Part 5: Chained API Calls

Often you need to use data from one API call in subsequent calls:

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("Chained API Calls")
class ChainedApiTest {

private static final String BASE_URL = "https://jsonplaceholder.typicode.com";

@Test
@DisplayName("Should create post and then fetch it")
void shouldCreatePostAndThenFetchIt() {
// Step 1: Create a new post
Post newPost = new Post("Chained Test", "Testing chained calls", 1);

Integer postId = given()
.baseUri(BASE_URL)
.contentType("application/json")
.body(newPost)
.when()
.post("/posts")
.then()
.statusCode(201)
.extract()
.path("id");

// Step 2: Fetch the created post
given()
.baseUri(BASE_URL)
.when()
.get("/posts/" + postId)
.then()
.statusCode(200)
.body("title", equalTo("Chained Test"));
}

@Test
@DisplayName("Should fetch user and then their posts")
void shouldFetchUserAndTheirPosts() {
// Step 1: Get user
int userId = given()
.baseUri(BASE_URL)
.when()
.get("/users/1")
.then()
.statusCode(200)
.extract()
.path("id");

// Step 2: Get posts by that user
given()
.baseUri(BASE_URL)
.queryParam("userId", userId)
.when()
.get("/posts")
.then()
.statusCode(200)
.body("size()", greaterThan(0))
.body("userId", everyItem(equalTo(userId)));
}

@Test
@DisplayName("Should perform CRUD lifecycle")
void shouldPerformCrudLifecycle() {
// CREATE
Integer postId = given()
.baseUri(BASE_URL)
.contentType("application/json")
.body(new Post("CRUD Test", "Initial content", 1))
.when()
.post("/posts")
.then()
.statusCode(201)
.extract()
.path("id");

System.out.println("Created post with ID: " + postId);

// READ
given()
.baseUri(BASE_URL)
.when()
.get("/posts/" + postId)
.then()
.statusCode(200)
.body("title", equalTo("CRUD Test"));

// UPDATE
given()
.baseUri(BASE_URL)
.contentType("application/json")
.body(new Post("Updated CRUD Test", "Updated content", 1))
.when()
.put("/posts/" + postId)
.then()
.statusCode(200)
.body("title", equalTo("Updated CRUD Test"));

// DELETE
given()
.baseUri(BASE_URL)
.when()
.delete("/posts/" + postId)
.then()
.statusCode(200);
}
}

Screenplay Chained Calls

@Test
@DisplayName("Should chain API calls with Screenplay")
void shouldChainApiCallsWithScreenplay() {
Actor tester = Actor.named("API Tester")
.whoCan(CallAnApi.at(BASE_URL));

// Create post and remember the ID
tester.attemptsTo(
Post.to("/posts")
.with(request -> request
.contentType("application/json")
.body(new Post("Screenplay Chain", "Content", 1))
)
);

Integer postId = SerenityRest.lastResponse().path("id");
tester.remember("postId", postId);

// Fetch the post using remembered ID
tester.attemptsTo(
Get.resource("/posts/{id}")
.with(request -> request.pathParam("id", tester.recall("postId")))
);

tester.should(
seeThatResponse(response -> response
.statusCode(200)
.body("title", equalTo("Screenplay Chain"))
)
);
}

Part 6: Data-Driven API Testing

Parameterized Tests

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("Data-Driven API Tests")
class DataDrivenApiTest {

private static final String BASE_URL = "https://jsonplaceholder.typicode.com";

@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
@DisplayName("Should fetch user by ID")
void shouldFetchUserById(int userId) {
given()
.baseUri(BASE_URL)
.when()
.get("/users/" + userId)
.then()
.statusCode(200)
.body("id", equalTo(userId));
}

@ParameterizedTest
@CsvSource({
"1, Leanne Graham, Sincere@april.biz",
"2, Ervin Howell, Shanna@melissa.tv",
"3, Clementine Bauch, Nathan@yesenia.net"
})
@DisplayName("Should verify user details")
void shouldVerifyUserDetails(int id, String name, String email) {
given()
.baseUri(BASE_URL)
.when()
.get("/users/" + id)
.then()
.statusCode(200)
.body("name", equalTo(name))
.body("email", equalTo(email));
}

@ParameterizedTest
@MethodSource("postDataProvider")
@DisplayName("Should create posts with various data")
void shouldCreatePostsWithVariousData(Post post) {
given()
.baseUri(BASE_URL)
.contentType("application/json")
.body(post)
.when()
.post("/posts")
.then()
.statusCode(201)
.body("title", equalTo(post.getTitle()));
}

static Stream<Post> postDataProvider() {
return Stream.of(
new Post("First Post", "Content 1", 1),
new Post("Second Post", "Content 2", 2),
new Post("Third Post", "Content 3", 3)
);
}
}

Loading Test Data from Files

Create a test data file src/test/resources/testdata/users.json:

[
{"userId": 1, "expectedName": "Leanne Graham"},
{"userId": 2, "expectedName": "Ervin Howell"},
{"userId": 3, "expectedName": "Clementine Bauch"}
]
@ParameterizedTest
@MethodSource("loadUsersFromFile")
@DisplayName("Should verify users from JSON file")
void shouldVerifyUsersFromFile(int userId, String expectedName) {
given()
.baseUri(BASE_URL)
.when()
.get("/users/" + userId)
.then()
.statusCode(200)
.body("name", equalTo(expectedName));
}

static Stream<Arguments> loadUsersFromFile() throws Exception {
ObjectMapper mapper = new ObjectMapper();
List<Map<String, Object>> users = mapper.readValue(
new File("src/test/resources/testdata/users.json"),
new TypeReference<List<Map<String, Object>>>() {}
);

return users.stream()
.map(user -> Arguments.of(
((Number) user.get("userId")).intValue(),
user.get("expectedName")
));
}

Part 7: File Uploads and Downloads

File Upload (Multipart)

@Test
@DisplayName("Should upload a file")
void shouldUploadFile() {
File testFile = new File("src/test/resources/testdata/sample.txt");

given()
.baseUri("https://httpbin.org")
.multiPart("file", testFile)
.when()
.post("/post")
.then()
.statusCode(200)
.body("files.file", notNullValue());
}

@Test
@DisplayName("Should upload file with additional form data")
void shouldUploadFileWithFormData() {
File testFile = new File("src/test/resources/testdata/sample.txt");

given()
.baseUri("https://httpbin.org")
.multiPart("file", testFile)
.formParam("description", "Test file upload")
.formParam("category", "documents")
.when()
.post("/post")
.then()
.statusCode(200);
}

File Download

@Test
@DisplayName("Should download a file")
void shouldDownloadFile() {
byte[] fileContent = given()
.baseUri("https://httpbin.org")
.when()
.get("/image/png")
.then()
.statusCode(200)
.contentType("image/png")
.extract()
.asByteArray();

assertThat(fileContent.length).isGreaterThan(0);

// Optionally save to file
// Files.write(Paths.get("downloaded.png"), fileContent);
}

Part 8: Advanced Configuration

Request Specification (Reusable Configuration)

@ExtendWith(SerenityJUnit5Extension.class)
class RequestSpecificationTest {

private RequestSpecification baseSpec;

@BeforeEach
void setup() {
baseSpec = new RequestSpecBuilder()
.setBaseUri("https://jsonplaceholder.typicode.com")
.setContentType("application/json")
.addHeader("Accept", "application/json")
.log(LogDetail.ALL)
.build();
}

@Test
@DisplayName("Should use request specification")
void shouldUseRequestSpecification() {
given()
.spec(baseSpec)
.when()
.get("/users/1")
.then()
.statusCode(200);
}
}

Response Specification (Reusable Assertions)

@ExtendWith(SerenityJUnit5Extension.class)
class ResponseSpecificationTest {

private ResponseSpecification successSpec;
private ResponseSpecification errorSpec;

@BeforeEach
void setup() {
successSpec = new ResponseSpecBuilder()
.expectStatusCode(200)
.expectContentType("application/json")
.expectResponseTime(lessThan(5000L))
.build();

errorSpec = new ResponseSpecBuilder()
.expectStatusCode(404)
.build();
}

@Test
@DisplayName("Should use success response specification")
void shouldUseSuccessResponseSpec() {
given()
.baseUri("https://jsonplaceholder.typicode.com")
.when()
.get("/users/1")
.then()
.spec(successSpec)
.body("name", notNullValue());
}
}

Timeouts and Retries

import static io.restassured.config.HttpClientConfig.httpClientConfig;

@Test
@DisplayName("Should configure connection timeout")
void shouldConfigureTimeout() {
given()
.baseUri("https://jsonplaceholder.typicode.com")
.config(RestAssured.config()
.httpClient(httpClientConfig()
.setParam("http.connection.timeout", 5000)
.setParam("http.socket.timeout", 5000)
)
)
.when()
.get("/users")
.then()
.statusCode(200);
}

Logging

@Test
@DisplayName("Should log request and response")
void shouldLogRequestAndResponse() {
given()
.baseUri("https://jsonplaceholder.typicode.com")
.log().all() // Log entire request
.when()
.get("/users/1")
.then()
.log().all() // Log entire response
.statusCode(200);
}

@Test
@DisplayName("Should log only on failure")
void shouldLogOnlyOnFailure() {
given()
.baseUri("https://jsonplaceholder.typicode.com")
.log().ifValidationFails()
.when()
.get("/users/1")
.then()
.log().ifValidationFails()
.statusCode(200);
}

Part 9: Hybrid UI + API Testing

Combine UI and API testing for powerful end-to-end scenarios:

Using Playwright with API Calls

import net.serenitybdd.screenplay.playwright.abilities.BrowseTheWebWithPlaywright;
import net.serenitybdd.screenplay.playwright.interactions.*;
import net.serenitybdd.screenplay.rest.abilities.CallAnApi;
import net.serenitybdd.screenplay.rest.interactions.Post;

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("Hybrid UI + API Tests")
class HybridTest {

@Test
@DisplayName("Should create data via API and verify in UI")
void shouldCreateDataViaApiAndVerifyInUi() {
// Actor with both abilities
Actor tester = Actor.named("Hybrid Tester")
.whoCan(CallAnApi.at("https://api.example.com"))
.whoCan(BrowseTheWebWithPlaywright.usingTheDefaultConfiguration());

// Create data via API
tester.attemptsTo(
Post.to("/products")
.with(request -> request
.contentType("application/json")
.body("""
{"name": "New Product", "price": 99.99}
""")
)
);

// Verify in UI
tester.attemptsTo(
Open.url("https://example.com/products"),
Ensure.that(Text.of(".product-name")).contains("New Product")
);
}

@Test
@DisplayName("Should perform UI action and verify via API")
void shouldPerformUiActionAndVerifyViaApi() {
Actor tester = Actor.named("Hybrid Tester")
.whoCan(CallAnApi.at("https://api.example.com"))
.whoCan(BrowseTheWebWithPlaywright.usingTheDefaultConfiguration());

// Perform UI action
tester.attemptsTo(
Open.url("https://example.com/checkout"),
Click.on("#place-order-button")
);

// Verify via API
tester.attemptsTo(
Get.resource("/orders/latest")
);

tester.should(
seeThatResponse(response -> response
.statusCode(200)
.body("status", equalTo("placed"))
)
);
}
}

Part 10: Best Practices

1. Organize by Domain

src/test/java/com/example/api/
├── model/
│ ├── User.java
│ ├── Post.java
│ └── Booking.java
├── tasks/
│ ├── CreateUser.java
│ ├── FetchUser.java
│ └── Authenticate.java
├── questions/
│ ├── TheUser.java
│ └── TheResponse.java
├── specs/
│ ├── RequestSpecs.java
│ └── ResponseSpecs.java
└── tests/
├── UserApiTest.java
├── PostApiTest.java
└── AuthenticationTest.java

2. Use Meaningful Assertions

// Bad
response.body("data", notNullValue());

// Good
response.body("data.id", equalTo(expectedUserId))
.body("data.email", matchesPattern("^[\\w.-]+@[\\w.-]+\\.\\w+$"))
.body("data.created_at", notNullValue());

3. Extract Common Setup

public class ApiTestBase {

protected static final String BASE_URL =
System.getProperty("api.baseurl", "https://api.example.com");

protected RequestSpecification baseRequest() {
return given()
.baseUri(BASE_URL)
.contentType("application/json")
.accept("application/json")
.log().ifValidationFails();
}
}

4. Use Tags for Test Categories

@Tag("smoke")
@Test
void criticalEndpointTest() { }

@Tag("regression")
@Test
void detailedValidationTest() { }

@Tag("slow")
@Test
void performanceTest() { }

5. Clean Up Test Data

@AfterEach
void cleanup() {
if (createdResourceId != null) {
given()
.baseUri(BASE_URL)
.header("Authorization", "Bearer " + authToken)
.when()
.delete("/resources/" + createdResourceId)
.then()
.statusCode(anyOf(equalTo(200), equalTo(204), equalTo(404)));
}
}

Summary

This tutorial covered:

TopicDescription
Basic OperationsGET, POST, PUT, PATCH, DELETE requests
Screenplay PatternActor-centric API testing with tasks and questions
AuthenticationBasic auth, bearer tokens, API keys
Error HandlingTesting error responses, schema validation
Chained CallsUsing data from one call in another
Data-Driven TestsParameterized tests with various data sources
File OperationsUpload and download files
ConfigurationRequest/response specs, timeouts, logging
Hybrid TestingCombining UI and API tests

Next Steps

Resources