Статьи

Выполняйте свои обещания: контрактное тестирование API-интерфейсов JAX-RS

Прошло много времени с тех пор, как мы говорили о тестировании и применении эффективных методов работы с TDD , особенно связанных с веб-сервисами REST (ful) и API. Но эта тема никогда не должна быть забыта, особенно в мире, где все делают микросервисы , что бы это ни означало, подразумевало или принимало.

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

Итак, как работает контрактное тестирование ? В двух словах, это удивительно простая техника, и она руководствуется следующими шагами:

  • провайдер (скажем, Сервис A ) публикует свой контакт (или спецификацию), реализация может даже быть недоступна на данном этапе
  • Потребитель (скажем, Услуга B ) следует этому контракту (или спецификации) для осуществления переговоров со Службой A
  • Кроме того, потребитель вводит тестовый набор, чтобы проверить свои ожидания относительно выполнения контракта на обслуживание A

В случае веб-служб и API SOAP все очевидно, поскольку существует явный контракт в виде файла WSDL . Но в случае REST (FUL) API, есть много разных вариантов за углом ( WADL , RAML , Swagger , …) и до сих пор нет соглашения по одному. Это может показаться сложным, но, пожалуйста, не расстраивайтесь, потому что на помощь приходит Пакт !

Pact — это семейство платформ для поддержки тестирования контрактов, ориентированных на потребителя Существует множество языковых привязок и реализаций, включая JVM, JVM Pact и Scala-Pact . Чтобы развить такую ​​экосистему полиглотов, Pact также включает специальную спецификацию, чтобы обеспечить взаимодействие между различными реализациями.

Отлично, Pact уже здесь, сцена готова, и мы готовы начать с некоторыми реальными фрагментами кода. Предположим, мы разрабатываем веб-API REST (ful) для управления людьми, используя потрясающие спецификации Apache CXF и JAX-RS 2.0 . Для простоты мы собираемся ввести только две конечные точки:

  • POST / people / v1 для создания нового человека
  • GET / people / v1? Email = <email>, чтобы найти человека по адресу электронной почты

По сути, мы можем не беспокоиться и просто сообщать эти минимальные части нашего контракта на обслуживание всем, поэтому потребители сами должны разобраться с этим (и действительно, Pact поддерживает такой сценарий). Но, конечно, мы не такие, мы заботимся и хотели бы подробно документировать наши API, вероятно, мы уже знакомы с Swagger . С этим вот наш PeopleRestService .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Api(value = "Manage people")
@Path("/people/v1")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PeopleRestService {
    @GET
    @ApiOperation(value = "Find person by e-mail",
        notes = "Find person by e-mail", response = Person.class)
    @ApiResponses({
        @ApiResponse(code = 404,
            message = "Person with such e-mail doesn't exists",
            response = GenericError.class)
    })
    public Response findPerson(
        @ApiParam(value = "E-Mail address to lookup for", required = true)
        @QueryParam("email") final String email) {
        // implementation here
    }
 
    @POST
    @ApiOperation(value = "Create new person",
        notes = "Create new person", response = Person.class)
    @ApiResponses({
        @ApiResponse(code = 201,
            message = "Person created successfully",
            response = Person.class),
        @ApiResponse(code = 409,
            message = "Person with such e-mail already exists",
            response = GenericError.class)
    })
    public Response addPerson(@Context UriInfo uriInfo,
        @ApiParam(required = true) PersonUpdate person) {
        // implementation here
    }
}

Детали реализации на данный момент не важны, однако давайте взглянем на классы GenericError , PersonUpdate и Person, поскольку они являются неотъемлемой частью нашего сервисного контракта.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@ApiModel(description = "Generic error representation")
public class GenericError {
    @ApiModelProperty(value = "Error message", required = true)
    private String message;
}
 
@ApiModel(description = "Person resource representation")
public class PersonUpdate {
    @ApiModelProperty(value = "Person's first name", required = true)
    private String email;
    @ApiModelProperty(value = "Person's e-mail address", required = true)
    private String firstName;
    @ApiModelProperty(value = "Person's last name", required = true)
    private String lastName;
    @ApiModelProperty(value = "Person's age", required = true)
    private int age;
}
 
@ApiModel(description = "Person resource representation")
public class Person extends PersonUpdate {
    @ApiModelProperty(value = "Person's identifier", required = true)
    private String id;
}

Превосходно! Как только у нас появятся аннотации Swagger и будет включена интеграция Apache CXF Swagger , мы сможем сгенерировать файл спецификации swagger.json , перенести его в пользовательский интерфейс Swagger и разослать каждому партнеру или заинтересованному потребителю.

люди-отдых-сервис-2

люди-отдых-сервис-1

Было бы здорово, если бы мы могли использовать эту спецификацию Swagger вместе с реализацией платформы Pact в качестве контракта на обслуживание. Благодаря Atlassian мы, безусловно, можем сделать это, используя swagger-request-validator , библиотеку для проверки HTTP- запроса / ответа на соответствие спецификации Swagger / OpenAPI, которая также хорошо интегрируется с Pact JVM .

Круто, теперь давайте перейдем от поставщика к потребителю и попытаемся выяснить, что мы можем сделать, имея такую ​​спецификацию Swagger в наших руках. Оказывается, мы можем сделать много вещей. Например, давайте посмотрим на действие POST , которое создает нового человека. Как клиент (или потребитель) мы могли бы выразить наши ожидания в такой форме, что, имея действительную полезную нагрузку, отправленную вместе с запросом, мы ожидаем, что провайдер вернет код состояния HTTP 201, а полезная нагрузка ответа должна содержать нового человека с идентификатор назначен. На самом деле перевод этого утверждения в утверждения Pact JVM довольно прост.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Pact(provider = PROVIDER_ID, consumer = CONSUMER_ID)
public PactFragment addPerson(PactDslWithProvider builder) {
    return builder
        .uponReceiving("POST new person")
        .method("POST")
        .path("/services/people/v1")
        .body(
            new PactDslJsonBody()
                .stringType("email")
                .stringType("firstName")
                .stringType("lastName")
                .numberType("age")
        )
        .willRespondWith()
        .status(201)
        .matchHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
        .body(
            new PactDslJsonBody()
                .uuid("id")
                .stringType("email")
                .stringType("firstName")
                .stringType("lastName")
                .numberType("age")
        )
       .toFragment();
}

Чтобы запустить процесс проверки контракта, мы собираемся использовать удивительный JUnit и очень популярную платформу REST Assured . Но перед этим давайте уточним, что такое PROVIDER_ID и CONSUMER_ID из приведенного выше фрагмента кода. Как и следовало ожидать, PROVIDER_ID является ссылкой на спецификацию контракта. Для простоты мы могли бы получить спецификацию Swagger из конечной точки PeopleRestService, и , к счастью, улучшения в тестировании Spring Boot делают эту задачу легкой и понятной .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
    classes = PeopleRestConfiguration.class)
public class PeopleRestContractTest {
    private static final String PROVIDER_ID = "People Rest Service";
    private static final String CONSUMER_ID = "People Rest Service Consumer";
 
    private ValidatedPactProviderRule provider;
    @Value("${local.server.port}")
    private int port;
 
    @Rule
    public ValidatedPactProviderRule getValidatedPactProviderRule() {
        if (provider == null) {
            provider = new ValidatedPactProviderRule("http://localhost:" + port +
                "/services/swagger.json", null, PROVIDER_ID, this);
        }
 
        return provider;
    }
}

CONSUMER_ID — это просто способ идентифицировать потребителя, не так много, чтобы сказать об этом. На этом мы готовы закончить наш первый тестовый пример:

1
2
3
4
5
6
7
8
@Test
@PactVerification(value = PROVIDER_ID, fragment = "addPerson")
public void testAddPerson() {
    given()
        .contentType(ContentType.JSON)
        .body(new PersonUpdate("tom@smith.com", "Tom", "Smith", 60))
        .post(provider.getConfig().url() + "/services/people/v1");
}

Потрясающие! Как просто, просто обратите внимание на наличие аннотации @PactVerification, где мы ссылаемся на соответствующий проверочный фрагмент по имени, в этом случае он указывает на метод addPerson, который мы ввели ранее.

Отлично, но … какой смысл? Рад, что вы спрашиваете, потому что отныне любые изменения в контракте, которые могут быть несовместимыми с предыдущими версиями, будут нарушать наш тест. Например, если провайдер решит удалить свойство id из полезной нагрузки ответа, контрольный пример не пройден. Переименование свойств полезной нагрузки запроса, большое нет-нет, опять же, контрольный пример не пройдёт. Добавление новых параметров пути? Не повезло, контрольный пример не пропустит. Вы можете пойти еще дальше и потерпеть неудачу при каждом изменении контракта, даже если оно обратно совместимо (для точной настройки используется swagger-validator.properties ).

1
2
validation.response=ERROR
validation.response.body.missing=ERROR

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Pact(provider = PROVIDER_ID, consumer = CONSUMER_ID)
public PactFragment findPerson(PactDslWithProvider builder) {
    return builder
        .uponReceiving("GET find person")
        .method("GET")
        .path("/services/people/v1")
        .query("email=tom@smith.com")
        .willRespondWith()
        .status(200)
        .matchHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
        .body(
            new PactDslJsonBody()
                .uuid("id")
                .stringType("email")
                .stringType("firstName")
                .stringType("lastName")
                .numberType("age")
        )
        .toFragment();
}
 
@Test
@PactVerification(value = PROVIDER_ID, fragment = "findPerson")
public void testFindPerson() {
    given()
        .contentType(ContentType.JSON)
        .queryParam("email", "tom@smith.com")
        .get(provider.getConfig().url() + "/services/people/v1");
}

Обратите внимание, что здесь мы ввели проверку строки запроса с помощью подтверждения запроса («email=tom@smith.com») . Следуя возможным результатам, давайте также рассмотрим неудачный сценарий, когда человек не существует, и мы ожидаем, что будет возвращена некоторая ошибка, наряду с кодом состояния 404 , например:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@Pact(provider = PROVIDER_ID, consumer = CONSUMER_ID)
public PactFragment findNonExistingPerson(PactDslWithProvider builder) {
    return builder
        .uponReceiving("GET find non-existing person")
        .method("GET")
        .path("/services/people/v1")
        .query("email=tom@smith.com")
        .willRespondWith()
        .status(404)
        .matchHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
        .body(new PactDslJsonBody().stringType("message"))
        .toFragment();
}
 
@Test
@PactVerification(value = PROVIDER_ID, fragment = "findNonExistingPerson")
public void testFindPersonWhichDoesNotExist() {
    given()
        .contentType(ContentType.JSON)
        .queryParam("email", "tom@smith.com")
        .get(provider.getConfig().url() + "/services/people/v1");
}

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

Благодаря Swagger мы смогли сделать несколько ярлыков, но в случае, если у вас нет такой роскоши, у Pact достаточно богатая спецификация, которую вы всегда можете изучить и использовать. В любом случае, Pact JVM отлично справляется с задачей, помогая вам писать небольшие и сжатые тестовые примеры.

Полные источники проекта доступны на Github .