REST Assured: what we learned from five years of using the tool

REST Assured - DSL for testing REST services, which is embedded in Java tests. This solution appeared more than nine years ago and has become popular because of its simplicity and convenient functionality.







In DINS, we wrote more than 17 thousand tests with it and over the five years of use we encountered a lot of โ€œpitfallsโ€ that you cannot learn about immediately after importing the library into the project: a static context, confusion in the order in which filters are applied to the query, difficulties in structuring the test.







This article is about such implicit features of REST Assured. They need to be taken into account if there is a chance that the number of tests in the project will increase rapidly - so that you do not have to rewrite them later.







image







What are we testing



DINS is involved in the development of the UCaaS platform. In particular, we develop and test the API that RingCentral uses itself and provides to third-party developers .







When developing any API, it is important to ensure that it works correctly, but when you give it out, you have to check a lot more cases. Therefore, dozens and hundreds of tests are added to each new endpoint. Tests are written in Java, TestNG is selected as a test framework, and REST Assured is used for API requests.







When REST Assured Will Benefit



If your goal is not to thoroughly test the entire API, then the easiest way to do this is with REST Assured. It is well suited for checking response structures, PVD, and smoke tests.







This is how a simple test looks, which will check that the endpoint gives the status of 200 OK when accessing it:







given() .baseUri("http://cookiemonster.com") .when() .get("/cookies") .then() .assertThat() .statusCode(200);
      
      





The keywords given



, when



and then



form the request: given



determines what will be sent in the request, when



โ€“โ€“ with which method and to which endpoint we send the request, and then



โ€“โ€“ how the received response is checked. In addition, you can extract the response body in the form of an object of the JsonPath



or XmlPath



, to use the received data later.







Real tests are usually bigger and more complicated. Headers, cookies, authorization, request body are added to requests. And if the API under test does not consist of dozens of unique resources, each of which requires special parameters, you will want to store ready-made templates somewhere to add them later to a specific call in the test.







For this, in REST Assured there are:









RequestSpecification and ResponseSpecification



These two classes allow you to determine the request parameters and expectations from the response:







 RequestSpecification requestSpec = given() .baseUri("http://cookiemonster.com") .header("Language", "en"); requestSpec.when() .get("/cookiesformonster") .then() .statusCode(200); requestSpec.when() .when() .get("/soup") .then() .statusCode(400);
      
      





 ResponseSpecification responseSpec = expect() .statusCode(200); given() .expect() .spec(responseSpec) .when() .get("/hello"); given() .expect() .spec(responseSpec) .when() .get("/goodbye");
      
      





One specification is used in several calls, tests, and test classes, depending on where it is defined - there is no restriction. You can even add multiple specifications to a single request. However, this is a potential source of problems :







 RequestSpecification requestSpec = given() .baseUri("http://cookiemonster.com") .header("Language", "en"); RequestSpecification yetAnotherRequestSpec = given() .header("Language", "fr"); given() .spec(requestSpec) .spec(yetAnotherRequestSpec) .when() .get("/cookies") .then() .statusCode(200);
      
      





Call Log:







 Request method: GET Request URI: http://localhost:8080/ Headers: Language=en Language=fr Accept=*/* Cookies: <none> Multiparts: <none> Body: <none> java.net.ConnectException: Connection refused (Connection refused)
      
      





It turned out that all headers were added to the call, but the URI suddenly became localhost - although it was added in the first specification.







This happened due to the fact that REST Assured handles overrides for request parameters differently (with the same answer). Headers or filters are added to the list and then applied in turn. There can be only one URI, so the last set is applied. It was not specified in the latest specification, which is why REST Assured overrides it with the default value (localhost).







If you add a specification to the request, add one . The advice seems obvious, but when the project with tests grows, helper classes and basic test classes appear, before-methods appear inside them. Keeping track of what is actually happening with your request becomes difficult, especially if several people write tests at once.







Basic REST Assured Configuration



Another way to template queries in REST Assured is to configure the basic configuration and define the static fields of the RestAssured class:







 @BeforeMethod public void configureRestAssured(...) { RestAssured.baseURI = "http://cookiemonster.com"; RestAssured.requestSpecification = given() .header("Language", "en"); RestAssured.filters(new RequestLoggingFilter(), new ResponseLoggingFilter()); ... }
      
      





Values โ€‹โ€‹will be automatically added to the request each time. The configuration is combined with the annotations @BeforeMethod



in TestNG and @BeforeEach



in JUnit - so you can be sure that every test you run will start with the same parameters.







However, the configuration will become a potential source of problems, because it is static .







Example: before each test, we take a test user, get an authorization token for him, and then add it through AuthenticationScheme or an authorization filter to the basic configuration. As long as the tests run in a single thread, everything will work.

When there are too many tests, the usual decision to divide their execution into several threads will lead to rewriting a piece of code so that the token from one thread does not fall into the neighboring one.







REST Assured Filters



Filters change both requests before sending and responses before checking for compliance with specified expectations. Application example - adding logging, or authorization:







 public class OAuth2Filter implements AuthFilter { String accessToken; OAuth2Filter(String accessToken) { this.accessToken = accessToken; } @Override public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec, FilterContext ctx) { requestSpec.replaceHeader("Authorization", "Bearer " + accessToken); return ctx.next(requestSpec, responseSpec); } }
      
      





 String accessToken = getAccessToken(username, password); OAuth2Filter auth = new OAuth2Filter(accessToken); given() .filter(auth) .filter(new RequestLoggingFilter()) .filter(new ResponseLoggingFilter()) ...
      
      





Filters that are added to the request are stored in LinkedList



. Before making a request, REST Assured modifies it by going through the list and applying one filter after another. Then the same thing is done with the answer that came.







The order of the filters matters . These two queries will lead to different logs: the first will indicate the authorization header, the second - no. In this case, the header will be added to both requests - just in the first case, REST Assured will first add authorization before signing up, and in the second - vice versa.







 given() .filter(auth) .filter(new RequestLoggingFilter()) โ€ฆ given() .filter(new RequestLoggingFilter()) .filter(auth)
      
      





In addition to the usual rule that filters are applied in the order in which they are added, there is still the opportunity to prioritize your filter by implementing the OrderedFilter



interface. It allows you to set a special numerical priority for the filter, above or below the default (1000). Filters with a priority above will be executed earlier than usual, with a priority below - after them.







Of course, here you can get confused and accidentally set the two filters to the same priority, for example, at 999. Then the one that was added before will be applied to the request first.







Not only filters



How to do authorization through filters is shown above. But besides this method in REST Assured, there is another one, through AuthenticationScheme



:







 String accessToken = getAccessToken(username, password); OAuth2Scheme scheme = new OAuth2Scheme(); scheme.setAccessToken(accessToken); RestAssured.authentication = scheme;
      
      





This is an obsolete method. Instead, you should choose the one shown above. There are two reasons:







Dependency Problem







The documentation for REST Assured indicates that in order to use Oauth1 or Oauth2 (by specifying a token as a query parameter), authorizations must be added depending on the Scribe. However, importing the latest version will not help you - you will encounter an error described in one of the open problems . You can solve it only by importing the old version of the library, 2.5.3. However, in this case, you will come across another problem .







In general, no other version of Scribe works with Oauth2 REST Assured version 3.0.3 and higher (and the recent release 4.0.0 did not fix this).







Logging does not work







Filters are applied to queries in a specific order. And AuthenticationScheme



is applied after them. This means that it will be difficult to detect a problem with authorization in the test - it is not pledged.







More about REST Assured syntax



A large number of tests usually means that they are also complex. And if the API is the main subject of testing, and you need to check not just the json fields, but the business logic, then with REST Assured the test turns into a sheet:







 @Test public void shouldCorrectlyCountAddedCookies() { Integer addNumber = 10; JsonPath beforeCookies = given() .when() .get("/latestcookies") .then() .assertThat() .statusCode(200) .extract() .jsonPath(); String beforeId = beforeCookies.getString("id"); JsonPath afterCookies = given() .body(String.format("{number: %s}", addNumber)) .when() .put("/cookies") .then() .assertThat() .statusCode(200) .extract() .jsonPath(); Integer afterNumber = afterCookies.getInt("number"); String afterId = afterCookies.getString("id"); JsonPath history = given() .when() .get("/history") .then() .assertThat() .statusCode(200) .extract() .jsonPath(); assertThat(history.getInt(String.format("records.find{r -> r.id == %s}.number", beforeId))) .isEqualTo(afterNumber - addNumber); assertThat(history.getInt(String.format("records.find{r -> r.id == %s}.number", afterId))) .isEqualTo(afterNumber); }
      
      





This test verifies that when we feed a monster cookie, we correctly calculate how many cookies were given to him, and indicate this in the story. But at first glance this cannot be understood - all requests look the same, and it is not clear where the preparation of data through the API ends, and where the test request is sent.







given()



, when()



and then()



REST Assured takes from BDD, like Spock or Cucumber. However, in complex tests, their meaning is lost, because the scale of the test becomes much larger than one request - this is one small action that needs to be denoted by one line. And for this, you can transfer REST Assured calls to auxiliary classes:







 public class CookieMonsterHelper { public static JsonPath getCookies() { return given() .when() .get("/cookiesformonster") .then() .extract() .jsonPath(); } ... }
      
      





And call in the test:







 JsonPath response = CookieMonsterHelper.getCookies();
      
      





Itโ€™s good when such helper classes are universal so that a call to one method can be embedded in a large number of tests - then they can be put into a separate library altogether: suddenly you need to call the method at some point in another project. Only in this case you will have to remove all the verification of the response that Rest Assured can do - after all, very different data can often be returned in response to the same request.







Conclusion



REST Assured is a library for testing. She knows how to do two things: send requests and check answers. If we try to remove it from the tests and remove all validation, then it turns into an HTTP client .







If you have to write a large number of tests and continue to support them - think about whether you need an HTTP client with cumbersome syntax, static configuration, confusion in the order of applying filters and specifications, and logging that can be easily broken? Maybe nine years ago, REST Assured was the most convenient tool, but during this time alternatives appeared - Retrofit, Feign, Unirest, etc. - which do not have such features.







Most of the problems that are described in the article manifest themselves in large projects. If you need to quickly write a couple of tests and forget about them forever, and Retrofit doesn't like it, REST Assured is the best option.







If you are already writing tests using REST Assured, it is not necessary to rush to rewrite everything. If they are stable and fast, it will spend more of your time than will bring practical benefits. If not, REST Assured is not your main problem.







Every day, the number of tests written in DINS for the RingCentral API is getting larger and they still use REST Assured. The amount of time that will have to be spent to switch to another HTTP client, at least in new tests, is too large, and the created helper classes and methods that configure the test configuration solve most of the problems. In this case, maintaining the integrity of the project with tests is more important than using the most beautiful and fashionable client. REST Assured, despite its shortcomings, performs its main work.








All Articles