Тестируем OpenAPI-документацию в автотестах

de2625c1cdccc37a76542e9fe2e21a28.jpg

Всем привет! Я Настя — QA команды, которая занимается развитием и поддержкой публичного API hh.ru. В этой статье расскажу, как мы проверяем OpenAPI-документацию в тестах при помощи автогенерации классов и валидации.

Контекст

В этой статье моя коллега Ира рассказала о наших мотивах и причинах перевода документации. Если коротко, то:

  • генерация DTO по документации;

  • возможность проверять документацию на соответствие действительности;

  • стандартизация написания документации и вытекающие из этого преимущества.

Мотивация тестировщика

В задачи тестировщика в hh.ru входит контроль выпуска фичи и проверка того, что она успешно дошла до пользователя, не сломав тестовые стенды. В моем случае одной большой фичей была документация в новом формате. Для нас было важно выпустить ее с минимальным количеством неточностей, так как ошибки в OpenAPI-документации напрямую затрагивают пользователей, которые могут ее использовать в своих системах (например, генерировать по ней свои клиенты, валидировать запросы и ответы).

Как выпустить документацию с минимумом ошибок?

На самом деле вариантов масса: от совсем примитивных ручных проверок до написания каких-то скриптов, которые эти проверки автоматизируют. Но для начала вспомним, что было у нас на момент постановки задачи: наши автотесты очень тесно связаны с выпускаемыми фичами в сервисе, у нас есть правило, что всё покрывается автотестами (исключения бывают). Так мы гарантируем работоспособность выпускаемого функционала, даже если его делает не наша команда, что бывает достаточно часто. Фактически, на каждую ручку из нашей документации были автотесты. Поэтому показалось логичным использовать их для того, чтобы они проверяли OpenAPI-документацию и никого не беспокоили. 

Валидация

В нашем случае под валидацией понимается процесс проверки ответа сервиса по заданной схеме. Валидатор позволяет выявить не только неточности в работе сервиса, но и неточности в документации. Так как мы переписывали документацию по эндпоинтам, которые давно существуют, мы полагались на результаты валидации в контексте правильности документации, а не работы самого сервиса. Если валидация проходила с ошибкой, то мы разбирались, что в документации написано неверно. Ниже описала, что мы валидируем и как.

В автотестах валидируем только ответы

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

Подробнее

  1. Добавляем зависимость.


   org.openapi4j
   openapi-operation-validator
   1.0.7
  1. Добавляем класс, который будет являться прослойкой между сторонней библиотекой и нашей архитектурой тестов. В общих чертах класс выглядит так:

public class OpenApiValidator {

 private static final String OPENAPI_DOC_URL = "http://url.to.download/openapi/specification";
 private static final OpenApi3 parser;
 private static final RequestValidator validator;

 static {
   try {
     parser = new OpenApi3Parser().parse(new URL(OPENAPI_DOC_URL), false);
     // Если в именах используются нижние подчеркивания (например, path-параметры), то их необходимо вырезать,
     // так как такие имена используются в качестве имени для capture-группы, а там нижние подчеркивания недопустимы.
     // Подробнее смотреть в jdk doc java.util.regex.Pattern.
     Map pathsWithoutUnderscores = parser.getPaths().entrySet().stream()
         .collect(toMap(entry -> removeUnderscores(entry.getKey()),
             Map.Entry::getValue));
     parser.setPaths(pathsWithoutUnderscores);
     validator = new RequestValidator(parser);
   } catch (ResolutionException e) {
     throw new RuntimeException(String.format("Docs by %s has not been found or cannot be reached", OPENAPI_DOC_URL), e);
   } catch (ValidationException e) {
     throw new RuntimeException(String.format("Url or docs by %s contains errors", OPENAPI_DOC_URL), e);
   } catch (MalformedURLException e) {
     throw new RuntimeException("Malformed URL has occurred", e);
   }
 }

 // Опустила методы, которые используют validateResponse с различными параметрами GET, POST, PUT, для примера оставила только DELETE
 public static void validateDeleteResponse(HttpResponse response, String path) {
   validateResponse(response, HttpMethod.DELETE, path);
 }

 public static void validateResponse(HttpResponse response, HttpMethod method, String path) {
   Path action = parser.getPath(removeUnderscores(path));
   Operation operation = action.getOperation(method.name().toLowerCase());
   DefaultResponse defaultResponse = convertHttpResponseToDefaultResponse(response);
   validateDefaultResponse(response.getStatusLine().getStatusCode(), path, action, operation, defaultResponse);
 }

 private static void validateDefaultResponse(int statusCode, String path, Path action, Operation operation, DefaultResponse defaultResponse) {
   ValidationData validationResult = new ValidationData<>();
   try {
     validator.validate(defaultResponse, action, operation, validationResult);
   } catch (ValidationException e) {
     StringBuilder errorBuilder = new StringBuilder();
     for (var itemResult: validationResult.results().items()) {
       String error = String.format("При валидации ответа запроса %s с кодом %s произошла ошибка: %s%s" +
               "Скачать спецификацию можно по адресу: https://path.to.spec",
           path, statusCode, itemResult.message(), System.lineSeparator());
       errorBuilder.append(error);
     }
     Assert.fail(errorBuilder.toString(), e);
   }
 }

 private static DefaultResponse convertHttpResponseToDefaultResponse(HttpResponse response) {
   return new DefaultResponse.Builder(response.getStatusLine().getStatusCode())
       .headers(getHeaders(response))
       .body(Body.from(getResponseBodyForValidation(response)))
       .build();
 }

 private static String getResponseBodyForValidation(HttpResponse response) {
   // ... логика по получению боди из существующего response
   return stringResponseBody;
 }

 private static Map> getHeaders(HttpResponse response) {
   return stream(response.getAllHeaders())
       .collect(groupingBy(Header::getName, mapping(Header::getValue, toCollection(ArrayList::new))));
 }

 private static String removeUnderscores(String path) {
   return path.replaceAll("_", "");
 }
  1. Используем в тестах.

@Test(description = "Удаление фото должно работать")
public void photoDeletionShouldWork() {
 OpenApiValidator.validateDeleteResponse(doDeleteArtifactRequest(photo, auth), "/artifacts/{id}");
}

Генерация классов

Валидация дала первичное представление о том, насколько корректно написана документация. Сгенерированные классы используются в автотестах для проверки объектов на соответствие различным бизнес-правилам, которые могут быть описаны. Также сгенерированные классы позволяют более жестко закрепить контракт. Ниже расскажу, как добавить генерацию в проект.

1. Генерируем только DTO, переписываем стандартные шаблоны

Можно генерировать разные файлы: models (они же у нас называются DTO), model tests, model documentation и все тоже самое для API (см. property maven plugin). Мы решили генерировать DTO, потому что их можно сразу использовать в автотестах, для этого допилили шаблон. 

Подробнее

  1. Добавляем и настраиваем плагин под себя.


       org.openapitools
       openapi-generator-maven-plugin
       7.2.0
       
           
               
                   generate
               
               generateHhapiOpenapiDtos
               

                   ${openapi.generator.skip}
                   ${openapi.spec.url}
                   java                                
                   ${project.basedir}/.../dto/openapi                  
 
                   ${project.basedir}/.../templates
                   generated

                   true
                   false
                   false
                   false
                   false
                   false
                   false
                   true
                   true

                   
                       packagePath=...dto.openapi
                   

                   true
                   jersey2
                   

                       true
                   
                   

                       ./
                   
               
           
       
   
  1. Настраиваем шаблоны.

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

…
// Это автосгенеренный файл. Пожалуйста, не правьте его вручную! В случае ошибок см. README.md проекта.
public class {{classname}} {{#parent}}extends {{{parent}}} {{/parent}}{

…
 {{#deprecated}}
@Deprecated
 {{/deprecated}}
 {{#jackson}}
@JsonProperty("{{baseName}}")
 {{/jackson}}
 {{#isContainer}}
public {{{datatypeWithEnum}}} {{baseName}};
 {{/isContainer}}
…
  1. Генерируем классы по документации.

    У нас есть два варианта, как генерировать классы. Первый — генерируем после раскатки изменений в документации на тестовое окружение, в таком случае достаточно выполнить:

    mvn clean compile -Dopenapi.generator.skip=false

    Второй — когда документация лежит у вас локально:

    mvn clean compile -Dopenapi.generator.skip=false -Dopenapi.spec.url=/path_to_your_specification/openapi_spec.yml

    По умолчанию openapi.generator.skip и openapi.spec.urlмогут быть определены выше в properties, либо в локальных файлах проекта.

2. Генерируем все, а перед генерацией наводим порядок

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

Подробнее

Добавляем плагин в проект и настраиваем под себя.


   maven-clean-plugin
   3.1.0
   
       
           CleanGeneratedFiles
           initialize
           
               clean
           
           
               ${openapi.generator.skip}
               true
               
                   
                       ${openapi.generator.folder}
                   
               
           
       
   

3. Генерируем только при выпуске задач сервиса и никогда при локальной сборке проекта автотестов

Есть разные этапы сборки проектов, и генерацию можно добавить в любой из них. По умолчанию она будет запускаться всегда. И в нашем случае это было неудобно, так как генерация идет по документации с тестового стенда, который может здорово отставать от прода. Поэтому мы отключили ее при локальной сборке проекта, но оставили при выпуске задач. Так мы гарантируем, что на момент выпуска задачи проект автотестов собирается ровно с теми классами, которые сгенерировались при текущей версии сервиса и документации.

4. Заменяем существующие классы на автосгенеренные, настраиваем жесткую проверку при десериализации

У этого метода много противников, но он позволяет нам отлавливать ситуации, когда сервис начинает возвращать что-то не то. Если при десериализации возвращаются неизвестные поля, автотест не дает дойти до бизнес-проверок в тестах, а сразу падает с исключением: вернулись неизвестные поля, которые не описаны в классах (в документации).

Здесь могут быть послабления, так как возможна ситуация, когда сервис возвращает рандомные поля. Это описано в документации и считается легитимным и только в таком случае, мы пропускаем проверку на неизвестные поля.

Подробнее

Наш проект автотестов написан на Java, поэтому часть шаблона, которая позволяет обходить правило десериализации, выглядит следующим образом:  

… //other imports
{{#isAdditionalPropertiesTrue}}import com.fasterxml.jackson.annotation.JsonIgnoreProperties;


@JsonIgnoreProperties(ignoreUnknown = true){{/isAdditionalPropertiesTrue}}
// Это автосгенеренный файл. Пожалуйста, не правьте его вручную! В случае ошибок см. README.md проекта.
public class {{classname}} {{#parent}}extends {{{parent}}} {{/parent}}{
…

Работа над ошибками

При проверке ошибок использовать автосгенеренные классы

Как только появляются такие классы, их надо использовать в автотестах, иначе пропадает весь смысл. Мы этого не учли в случае, когда генерятся классы по боди ошибок. Например, у нас в ответе при публикации вакансий может возвращаться ошибка 400 с большим набором параметров. Если они описаны в документации неверно, а в автотестах никто не проверяет enum значений, может возникнуть ситуация, когда в enum перечислены одни, а по факту возвращаются совсем другие. 

Инструкции

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

Итоги

Преимущества проверки документации в автотестах

  • Валидация позволяет обеспечить проверку наличия обязательных полей и строгого соответствия типов данных;

  • Генерация происходит автоматически, поэтому любые неточности в описании боди объектов станут заметны при первом прогоне автотестов, завязанных на сгенеренные классы;

  • Генерация позволяет избежать ошибок в описании DTO и уменьшить число ручных действий, что сокращает число ошибок по невнимательности и ускоряет написание тестов;

  • Валидация и генерация по документации не зависят друг друга и обеспечивают сцепку между сервисом, документацией и тестами.  

Оставшиеся проблемы

  • Нам приходится комитить сгенерированные файлы, из-за этого возникают разные проблемы. Например, кто-то может забыть закоммитить нужные файлы и выпустить доку, а потом у других разработчиков тестов будут лишние дифы;

  • Много шаблонных тестов для валидации ответов, поэтому приходится писать по сути одинаковые кейсы;

  • Генератор не всегда корректно отрабатывает в тех случаях, когда в документации несколько уровней вложенности. Например, несколько oneOf, которые наследуются друг от друга;

  • Валидация плохо работает с nullable объектами, они могут считаться обязательными, хотя согласно документации это не так.

Спасибо за внимание! Надеюсь, информация в статье стала полезной для вас, а если остались вопросы по нашему кейсу — буду рада ответить на них в комментариях.

© Habrahabr.ru