REST Assured — DSL для тестирования REST-сервисов, который встраивается в тесты на Java. Это решение появилось более девяти лет назад и стало популярным из-за своей простоты и удобного функционала.
В DINS мы написали с ним более 17 тысяч тестов и за пять лет использования столкнулись со множеством «подводных камней», о которых нельзя узнать сразу после импорта библиотеки в проект: статическим контекстом, путаницей в порядке применения фильтров к запросу, трудностями в структурировании теста.
Эта статья — о таких неявных особенностях REST Assured. Их нужно учитывать, если есть шанс, что количество тестов в проекте будет быстро увеличиваться — чтобы потом не пришлось переписывать.
Что тестируем
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, несмотря на свои недостатки, выполняет свою главную работу.