Тестирование служб RESTful на Java: лучшие практики
Оригинал Philipp Hauer. Как было в статье, так я и сделала перевод - от первого лица. Главное помнить, что лицо это не моё, а Филиппа Хауэра. Почитать о том, что такое RESTful можно тут.
Тестирование веб-служб RESTful может осложняться тем, что вам приходится иметь дело с низкоуровневыми проблемами, делающими тесты многословными, трудными для чтения и обслуживания. К счастью, существуют библиотеки и передовые методы, которые помогут вам сделать ваши интеграционные тесты краткими, чистыми, изолированными и легко обслуживаемыми. В этом посте рассматриваются лучшие практики.
TL; DR
- Общие рекомендации по модульному тестированию на Java также применимы к тестированию служб RESTful.
- Инвестируйте в хорошо читаемые и поддерживаемые тесты. Если вы позволите своим тестам протухнуть, вы приговорите свой сервис к гибели.
- Сделайте тесты независимыми от внутренних компонентов тестируемого сервиса (методы, схема БД). Это делает ваши тесты хрупкими.
- Используйте POJO и сопоставление объектов (object mapping)
- Создавайте индивидуальные POJO для ваших тестов.
- Всегда используйте POJO для создания полезной нагрузки запроса или проверки полезной нагрузки ответа. Это делает ваши тесты удобочитаемыми, краткими и безопасными. Не возитесь с ошибочными строками JSON.
- Используйте быстрые сеттеры в ваших POJO вместо загадочных бесконечных списков аргументов. IntelliJ IDEA может сгенерировать для вас быстрые сеттеры.
- Попробуйте Котлин . Его синтаксис отлично подходит для написания POJO.
- Используйте библиотеки
- Rest-Assured чтобы быстро создавать HTTP-запросы и делать проверки ответов.
- AssertJ для создания понятных, типизированных и читаемых проверок.
- JsonPath (интегрирован в rest-assured), чтобы легко парсить JSON для простых проверок (хотя более предпочтителен POJO).
- Ожидание, чтобы справиться с асинхронным поведением.
- Object mapper (например, Jackson) для сопоставления объектов между POJO и JSON.
- OkHttp MockWebServer для тестирования клиентов REST.
- Используйте принципы чистого кода для своих автотестов. Напишите тесты, которые можно читать как рассказ. Для этого используйте подметоды с красивым описательным названием. Используйте небольшие методы.
Реализация тестов
Давайте кодить! Предположим, мы хотим протестировать RESTful сервис, предоставляющий информацию о блогах. Сервис дает нам следующие ресурсы, которые мы хотим протестировать:
/blogs /blogs/<blogId>
Используйте Rest-Assured
Rest-Assured предоставляет удобный API-интерфейс для создания легкочитаемых тестов для ресурсов REST.
import static com.jayway.restassured.RestAssured.given; @Test public void collectionResourceOK(){ given() .param("limit", 20) .when() .get("blogs") .then() .statusCode(200); }
Параметризуйте запросы через RequestSpecifications
Создайте RequestSpecification для параметризации запросов (базовый URL, параметры, тип контента, логирование), которые вы хотите использовать для всех запросов.
private static RequestSpecification spec; @BeforeClass public static void initSpec(){ spec = new RequestSpecBuilder() .setContentType(ContentType.JSON) .setBaseUri("http://localhost:8080/") .addFilter(new ResponseLoggingFilter())//лог запроса и ответа. Вы также можете логировать только сбои запросов. .addFilter(new RequestLoggingFilter()) .build(); } @Test public void useSpec(){ given() .spec(spec) .param("limit", 20) .when() .get("blogs") .then() .statusCode(200); }
Использование POJO и ObjectMapper
Не мучайтесь с конкатенацией строк или громоздким JsonObject для создания JSON для запроса или для проверки JSON в ответе. Это слишком подробно, не типизировано и порождает ошибки.
Вместо этого создайте отдельный класс POJO и позвольте ObjectMapper, например, Jackson, выполнить десериализацию и сериализацию за вас. Rest-assured обеспечивает встроенную поддержку сопоставления объектов.
BlogDTO retrievedBlog = given() .spec(spec) .when() .get(locationHeader) .then() .statusCode(200) .extract().as(BlogDTO.class); //проверяем объект retrievedBlog...
Используйте Fluent Setters для POJO
Не используйте обычные сеттеры или конструкторы с огромными списками аргументов (трудными для чтения и подверженными ошибкам) для создания полезной нагрузки тестового запроса. Вместо этого используйте fluent setters.
public class BlogDTO { private String name; private String description; private String url; //дайте вашей IDE сгенерировать геттеры и fluent setters за вас: public BlogDTO setName(String name) { this.name = name; return this; } public BlogDTO setDescription(String description) { this.description = description; return this; } public BlogDTO setUrl(String url) { this.url = url; return this; } // getter... }
IntelliJ IDEA генерирует эти сеттеры за вас. Более того, есть удобные шорткаты (горячие клавиши). Просто напишите поля (как private String name;) и нажмите Alt+Insert , Arrow-Down пролистайте до «Getter и Setter» нажмите Enter , выберите “Builder” для “Setter template”, затем Shift+Arrow-Down (несколько раз) и , наконец , нажмите кнопку Enter. Это так круто!
После этого вы можете использовать средства быстрой установки для написания читаемого и безопасного кода для создания тестовых данных.
BlogDTO newBlog = new BlogDTO() .setName("Example") .setDescription("Example") .setUrl("www.blogdomain.de");
Поскольку отображение объектов поддерживается rest-assured, мы можем просто передать объект в rest-assured. Rest-assured сериализует объект и установит JSON в теле запроса.
String locationHeader = given() .spec(spec) .body(newBlog) .when() .post("blogs") .then() .statusCode(201) .extract().header("location");
Рассмотрите Kotlin вместо Java
Язык JVM Kotlin позволяет определять POJO с помощью одной строчки кода. Например, класс BlogDTOбудет выглядеть так:
//definition: data class BlogDTO (val name: String, val description: String, val url: String) //usage: val newBlog = BlogDTO( name = "Example", description = "Example", url = "www.blogdomain.de")
С Kotlin мы можем значительно сократить шаблон. Определенный класс данных BlogDTOуже содержит конструктор, hashCode(), equals(), toString()и copy(). Нам не нужно их поддерживать. Более того, Kotlin поддерживает именованные аргументы, они делают вызов конструктора очень читабельным. Таким образом, нам вообще становятся не нужны сеттеры.
Если вы хотите узнать больше о Kotlin, прочтите пост «Kotlin. Экосистема Java заслуживает этого языка » .
Обратите внимание, что вы должны добавить jackson-module-kotlin в свой путь к классам, чтобы десериализация продолжалась. В противном случае Джексон будет жаловаться на отсутствие конструктора по умолчанию.
Используйте AssertJ для проверки возвращенных POJO
AssertJ - отличная библиотека для написания быстрых, удобочитаемых и типизированных тестовых проверок. Мне он нравится больше, чем Hamcrest, потому что AssertJ поможет вам найти подходящее совпадение для используемого типа.
Вы можете использовать AssertJ для проверки возвращенного (и десериализованного) POJO в теле ответа.
import static org.assertj.core.api.Assertions.assertThat; BlogDTO retrievedBlog = given() .spec(spec) .when() .get(locationHeader) .then() .statusCode(200) .extract().as(BlogDTO.class); assertThat(retrievedBlog.getName()).isEqualTo(newBlog.getName()); assertThat(retrievedBlog.getDescription()).isEqualTo(newBlog.getDescription()); assertThat(retrievedBlog.getUrl()).isEqualTo(newBlog.getUrl());
Используйте AssertJ's isEqualToIgnoringGivenFields()
Обычно вы проверяете, равен ли полученный объект отправляемому объекту, который вы хотели создать. Использование обычной equals() проверки не работает, так как служба сгенерировала идентификатор для нового объекта. Следовательно, объекты различаются по полям идентификатора. К счастью, вы можете указать AssertJ игнорировать определенные поля во время проверки равенства. Таким образом, вам не нужно проверять каждое поле вручную.
assertThat(retrievedEntity).isEqualToIgnoringGivenFields(expectedEntity, "id");
Также ознакомьтесь с методом isEqualToIgnoringNullFields().
Пишите чистый тестовый код
Помните о принципах чистого кода! Напишите тестовый код, который можно читать как рассказ - извлекайте код в подметоды с красивыми описательными именами. Используйте короткие методы. Это сделает ваши тесты читаемыми и поддерживаемыми.
Главное правило: каждый раз, когда вы начинаете строить блоки внутри метода, разделенные пустой строкой или комментарием, держитесь! Вместо этого извлеките блоки кода в новый метод и используйте комментарий как имя метода ( Ctrl+Alt+M извлекает выбранный код в новый метод в IntelliJ).
Например, следующий код можно понять очень быстро:
@Test public void createBlogAndCheckExistence(){ BlogDTO newBlog = createDummyBlog(); String blogResourceLocation = createResource("blogs", newBlog); BlogDTO retrievedBlog = getResource(blogResourceLocation, BlogDTO.class); assertEqualBlog(newBlog, retrievedBlog); } private BlogDTO createDummyBlog() { return new BlogDTO() .setName("Example Name") .setDescription("Example Description") .setUrl("www.blogdomain.de"); } //nice reusable method private String createResource(String path, Object bodyPayload) { return given() .spec(spec) .body(bodyPayload) .when() .post(path) .then() .statusCode(201) .extract().header("location"); } //nice reusable method private <T> T getResource(String locationHeader, Class<T> responseClass) { return given() .spec(spec) .when() .get(locationHeader) .then() .statusCode(200) .extract().as(responseClass); } private void assertEqualBlog(BlogDTO newBlog, BlogDTO retrievedBlog) { assertThat(retrievedBlog.getName()).isEqualTo(newBlog.getName()); assertThat(retrievedBlog.getDescription()).isEqualTo(newBlog.getDescription()); assertThat(retrievedBlog.getUrl()).isEqualTo(newBlog.getUrl()); }
Если вы будете придерживаться шаблона «Given-When-Then» для написания тестов, вы получите аналогичный результат. Каждая часть (Given, When, Then) должна содержать только несколько строк (в идеале - одну строку). Для этого попробуйте извлечь каждую часть в подметоды.
@Test public void test(){ //Given: установить точку входа для действия перед началом теста (test data, mocks, stubs) //When: выполните действие, которое хотите протестировать //Then: проверьте вывод проверками }
Создание многоразовых методов для общих операций с ресурсами
Взгляните на методы createResource() и getResource() выше. Эти методы можно повторно использовать для каждого ресурса. Вы можете поместить их в абстрактный тестовый класс, который может быть расширен вашими конкретными тестовыми классами.
Используйте AssertJ's as()
Используйте AssertJ, as()чтобы добавить информацию о домене в сообщения об ошибках утверждения.
assertThat(retrievedBlog.getName()).as("Blog Name").isEqualTo(newBlog.getName()); assertThat(retrievedBlog.getDescription()).as("Blog Description").isEqualTo(newBlog.getDescription()); assertThat(retrievedBlog.getUrl()).as("Blog URL").isEqualTo(newBlog.getUrl());
Создание индивидуальных POJO
Добавьте только необходимые поля в POJO. Сервис возвращает JSON с 20 свойствами, но вас интересуют только два из них? Добавьте @JsonIgnoreProperties(ignoreUnknown = true) аннотацию к вашему классу POJO, и Джексон не будет беспокоиться, если в ответе будет больше свойств JSON, чем полей в вашем POJO.
@JsonIgnoreProperties(ignoreUnknown = true) public class BlogDTO { private String name; //the other JSON properties are not relevant for the test //... }
В качестве альтернативы вы можете установить эту конфигурацию глобально для ObjectMapper:
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Как видите, использование генерации кода IDE @JsonIgnoreProperties делает написание POJO для полезной нагрузки чрезвычайно простым и быстрым. Нет никаких обвинений в том, что они не используются.
Публичные поля не всегда запрещены
Есть случаи, когда вы можете еще больше упростить код класса POJO: просто сделайте поля общедоступными. OMG, он действительно предлагал это ?!
@JsonIgnoreProperties(ignoreUnknown = true) public class BlogListDTO { public int count; public List<BlogReference> blogs; public static class BlogReference{ public int id; public String name; public String href; } } @Test public void getBlogList(){ BlogListDTO retrievedBlogs = given() .spec(spec) .when() .get("blogs") .then() .statusCode(200) .extract().as(BlogListDTO.class); assertThat(retrievedBlogs.count).isGreaterThan(7); assertThat(retrievedBlogs.blogs).isNotEmpty(); }
Это может быть полезно, если ответ JSON содержит вложенные данные, а вы все еще хотите использовать POJO. Использование общедоступных полей позволяет вам написать вложенный класс POJO из нескольких строк. Но я рекомендую этот подход только а) если класс не используется повторно в других проектах (что должно быть верно для тестовых проектов) и б) если класс используется только для сопоставления полезной нагрузки ответа. Если вы хотите отобразить полезную нагрузку запроса, вам следует использовать свободно работающие сеттеры, потому что создание объектов с общедоступными полями неуклюже. Так что будьте осторожны с этим подходом.
Используйте JsonPath для простых случаев
Если вас интересует только одно значение ответа JSON, создание класса POJO для сопоставления будет немного излишним. В этом случае JsonPath можно использовать для извлечения определенных значений из документа JSON. JsonPath похож на XPath для JSON.
Допустим, вы хотите получить одно глубоко вложенное значение.
JsonPath jsonPath = new JsonPath("{\"blogs\":[\"posts\":[{\"author\":{\"name\":\"Paul\"}}]]}"); String value = jsonPath.getString("blogs[0].posts[0].author.name");
Есть два возможных способа использования JsonPath с Rest-assured.
A: Преобразование JSON в объект JsonPath и использование AssertJ для его проверки.
JsonPath retrievedBlogs = given() .spec(spec) .when() .get("blogs") .then() .statusCode(200) .extract().jsonPath(); assertThat(retrievedBlogs.getInt("count")).isGreaterThan(7); assertThat(retrievedBlogs.getList("blogs")).isNotEmpty();
B: Rest-assured имеет встроенную поддержку JsonPath, но тогда вы должны использовать сопоставители Hamcrest.
import static org.hamcrest.Matchers.*; given() .spec(spec) .when() .get("blogs") .then() .statusCode(200) .content("count", greaterThan(7)) .content("blogs", is(not(empty())));
Хотя он более подробный, я лично предпочитаю решение AssertJ, потому что вам не нужно угадывать, какой Matcher работает для какого типа.
Однако с JsonPath вы возитесь со строками для адресации свойств JSON, что подвержено ошибкам. В других случаях лучше всего подходят POJO и сопоставление объектов.
Работа со списками
Когда дело доходит до утверждений о списках, есть три способа сделать это.
A: Отображение объектов + AssertJ. Типичный, читаемый, простой в отладке, но более подробный.
BlogListDTO retrievedBlogList = given() .spec(spec) .when() .get("blogs") .then() .statusCode(200) .extract().as(BlogListDTO.class); assertThat(retrievedBlogList.blogs) .extracting(blogEntry -> blogEntry.id) .contains(23);
Этот extracting()метод - отличная особенность AssertJ. Он позволяет отображать один элемент в другой (как map()в Java 8 Stream API). Помимо contains(), AssertJ предлагает много других методов полезный список , как containsAll(), containsExactly(), containsSequence()или doesNotContain().
B: JsonPath + Hamcrest. Лаконично, но более подвержено ошибкам, труднее отлаживать, и вы должны помнить о том, чтобы использовать правильное сопоставление для типа.
given() .spec(spec) .when() .get("blogs") .then() .statusCode(200) .content("blogs.id", hasItem(23));
Но я признаю, что вы можете писать действительно краткие выражения с помощью JsonPath. Например, blogs.id собирает свойства id каждого элемента в списке блогов в новый список.
C: JsonPath + AssertJ. 50% типизированный, довольно подробный, но удобный для отладки.
JsonPath retrievedBlogList = given() .spec(spec) .when() .get("blogs") .then() .statusCode(200) .extract().jsonPath(); assertThat(retrievedBlogList.getList("blogs.id")) .contains(23);
Работа с асинхронным поведением (например, с событиями)
Иногда в тесте приходится подождать, пока асинхронное событие не вступит в силу. Вы должны опрашивать, пока не будет выполнено определенное условие. Awaitility предоставляет удобный API для ожидания и опроса, пока утверждение не станет истинным или не истечет время ожидания.
import static com.jayway.awaitility.Awaitility.await; sendAsyncEventThatCreatesABlog(123); await().atMost(Duration.TWO_SECONDS).until(() -> { given() .when() .get("blogs/123") .then() .statusCode(200); });
Обратите внимание, что методы Awaitility возвращают неизменяемый ConditionFactory. Таким образом, вы можете настроить поведение для опроса и ожидания один раз и повторно использовать его.
public static final ConditionFactory WAIT = await() .atMost(new Duration(15, TimeUnit.SECONDS)) .pollInterval(Duration.ONE_SECOND) .pollDelay(Duration.ONE_SECOND); @Test public void waitAndPoll(){ WAIT.until(() -> { //... }); }
Тестирование клиентов REST с помощью MockWebServer
Если вашей службе нужны другие службы для выполнения своей задачи, вы также можете протестировать класс, который выполняет запрос REST. Это легко сделать с помощью OkHttp MockWebServer, и самое главное : вы можете запустить его как модульный тест в сервисном проекте.
public class ImageReferenceServiceClientTest { private MockWebServer imageService; private ImageServiceClient imageClient; @Before public void init() throws IOException { imageService = new MockWebServer(); imageService.start(); //uses an available port HttpUrl baseUrl = imageService.url("/images/"); imageClient = new ImageServiceClient(baseUrl.host(), baseUrl.port()); } @Test public void requestImage() throws JsonProcessingException { ImageReference expectedImageRef = new ImageReference().setId("123").setHref("http://images.company.org/123"); String json = new ObjectMapper().writeValueAsString(expectedImageRef); imageService.enqueue(new MockResponse() .addHeader("Content-Type", "application/json") .setBody(json)); ImageReference retrievedImageRef = imageClient.requestImage("123"); assertThat(retrievedImageRef.getId()).isEqualTo(expectedImageRef.getId()); assertThat(retrievedImageRef.getHref()).isEqualTo(expectedImageRef.getHref()); } }
Вы также можете использовать MockWebServer в тесте интеграции в тестовом проекте.
Если ваш клиент использует Spring RestTemplate, проверьте MockRestServiceServer . Однако я по-прежнему предпочитаю MockWebServer OkHttp, потому что он запускает реальный сервер на выделенном порту. MockRestServiceServer перехватывает код RestTemplate. Так что во время тестов ваш клиент ведет себя иначе, чем в производственной среде. Кроме того, вы можете проверить сбои сети (тайм-ауты, дросселирование, недоступность сервера) более простым и надежным способом. И наконец, мне лично не нравится использование Spring многих статически импортированных методов.
Разделение и зависимости
Никогда не полагайтесь на внутреннюю часть службы REST
Тесты для служб RESTful - это тесты черного ящика. Поэтому никогда не следует полагаться на внутреннюю часть тестируемой службы RESTful - так ваши тесты останутся надежными. Они не ломаются при внутренних изменениях внутри сервиса. Пока REST API не меняется, ваши тесты будут работать. Это означает:
- Не иметь зависимости от сервисного проекта.
- Не используйте классы проекта службы в своем проекте тестирования интеграции. Особенно в отношении POJO (классы модели), хотя иногда это заманчиво. Если вам нужны классы моделей, перепишите их . При использовании правильных библиотек и инструментов это совсем не проблема.
- Более того, вы можете адаптировать классы к требованиям ваших тестов. Вы можете использовать другие языки (Kotlin), фреймворки сериализации (Jackson, Gson), типы полей или вложение классов для ваших тестовых POJO.
- Кроме того, использование различных POJO гарантирует, что вы случайно не сломаете свой API при изменении POJO приложения. В этом случае ваши тесты (с API-совместимыми POJO) завершатся ошибкой и укажут вам на эту проблему.
- Старайтесь избегать доступа к базе данных сервиса для создания тестовых данных. Это приводит к высокой зависимости. Если схема или технология базы данных изменится, ваши тесты сломаются. Но иногда доступ к базе данных неизбежен. Не будьте догматичны. Но помните о зависимости!
Кроме того, этот подход «черного ящика» отражает реальность: у ваших клиентов также нет знаний о реализации, почему в ваших тестах это должно быть? Это помогает поставить себя на место клиента, что, в свою очередь, помогает выявлять недостатки.
Создание тестовых данных
Когда дело доходит до вставки тестовых данных, у вас есть несколько вариантов:
- Использование интерфейса REST для вставки тестовых данных. В любом случае вам нужно протестировать интерфейс, так почему бы вам не использовать API, который должен использоваться для создания данных? Однако иногда вашему контракту не требуются ресурсы для обновления или вставки данных.
- Доступ к базе данных напрямую. Это удобно, но приводит к сильной зависимости. Интеграционные тесты - это тесты черного ящика, которые не должны нарушаться при изменении внутренней схемы базы данных. Но я признаю, что иногда доступ к базе данных неизбежен.
- Предоставление дополнительного ресурса для вставки данных, который доступен только во время тестирования. Вы можете использовать аутентификацию или переключатели функций, чтобы скрыть их в рабочей среде. Таким образом, ваши тесты будут более надежными, потому что вы не полагаетесь на внутренние компоненты. Однако вам нужно поддерживать дополнительные ресурсы. Кроме того, вы должны позаботиться о том, чтобы отключить эти ресурсы для тестирования, поскольку они могут значительно повлиять на безопасность вашего приложения или привести к неправильному использованию. Так что будьте осторожны.
Полезные инструменты и советы
- Postman - ваш лучший друг, когда дело доходит до специального тестирования служб RESTful.
- Если вы предпочитаете создавать специальные HTTP-запросы через интерфейс командной строки, попробуйте HTTPie . Это действительно здорово и намного лучше, чем cURL.
- Иногда бывает полезно добавить JSONView в Chrome.
- Во время разработки я предпочитаю выполнять тесты из IntelliJ IDEA вместо запуска Maven. Но для тестов обычно требуется информация о сервисе (URL, порт, учетные данные), которые обычно предоставляются Maven и передаются тестам через системные свойства во время сборки. Но IntelliJ также может предоставить необходимые свойства для тестов. Для этого вам нужно только изменить конфигурацию запуска по умолчанию (!). Откройте «Редактировать конфигурацию…», отредактируйте конфигурацию запуска по умолчанию для JUnit или TestNG и добавьте системные свойства ( -Dhost=<host>, -Dport=<port>). С этого момента эти свойства предоставляются каждому тесту, выполняемому в среде IDE. Просто нажмите на маленький зеленый значок слева от метода проверки и выберите Run <Your Test>.
Структура тестов
Где мы можем разместить наши тесты? У нас есть две возможности:
- Поместите интеграционные тесты в сервисный проект (в папку src / test / java) и выполните их во время той же сборки.
- Вы должны различать модульные и интеграционные тесты (используя соглашения об именах или аннотации)
- Простая установка. Нет необходимости в тестовой среде, отдельном задании Jenkins и конвейере сборки.
- Более длительное время сборки, более длительное время обработки и циклы обратной связи. Всегда нужно выполнять сборку целиком, включая тесты.
- Все равно может не работать, если вашему сервису нужны другие сервисы для выполнения своих задач.
- Создайте отдельный проект тестирования интеграции для каждой службы и выполните их в отдельной сборке / задании.
- Вы можете легко запустить только интеграционные тесты, не собирая все приложение. Это сокращает время выполнения работ.
- Вам необходимо настроить этап тестирования и конвейер сборки: построить службу, настроить тестовую среду, развернуть службу и запустить для нее интеграционные тесты.
- Лучшее разделение между сервисным проектом и интеграционными тестами.
- Другим командам (использующим ваш сервис) проще разобраться в поведении сервиса в виде тестов в тестовый проект.
Итог: если ваш сервис небольшой, интеграционных тестов не так много - вам не нужны другие сервисы во время тестов. Вы можете включить интеграционные тесты в свой обычный жизненный цикл сборки. Однако, когда ваша система сервисов становится сложной и взаимосвязанной, вам в любом случае необходимо настроить специальную тестовую среду. В этом случае лучше разместить тесты для службы в отдельном проекте и запустить их для развернутой службы в тестовой среде.
Исходный код и примеры
Я создал небольшой проект Github testingrestservice, чтобы показать тестовый код в действии на основе небольшой службы Spring Boot.