REST Assured: что мы узнали за пять лет использования инструмента

в 13:04, , рубрики: api, java, qa, rest api, REST-assured, test automation, Блог компании DINS, тестирование, Тестирование IT-систем, Тестирование веб-сервисов

REST Assured — DSL для тестирования REST-сервисов, который встраивается в тесты на Java. Это решение появилось более девяти лет назад и стало популярным из-за своей простоты и удобного функционала.

В DINS мы написали с ним более 17 тысяч тестов и за пять лет использования столкнулись со множеством «подводных камней», о которых нельзя узнать сразу после импорта библиотеки в проект: статическим контекстом, путаницей в порядке применения фильтров к запросу, трудностями в структурировании теста.

Эта статья — о таких неявных особенностях REST Assured. Их нужно учитывать, если есть шанс, что количество тестов в проекте будет быстро увеличиваться — чтобы потом не пришлось переписывать.

image

Что тестируем

DINS участвует в разработке UCaaS-платформы. В том числе мы разрабатываем и тестируем API, который компания RingCentral использует сама и предоставляет сторонним разработчикам.

При разработке любого API важно следить, чтобы он работал корректно, но когда отдаешь его наружу, приходится проверять намного больше кейсов. Поэтому на каждый новый эндпоинт добавляются десятки и сотни тестов. Тесты написаны на Java, в качестве тестового фреймворка выбран TestNG, а для запросов к API используется REST Assured.

Когда REST Assured принесет пользу

Если вашей целью не является досконально протестировать весь API, то проще всего это сделать с REST Assured. Он хорошо подходит для проверки структуры ответов, PVD и smoke-тестов.

Так выглядит простой тест, который будет проверять, что эндпоинт отдает статус 200 OK при обращении к нему:

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

Ключевые слова given, when и then формируют запрос: given определяет, что будет отправлено в запросе, when –– с каким методом и на какой эндпоинт отправляем запрос, а then –– как проверяется пришедший ответ. Кроме этого, можно извлечь тело ответа в виде объекта типа JsonPath или XmlPath, чтобы потом использовать полученные данные.

Реальные тесты обычно больше и сложнее. В запросы добавляются заголовки, куки, авторизация, тело запроса. И если тестируемый API не состоит из десятков уникальных ресурсов, каждый из которых требует особых параметров, вы захотите где-то хранить уже готовые шаблоны, чтобы добавлять их потом к конкретному вызову в тесте.

Для такого в REST Assured существуют:

  • RequestSpecification/ResponseSpecification;
  • базовая конфигурация;
  • фильтры.

RequestSpecification и ResponseSpecification

Эти два класса позволяют определить параметры запроса и ожидания от ответа:

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");

Одна спецификация используется в нескольких вызовах, тестах и тестовых классах в зависимости от того, где определена — ограничения нет. Можно даже добавлять несколько спецификаций к одному запросу. Однако это — потенциальный источник проблем:

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);

Лог вызова:

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)

Получилось, что в вызов добавлены все заголовки, а вот URI внезапно стал localhost — хотя его добавили в первой спецификации.

Это произошло из-за того, что REST Assured по-разному справляется с переопределениями для параметров запроса (с ответом то же самое). Заголовки или фильтры добавляются в список, а потом по очереди применяются. URI может быть только один, поэтому применяется последний заданный. В последней добавленной спецификации его не задали — поэтому REST Assured переопределяет его дефолтным значением (localhost).

Если добавляете к запросу спецификацию — добавляйте одну. Совет кажется очевидным, но когда проект с тестами разрастается, возникают классы-хэлперы и базовые тестовые классы, внутри них появляются before-методы. Уследить за тем, что на самом деле происходит с вашим запросом, становится сложно, особенно если тесты пишет сразу несколько человек.

Базовая конфигурация REST Assured

Другой способ шаблонизировать запросы в REST Assured — настроить базовую конфигурацию и определить статические поля класса RestAssured:

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

Значения будут автоматически добавляться к запросу каждый раз. Конфигурация сочетается с аннотациями @BeforeMethod в TestNG и @BeforeEach в JUnit –– так можно быть уверенными, что каждый запущенный тест будет начинаться с одними и теми же параметрами.

Тем не менее, конфигурация станет потенциальным источником проблем, ведь она является статической.

Пример: перед каждым тестом берем тестового пользователя, получаем для него авторизационный токен, а потом добавляем его через AuthenticationScheme или авторизационный фильтр к базовой конфигурации. Пока тесты запускаются в один поток, все будет работать.
Когда тестов становится слишком много, обычное решение разделить их выполнение на несколько потоков приведет к переписыванию куска кода, чтобы токен из одного треда не попал в соседний.

Фильтры REST Assured

Фильтры изменяют как запросы перед отправкой, так и ответы перед проверкой на соответствие заданным ожиданиям. Пример применения — добавление логирования, или авторизации:

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())
...

Фильтры, которые добавляются к запросу, хранятся в LinkedList. Перед тем, как сделать запрос, REST Assured изменяет его, проходясь по списку и применяя один фильтр за другим. Потом то же самое делается с пришедшим ответом.

Порядок фильтров имеет значение. Эти два запроса приведут к разным логам: в первом будет указан авторизационный заголовок, во втором — нет. При этом заголовок будет добавлен в оба запроса — просто в первом случае REST Assured сначала добавит авторизацию до того, как залогировать, а во втором — наоборот.

given()
   .filter(auth)
   .filter(new RequestLoggingFilter())
…

given()
   .filter(new RequestLoggingFilter())
   .filter(auth)

Помимо обычного правила, что фильтры применяются в том порядке, в котором их добавили, существует еще возможность выставить своему фильтру приоритет, имплементировав интерфейс OrderedFilter. Он позволяет выставить особый числовой приоритет фильтру, выше или ниже дефолтного (1000). Фильтры с приоритетом выше будут выполняться раньше обычных, с приоритетом ниже — после них.

Конечно, здесь можно запутаться и случайно выставить двум фильтрам одинаковый приоритет, например, в 999. Тогда первым к запросу будет применен тот, который был добавлен раньше.

Не только фильтры

Как сделать авторизацию через фильтры, показано выше. Но кроме этого способа в REST Assured существует и другой, через AuthenticationScheme:

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

Это устаревший способ. Вместо него стоит выбрать тот, который показан выше. Причины две:

Проблема с зависимостями

Документация к REST Assured указывает, что для использования Oauth1 или Oauth2 (указывая токен в качестве query-параметра) авторизации необходимо добавить в зависимости Scribe. Однако импорт последней версии вам не поможет — у вас возникнет ошибка, описанная в одной из открытых проблем. Решить ее можно только импортом старой версии библиотеки, 2.5.3. Однако в этом случае вы наткнетесь на другую проблему.

Вообще никакая другая версия Scribe не работает с Oauth2 REST Assured версии 3.0.3 и выше (и недавний выход 4.0.0 это не исправил).

Логирование не работает

Фильтры применяются к запросам в определенном порядке. А AuthenticationScheme применяется после них. А значит, будет трудно обнаружить проблему с авторизацией в тесте — она же не залогируется.

Еще о синтаксисе REST Assured

Большое количество тестов обычно значит, что они еще и сложные. А если API является основным предметом тестирования, и нужно проверить не просто поля json’a, а бизнес-логику, то с REST Assured тест превращается в простыню:

@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);
}

Этот тест проверяет, что, когда мы кормим Куки-монстра, мы правильно подсчитываем, сколько печенек ему дали, и указываем это в истории. Но с первого взгляда это нельзя понять — все запросы выглядят одинаково, и неясно, где заканчивается подготовка данных через API, а где посылается тестируемый запрос.

given(), when() и then() REST Assured берет из BDD, как Spock или Cucumber. Однако в сложных тестах их смысл теряется, ведь масштаб теста становится намного больше, чем один запрос — это одно мелкое действие, которое нужно обозначать одной строкой. А для этого можно перенести вызовы REST Assured во вспомогательные классы:

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

И вызывать в тесте:

JsonPath response = CookieMonsterHelper.getCookies();

Хорошо, когда такие классы-хэлперы универсальны, чтобы вызов одного метода можно было встроить в большое количество тестов — тогда их вообще можно вынести в отдельную библиотеку: вдруг потребуется в какой-то момент вызвать метод в другом проекте. Только при этом придется убрать всю проверку ответа, которую может сделать Rest Assured — все-таки в ответ на один и тот же запрос часто могут вернуться совсем разные данные.

Заключение

REST Assured — это библиотека для тестирования. Она умеет делать две вещи: посылать запросы и проверять ответы. Если мы пытаемся вынести ее из тестов и убрать всю валидацию, то она превращается в HTTP-клиент.

Если вам предстоит написать большое количество тестов и в дальнейшем их поддерживать – задумайтесь, нужен ли в них HTTP-клиент с громоздким синтаксисом, статической конфигурацией, путаницей в порядке применения фильтров и спецификаций и логированием, которое можно легко сломать? Может быть, девять лет назад REST Assured был самым удобным инструментом, но за это время появились альтернативы, — Retrofit, Feign, Unirest и т.д., — у которых нет таких особенностей.

Большинство проблем, которые описаны в статье, проявляют себя в крупных проектах. Если вам нужно быстро написать пару тестов и навсегда про них забыть, а Retrofit не нравится, REST Assured — лучший вариант.

Если вы уже пишете тесты с использованием REST Assured, не обязательно бросаться все переписывать. Если они стабильные и быстрые, это потратит больше вашего времени, чем принесет практической пользы. Если нет — REST Assured не ваша основная проблема.

Каждый день количество тестов, написанных в DINS для API RingCentral, становится все больше, и они по-прежнему используют REST Assured. Количество времени, которое придется потратить, чтобы перейти на другой HTTP-клиент хотя бы в новых тестах, слишком велико, а созданные классы-хэлперы и методы, настраивающие конфигурацию тестов, решают большинство проблем. В этом случае сохранить цельность проекта с тестами важнее, чем использовать самый красивый и модный клиент. REST Assured, несмотря на свои недостатки, выполняет свою главную работу.

Автор: imaginez

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js