John Ferguson Smart

John's latest articles

BDD, Microservices, and Serenity BDD

John Ferguson Smart
Mentor | Author | Speaker - Author of 'BDD in Action'.
Helping teams deliver more valuable software sooner

Introduction

Many people still associate Behaviour Driven Development (BDD) and automated acceptance testing with User-Interface tests. But BDD and automated acceptance criteria works equally well for service-level tests, notably for Microservices. In fact, automated acceptance tests for Microservices are easier to write and much quicker to run than UI-based tests. In this article, we will look at how BDD-style automated acceptance testing can be applied to Microservices, with examples using Java, Cucumber-JVM and Serenity BDD.

A Question of Audience

In modern applications, more and more services are provided via web services, where a single service can be used by many client applications. BDD requirements discovery techniques and automated acceptance criteria are a great way to describe and document what these services do.

There are two approaches to writing acceptance criteria for web services, and the choice of the most appropriate approach depends largely on the audience – The first approach is to take a high-level, business-language tact, using terms that a product owner could understand. – The second is more low level, more technical, aimed at developers rather than product owners or BAs. Tests like this discuss things that a front-end developer might be interested in knowing, such as JSON fields and error codes.

Remember, in both cases communication is key. You write tests, and even more so, executable specifications, for the benefit of someone else.

Executable specifications are communication vehicles. If they do not illustrate or explain something about the application, in a way that is useful to the target audience, then they are essentially useless.

Both approaches can be effective, but different audiences will benefit from different styles and levels of communication. However, I find the most effective approach to be a blend of the two, using a high-level domain-language style in your feature files, and discuss details about JSON structures, headers and error codes under the hood. This works particularly well with Serenity BDD, where the steps at different layers are recorded and appear in the reports.

A Practical Example

Let’s look at a practical example. Transport For London publish a number of publicly-available Web Service APIs, providing real-time information for applications about the London transport system. One of these services is to return the list of taxi rakes, either within a geographical area, or within a certain distance from a specific point.

Understanding the requirements

From a BDD perspective, this service provides us with the capability to find a taxi by different means. We could break this capability down into two main features: – Finding taxi racks by proximity, and – Finding taxi racks by location

Let’s describe the context and business value around each of these features. BDD practitioners often use the in order to..as a..I want notation shown below, which emphasises the importance of the business goal (the “why”) behind the feature:

Feature: Find taxi racks by proximity
  In order to get home alive
  As an inebriated business man on a Thursday evening
  I want to find the taxi racks nearest to my current location

Feature: Find taxi racks by location
  In order to plan my getaway
  As a car-less bank robber
  I want to know all the taxi racks in a given location

Notice that even though we are describing a set of web services, to be consumed by applications and with no UI to speak of, we still reason in terms of how these services will benefit the end user.

Both of these features are business-focused, expressed in terms of the needs of end users, using a non-technical domain language. They focus on the stakeholder goals, the why, and leave the what and the how until the next level down.

Illustrating a feature with examples and rules

When we use BDD, we use conversations around concrete examples and business rules to get a better understanding of how the application should behave. The business rules for the Find taxi racks by proximity feature might include:

  • All the taxi ranks within a given distance should be shown
  • The closest taxi rank should appear first
  • Where there are no taxi stands nearby none should be found

We can illustrate these acceptance criteria with concrete examples, like the ones shown in the Cucumber scenario below:

Scenario Outline: All the taxi ranks within a given distance should be shown
  Given Joe is at <station>
  When he looks for the closest taxi rank within <distance> meters
  Then <number-of-taxis> taxi ranks should be found
  And all of the taxi ranks should be no more than <distance> meters away
  Examples:
    | station               | distance | number-of-taxis |
    | London Bridge Station | 500      | 5               |
    | London Bridge Station | 1000     | 18              |
    | Canary Wharf          | 20       | 0               |

Notice how we are still using business language here, describing the outcomes in very readable terms, rather than describing the JSON data that would actually be returned. We do this for a couple of reasons. Firstly, this lets us focus on the information we are specifically interested in, without drowning this information in a flood of unrelated data. Secondly, it makes the tests more robust – this specification will only need to be changed if taxi ranks were added or removed.

One of the common misconceptions about Cucumber is that it is testing tool. This leads teams adopting BDD to write scenarios in more low level, technical terms, like the following:

Scenario: Should return closest taxi within a given distance
  When I call Get on /Place with parameters:
    | lat       | lon       | radius |
    | 51.505353 | -0.084826 | 500    |
  Then I should get a response that contains at least 5 'places' entries
  And the response should contain 'places' data:
    """
      {
        id: "TaxiRank_5901",
        url: "https://api-argon.tfl.gov.uk/Place/TaxiRank_5901",
        commonName: "Cornhill",
        distance: 161.4151956450323,
        placeType: "TaxiRank",
        lat: 51.513312,
        lon: -0.086724
      }
    """

However, this approach is more akin to unit testing than acceptance testing, focusing more on the how than the what. We can see what end point we are calling, and what JSON data the response should contain, but we have little indication of what we are looking for, and even less of why. The approach also scales poorly; if the response contains bigger or more complex JSON structures, the Cucumber scenario quickly becomes unwieldy and hard to read, which defeats the very purpose of Cucumber scenarios.

Nevertheless, it is often useful to see the JSON requests and data involved in a Microservice test. In the rest of this article, we will see how we can use a business-focused approach to describing Microservices, but still benefit from living documentation of the JSON requests and structures, using Serenity BDD.

Automating the acceptance criteria

Let’s see how we would automate this first scenario using Serenity BDD. While you can use any Rest client with Serenity BDD, the library provides tight integration with Rest Assured, so that is what we will use here.

I won’t reproduce all of the source code for the sample project here, and I’m going to assume a basic understanding of using Cucumber with Java (though the code should be fairly self-explanatory). We will just focus on the interesting bits, including some useful Cucumber tricks that can make your test code a bit cleaner. You can find the full source code on Github.

Preparing the test environment

In Cucumber, each step in the Gherkin scenario is matched against a method using regular expressions. We call these methods step definitions. The first step, “Given Joe is at <station>”, sets up the test environment for the steps that follow. It’s step definition is shown here:

public class FindingTaxiStandSteps {

    TubeStation currentLocation;

    @Given("(?:.*) is at (.*)")
    public void userIsCurrentlyAt(@Transform(TubeStationConverter.class) TubeStation station) {
        this.currentLocation = station;
    }
    ...
}

This step simply stores the user’s current location for future use. But even this simple example shows some useful Cucumber features. Cucumber matches the regular expressions in the @Given annotation and passes the corresponding values as method parameters. So the second “(.*)” will be passed to the step definition in the tubeStation parameter (more about that later). We a non-capturing matcher (the one starting with “?:”) to tell Cucumber that we don’t much care about the name of the actor, just that there is one. Non-capturing regular expressions make it easy to write scenarios in a more natural-feeling way.

As we mentioned, the second regular expression is passed to the userIsCurrentlyAt method in the tubeStationparameter. For these tests we maintain a list of tube stations, that we represent as an enum containing information about the geographical coordinates of each station:

public enum TubeStation {

    LONDON_BRIDGE("London Bridge Station", 51.505353, -0.084826),
    BARBICAN("Barbican", 51.520865, -0.097758),
    CANARY_WHARF("Canary Wharf", 51.50362, -0.01987),
    PADDINGTON("Paddington", 51.5151846554, -0.17553880792);

    public final String name;
    public final Double latitude;
    public final Double longitude;

    TubeStation(String name, Double latitude, Double longitude) {
        this.name = name;
        this.latitude = latitude;
        this.longitude = longitude;
    }
}

Invoking the Microservice

The next step, When he looks for the closest taxi rank within <distance> meters, performs the actual REST query. This query calls the /Place end point, and takes three main parameters: – lat: the current location’s latitude – lon: the current location’s longitude – radius: the maximum distance to look for taxi racks

Serenity BDD provides thin wrapper methods around the RestAssured methods like given(), when(), then(), and with(), to provide additional reporting on the JSON data being sent to and from the web service. For example, we can the with() method to define query parameters and then execute a REST query:

with().parameters(...).get(...)

Invoking REST endpoints with RestAssured is simple, and uses an easy-to-read DSL to make the code clear and expressive. The step definiton method for When he looks for the closest taxi rank within <distance> meters uses the with() method to call a GET query on the /Places endpoint with the lat, lon and radius parameters:

String jsonResponse;

@When("^s?he looks for the closest taxi rank within (\\d+) meters$")
public void lookForTheClosestTaxiRankWithin(int maximumDistance) throws Throwable {
    with().parameters(
            "lat", currentLocation.latitude,
            "lon", currentLocation.longitude,
            "radius", maximumDistance)
            .get(TFLPlaces.find(Place.TaxiRank));

    jsonResponse = then().extract().asString();
}

The actual REST endpoints are stored in a central class called TFLPlaces. This class plays a similar role to that of Page Objects in a web test: it’s responsibility is to know how to locate web service endpoints that are used elsewhere in the test.

The TFLPlaces class looks like this:

public class TFLPlaces {

    private static final String BASE_URL = "https://api.tfl.gov.uk";

    private static final String API_PARAMS 
        = APICredentials.fromLocalEnvironment().getApiParams();

    private static final String FIND_PLACE_BY_PROXIMITY 
        = BASE_URL + "/Place?type=%s&" + API_PARAMS;

    public static URL find(Place placeType) throws MalformedURLException {
        return new URL(format(FIND_PLACE_BY_PROXIMITY, placeType));
    }
}

Like many public APIs, the Transport for London API asks us to provide an API id and key, that you can obtain by registering on the Transport for London API site. We use a simple class called APICredentials to read these credentials from a local properties file.

Checking the outcomes

The next step, Then <number-of-taxis> taxi ranks should be found, checks that the number of responses returned by the service is as expected. RestAssured lets us use the very powerful JSONPath expression language to query JSON responses. The previous query will return a JSON response like the one shown here:

{
    centerPoint: [51.513,-0.089]
    places: [
        {id: "TaxiRank_5910", commonName: "Leadenhall Street", distance: 106.7938881264...},
        {id: "TaxiRank_5897", commonName: "Philpot Lane", distance: 161.6991153615...},
        {id: "TaxiRank_5901", commonName: "Cornhill", distance: 191.8335165276...},
        {id: "TaxiRank_5916", commonName: "Mincing Lane"", distance: 294.188357262...},
        {id: "TaxiRank_5925", commonName: "St Mary Axe", distance: 294.5287724692...},
    ]
}

For example, we can use the JSONPath expression “places.id” to retrieve all of the id attribute values from this JSON structure. In the step definition for Then <number-of-taxis> taxi ranks should be found, we use this expression to count the number of taxi ranks and compare this to the expected value:

@Then("^(\\d+) taxi ranks should be found$")
public void txiRanksShouldBeFound(int taxiRanksFound) throws Throwable {
    List<String> taxiRacks = JsonPath.from(jsonResponse).getList("places.id");

    assertThat(taxiRacks.size(), equalTo(taxiRanksFound));
}

For the next step, And all of the taxi ranks should be no more than <distance> meters away, we do something similar. But this time, we retrieve all of the distance values, and check that all of them are under the expected maximum distance:

@Then("^all of the taxi ranks should be no more than (\\d+) meters away$")
public void allOfTheTaxiRanksShouldBeNoMoreThanMetersAway(Float maxDistance) throws Throwable {
    List<Float> distances = JsonPath.from(jsonResponse).getList("places.distance");

    assertThat(distances, everyItem(lessThan(maxDistance)));
}

RestAssured lets us do much more than simply read values from a JSON response. For example, suppose we wanted to implement the following step, where we check the actual details of one of the taxi ranks we find:

And the first taxi rank found should be:
  | commonName        | distance           |
  | Leadenhall Street | 106.79388812648932 |

We can do this using the RestAssured body() method, which lets us query the response using JsonPath immediately:

@Then("^the first taxi rank found should be:$")
public void heShouldFindRankStand(List<TaxiStand> closestStands) throws Throwable {
    TaxiStand closestStand = closestStands.get(0);

    then().statusCode(200)
            .body("places[0].commonName", equalTo(closestStand.commonName))
            .body("places[0].distance", equalTo(closestStand.distance));
}

Reporting on the results

RestAssured is powerful, flexible and expressive, but when it comes to BDD, implementing the step definitions is only half the story. We also need to report on what our test is demonstrating.

Serenity BDD provides excellent reporting for Cucumber and Rest-Assured based tests. In Figure 1, we can see how the All the taxi ranks within a given distance should be shown scenario outline appears in the the Serenity reports.

Figure 1: The Serenity BDD scenario report gives an overview of the scenario

We can drill down into each individual scenario, as shown in Figure 2.

Figure 2: Showing more details about each step

Furthermore, we can view the JSON (or XML) request and response data if we need more details (Figure 3).

Figure 3: Details of the JSON request and response data

Conclusion

Behaviour Driven Development techniques are a great way to clarify and document requirements related to Microservices, and to organise automated acceptance tests to demonstrate these requirements. But it is important to remember that BDD shines when you describe the what and the why, and not the how.

Thanks to Jan Molak for his review and helpful suggestions!