Working with REST APIs using Serenity Screenplay
An extract from The Serenity Book
The Screenplay pattern is an approach to writing automated acceptance tests that helps us write cleaner, more maintainable, more scalable automation code. A Screenplay test talks first and foremost about the tasks a user performs, in business language, rather than diving into the details about buttons, clicks and input fields. Focusing on the business tasks makes our tests more readable, more maintainable, and easier to scale.
Screenplay is often associated with UI testing. Interestingly, the name of the pattern is actually unrelated to screens or user interfaces; it comes from a theatre metaphor, where actors play roles on a stage following a predefined script (the "screenplay"), and was coined by Antony Marcano and Andy Palmer around 2015. The pattern itself goes back further than that, and has been around in various forms since it was first proposed by Antony Marcano in 2007.
But Screenplay is also a great fit for API or web service tests. In particular, Screenplay is ideal when we want to include API and UI activities in the same test. For example, we might have an API task to set up some test data, a UI task to illustrate how a user interacts with this data, then another API task to check the new state of the database.
You can get a taste of what REST API interactions using Serenity Screenplay look like here:
@Test
public void list_all_users() {
Actor sam = Actor.named("Sam the supervisor")
.whoCan(CallAnApi.at(theRestApiBaseUrl));
sam.attemptsTo(
Get.resource("/users")
);
sam.should(
seeThatResponse("all the expected users should be returned",
response -> response.statusCode(200)
.body("data.first_name", hasItems("George", "Janet", "Emma")))
);
}
Serenity Screenplay uses Rest-Assured to interact with rest endpoints, and to query the responses. Rest-Assured provides us with a simple but extremely powerful Java DSL that allows us to test and virtually any kind of REST end point. Its highly readable code is also an ideal fit for Screenplay.
Setting up your project
To test REST API services with Screenplay, you need to add the serenity-screenplay-rest
dependency to your project. In Maven, add the following to the dependencies in your pom.xml
file:
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-screenplay-rest</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>
And for Gradle, you can add the same dependency to your build.gradle
file:
testCompile "net.serenity-bdd:serenity-screenplay-rest:${serenityVersion}"
Defining a base URI
When you test a REST API, it is convenient to be able to use the same tests against different environments. You may want to run your tests against a server running on your local machine, against a QA server, or even against a production box. And you don’t want to have to change your tests whenever you test against a different environment.
For example, in this chapter, we will be demonstrating the features of serenity-screenplay-rest
using the ResReq application (see below). If you have a reliable internet connection, you can run your tests against the live ResReq server at https://reqres.in/api/. Or if you are running the ResReq server locally, you would access endpoints at http://localhost:5000/api.
Reading from the Serenity config file
In Serenity BDD, you can define the base URL for your REST API directly in the serenity.properties
or serenity.conf
file for your project.
Here is an example from a serenity.conf
file:
restapi {
baseurl = "https://reqres.in/api"
}
Any test can read values from the Serenity configuration files simply by creating a field of type EnvironmentVariables
in the test.
You can then fetch the property, and provide a default value to use if the property hasn’t been defined, as shown below:
theRestApiBaseUrl = environmentVariables.optionalProperty("restapi.baseurl")
.orElse("https://reqres.in/api");
Setting the API Url from the command line
You can override the default URL defined this way simply by providing a system property on the command line, like this:
mvn verify -Drestapi.baseurl=http://localhost:5000/api
Configuring the base API URL in Maven
If you are using Maven, a more convenient approach may be to use Maven Profiles.
In your pom.xml
file, you define up different Maven profiles for each environment, and set the restapi.baseurl
property accordingly:
<profiles>
<profile>
<id>dev</id>
<properties>
<restapi.baseurl>http://localhost:5000/api</restapi.baseurl>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<restapi.baseurl>https://reqres.in/api</restapi.baseurl>
</properties>
</profile>
</profiles>
For this to work properly, you also need to ensure that the restapi.baseurl
is passed correctly to your tests.
You do this by using the systemPropertyVariables
tag in the `maven-failsafe-plugin' configuration, as shown here:
<build>
<plugins>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.20</version>
<configuration>
<includes>
<include>**/When*.java</include>
<include>**/*Feature.java</include>
</includes>
<systemPropertyVariables>
<restapi.baseurl>${restapi.baseurl}</restapi.baseurl>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
You can then run the tests with Maven using the -P
option:
$ mvn verify -Pdev
Configuring the actor - the CallAnApi ability
In Screenplay, tests describe behaviour in terms of actors, who achieve their business goals by performing tasks.
These tasks usually involve interacting with the application in some way.
And to perform these tasks, we give the actors various abilities.
The CallAnApi
ability gives actors the ability to interact with a REST web service using Rest-Assured.
This includes both invoking REST end-points and querying the results.
private String theRestApiBaseUrl;
private EnvironmentVariables environmentVariables; (1)
private Actor sam; (2)
@Before
public void configureBaseUrl() {
theRestApiBaseUrl = environmentVariables.optionalProperty("restapi.baseurl") (3)
.orElse("https://reqres.in/api");
sam = Actor.named("Sam the supervisor").whoCan(CallAnApi.at(theRestApiBaseUrl)); (4)
}
1 | Serenity properties - Serenity will instantiate this field automatically |
2 | A Screenplay actor - this actor will be used in all our tests |
3 | Here we fetch the REST API base url |
4 | Then we create an actor with the ability to call REST end-points at the specified URL |
The CallAnApi
ability allows the actor to perform the bundled Serenity REST interaction classes. This include:
- Get.resource()
- Post.to()
- Put.to()
- Delete.from()
The simplest of these is Get
.
GET Interactions
In a REST API, GET requests are used to query a REST resource.
Let’s see how we can do this using Serenity Screenplay.
Simple GET requests
In our demo application, the /users
resource represents application users.
We can retrieve the details of a particular user by appending the user ID, like this: /users/1
.
The structure of a user record is shown below:
{
"data": {
"id": 1,
"first_name": "George",
"last_name": "Bluth",
"avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/calebogden/128.jpg"
}
}
Suppose we need to write a scenario that retrieves a particular user, and checks some of the user’s details, such as first_name and last_name.
Such a test might look like this:
@Test
public void find_an_individual_user() {
sam.attemptsTo(
Get.resource("/users/1") (1)
);
sam.should(
seeThatResponse( "User details should be correct", (2)
response -> response.statusCode(200) (3)
.body("data.first_name", equalTo("George")) (4)
.body("data.last_name", equalTo("Bluth")) (5)
)
);
}
1 | Sam performs a GET |
2 | We check the response using the seeThatResponse method. |
3 | Did the call return a status code of 200? |
4 | Is the first_name field 'George'? |
5 | Is the last_name field 'Bluth'? |
As you can see, this code is fairly self-explanatory.
Like any other Screenplay test, we use the actor’s attemptsTo()
method to perform the action we want to test.
In this case, we use the Get
interaction class, which comes bundled with serenity-screenplay-rest
.
Next we check the response using the seeThatResponse
method.
This method takes a Lambda expression and allows us to access the full RestAssured API.
In particular, we can use jsonPath expressions to query the JSON structure we receive.
Retrieving objects
Sometimes we need to fetch a value from a REST response, and keep it for use later on. RestAssured makes it relatively easy to convert a JSON structure to a Java object, which you can use later on in your tests.
For example, suppose we have a class like the one below, which corresponds to the user details returned by our endpoint:
package examples.screenplay.rest.model;
public class User {
private String id;
private String first_name;
private String last_name;
public User(String first_name, String last_name) {
this.first_name = first_name;
this.last_name = last_name;
}
public String getId() {
return id;
}
public String getFirstName() {
return first_name;
}
public String getLastName() {
return last_name;
}
}
We could retrieve the user as an instance of this class by calling the jsonPath().getObject()
method on the received response. This method will convert the JSON data on a given path to a corresponding Java structure:
@Test
public void retrieve_an_element_from_the_json_structure() {
sam.attemptsTo(
Get.resource("/users/1")
);
User user = SerenityRest.lastResponse() (1)
.jsonPath()
.getObject("data", User.class); (2)
assertThat(user.getFirstName()).isEqualTo("George");
assertThat(user.getLastName()).isEqualTo("Bluth");
}
1 | Retrieve the response returned by the previous RESRT call |
2 | Convert the JSON entry in the data field to a User |
Retrieving lists
Oftentimes we need to retrieve not a single item, but a list of items.
Retrieving a list is little different to retrieving a single item:
sam.attemptsTo(
Get.resource("/users") (1)
);
sam.should(
seeThatResponse("all the expected users should be returned",
response -> response.body("data.first_name", hasItems("George", "Janet", "Emma"))) (2)
);
1 | Retrieve all the users |
2 | Check the list of user first names |
The difference happens when we query the results.
In this case, we use a jsonPath expression (data.first_name
) that will return all of the first_name field values.
The Hamcrest matcher hasItems
will compare the collection of first names that the jsonPath query returns, and check that it contains (at least) the names "George", "Janet" and "Emma".
But what if we want to capture the data we retrieve, rather than simply make an assertion about the contents?
We can do that using the SerenityRest.lastResponse()
method, like this:
List<String> userSurnames = SerenityRest.lastResponse().path("data.last_name"); (1)
assertThat(userSurnames).contains("Bluth", "Weaver", "Wong");
1 | Use a JsonPath expression to retrieve all the last_name values underneath the data entry. |
We can also retrieve lists of objects, just as we retrieved a single User
instance in the previous section.
Simply use the jsonPath.getList()
method as shown below:
List<User> users = SerenityRest.lastResponse()
.jsonPath()
.getList("data", User.class); (1)
assertThat(users).hasSize(3);
1 | Return a list of User instances |
Using Path Parameters
In the previous example, we hard-coded the path element in the request.
For a more flexible approach, we can supply the path parameter when we submit the query:
sam.attemptsTo(
Get.resource("/users/{id}").with( request -> request.pathParam("id", 1)) (1)
);
1 | Provide the user ID as a path parameter |
Here we are using the Get.resource(…).with(…)
structure to pass the RestAssured RequestSpecification
object into a lambda expression.
Once again, this gives us access to all the richness of the RestAssert library
Using Query Parameters
Some REST APIs take query parameters as well as path parameters. Query parameters are commonly used to filter results or implement pagination. For example, we could get the second page of users from our /users
API by using the page
query parameter like this:
/users?page=2
In our test code, we use the queryParam()
method to provide a value for the page
parameter:
sam.attemptsTo(
Get.resource("/users").with( request -> request.queryParam("page", 2)) (1)
);
sam.should(
seeThatResponse("All users on page 2 should be returned",
response -> response.body("data.first_name",
hasItems("Eve", "Charles", "Tracey"))) (2)
);
1 | Return page 2 of the results |
Post queries
We can send POST requests to a REST end-point using the Post
interaction class. Here is a simple example:
sam.attemptsTo(
Post.to("/users")
.with(request -> request.header("Content-Type", "application/json") (1)
.body("{\"firstName\": \"Sarah-Jane\",\"lastName\": \"Smith\"}") (2)
)
);
sam.should(
seeThatResponse("The user should have been successfully added",
response -> response.statusCode(201))
);
1 | Specify the content type in the header |
2 | Specify the request body |
Alternatively, we can post an object, letting RestAssured convert the object fields into JSON for us:
User newUser = new User("Sarah-Jane", "Smith");
sam.attemptsTo(
Post.to("/users")
.with(request -> request.header("Content-Type", "application/json")
.body(newUser) (1)
)
);
1 | Pass in an object rather than a JSON string |
Other types of queries
Other query types are similar to GET
and POST
queries.
For example, PUT
requests are often used to update resources.
In the following example, we use a PUT
request to update a user’s details:
sam.attemptsTo(
Put.to("/users")
.with(request -> request.header("Content-Type", "application/json")
.body("{\"firstName\": \"jack\",\"lastName\": \"smith\"}")
)
);
sam.should(
seeThatResponse(response -> response.statusCode(200)
.body("updatedAt", not(isEmptyString())))
);
Or you can delete a user using the DELETE
query as shown here:
sam.attemptsTo(
Delete.from("/users/1")
);
sam.should(
seeThatResponse(response -> response.statusCode(204))
);
Higher level tasks
The interactions we have seen so far are readable but still quite low level.
Screenplay allows us to build higher level tasks that represent the business intent behind these interactions.
For example, you could define a task that encapsulates listing all users like this:
package examples.screenplay.rest.tasks;
import net.serenitybdd.screenplay.Task;
import net.serenitybdd.screenplay.rest.interactions.Get;
public class UserTasks {
public static Task listAllUsers() {
return Task.where("{0} lists all users", (1)
Get.resource("/users") (2)
);
}
}
1 | Define a task where the actor lists all the users |
2 | This task is implemented using a Get interaction |
We can then use a static import to refactor our first test as follows:
sam.attemptsTo(
listAllUsers()
);
For a bit more flexibility, we can also write a custom Task
class. For example, we could write a FindAUser
task to find a user by ID:
package examples.screenplay.rest.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 FindAUser implements Task{
private final int id;
public FindAUser(int id) {
this.id = id;
}
public static FindAUser withId(int id) {
return instrumented(FindAUser.class, id);
}
@Override
@Step("{0} fetches the user with id #id")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Get.resource("/users/{id}")
.with(request -> request.pathParam("id", id))
);
}
}
Using this class, we could refactor our original class to read like this:
sam.attemptsTo(
FindAUser.withId(1)
);
Using tasks to encapsulate REST interactions results in a clear, layered reporting structure, that first describes what the user is doing, and they how they go about it. The test report for the previous scenario is shown here: