Без сомнения, JAX-RS — выдающаяся технология. А готовящаяся спецификация JAX-RS 2.0 предоставляет еще больше замечательных функций, особенно в отношении клиентского API. Тема сегодняшнего поста — интеграционное тестирование сервисов JAX-RS . Существует множество отличных тестовых фреймворков, таких как REST-уверен, чтобы помочь с этим, но я хотел бы представить это с помощью выразительного стиля BDD . Вот пример того, что я имею в виду:
|
1
2
3
4
|
Create new person with email <a@b.com> Given REST client for application deployed at http://localhost:8080 When I do POST to rest/api/people?email=a@b.com&firstName=Tommy&lastName=Knocker Then I expect HTTP code 201 |
Выглядит как типичный стиль Given / When / Then современных BDD- фреймворков. Насколько мы можем приблизиться к этому на JVM, используя статически скомпилированный язык? Оказывается, очень близко, благодаря отличным тестовым жгутам specs2 .
Следует упомянуть, что specs2 — это фреймворк Scala . Хотя мы собираемся написать немного Scala , мы сделаем это очень интуитивно понятным способом, знакомым опытному разработчику Java. Тестируемый сервис JAX-RS — это тот, который мы разработали в предыдущем посте . Вот:
|
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
|
package com.example.rs;import java.util.Collection;import javax.inject.Inject;import javax.ws.rs.DELETE;import javax.ws.rs.DefaultValue;import javax.ws.rs.FormParam;import javax.ws.rs.GET;import javax.ws.rs.POST;import javax.ws.rs.PUT;import javax.ws.rs.Path;import javax.ws.rs.PathParam;import javax.ws.rs.Produces;import javax.ws.rs.QueryParam;import javax.ws.rs.core.Context;import javax.ws.rs.core.MediaType;import javax.ws.rs.core.Response;import javax.ws.rs.core.UriInfo;import com.example.model.Person;import com.example.services.PeopleService;@Path( '/people' ) public class PeopleRestService { @Inject private PeopleService peopleService; @Produces( { MediaType.APPLICATION_JSON } ) @GET public Collection< Person > getPeople( @QueryParam( 'page') @DefaultValue( '1' ) final int page ) { return peopleService.getPeople( page, 5 ); } @Produces( { MediaType.APPLICATION_JSON } ) @Path( '/{email}' ) @GET public Person getPeople( @PathParam( 'email' ) final String email ) { return peopleService.getByEmail( email ); } @Produces( { MediaType.APPLICATION_JSON } ) @POST public Response addPerson( @Context final UriInfo uriInfo, @FormParam( 'email' ) final String email, @FormParam( 'firstName' ) final String firstName, @FormParam( 'lastName' ) final String lastName ) { peopleService.addPerson( email, firstName, lastName ); return Response.created( uriInfo.getRequestUriBuilder().path( email ).build() ).build(); } @Produces( { MediaType.APPLICATION_JSON } ) @Path( '/{email}' ) @PUT public Person updatePerson( @PathParam( 'email' ) final String email, @FormParam( 'firstName' ) final String firstName, @FormParam( 'lastName' ) final String lastName ) { final Person person = peopleService.getByEmail( email ); if( firstName != null ) { person.setFirstName( firstName ); } if( lastName != null ) { person.setLastName( lastName ); } return person; } @Path( '/{email}' ) @DELETE public Response deletePerson( @PathParam( 'email' ) final String email ) { peopleService.removePerson( email ); return Response.ok().build(); }} |
Очень простой сервис JAX-RS для управления людьми. Все основные глаголы HTTP присутствуют и поддерживаются реализацией Java: GET , PUT , POST и DELETE . Для полноты позвольте мне также включить некоторые методы уровня обслуживания, поскольку они вызывают некоторые исключения из нашего интереса.
|
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
36
37
38
39
40
41
42
43
44
45
46
47
48
|
package com.example.services;import java.util.ArrayList;import java.util.Collection;import java.util.Iterator;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.ConcurrentMap;import org.springframework.stereotype.Service;import com.example.exceptions.PersonAlreadyExistsException;import com.example.exceptions.PersonNotFoundException;import com.example.model.Person;@Servicepublic class PeopleService { private final ConcurrentMap< String, Person > persons = new ConcurrentHashMap< String, Person >(); // ... public Person getByEmail( final String email ) { final Person person = persons.get( email ); if( person == null ) { throw new PersonNotFoundException( email ); } return person; } public Person addPerson( final String email, final String firstName, final String lastName ) { final Person person = new Person( email ); person.setFirstName( firstName ); person.setLastName( lastName ); if( persons.putIfAbsent( email, person ) != null ) { throw new PersonAlreadyExistsException( email ); } return person; } public void removePerson( final String email ) { if( persons.remove( email ) == null ) { throw new PersonNotFoundException( email ); } }} |
Очень простая, но работающая реализация на основе ConcurrentMap . PersonNotFoundException возникает в случае, когда человек с запрошенной электронной почтой не существует. Соответственно, PersonAlreadyExistsException вызывается в случае, когда человек с запрошенной электронной почтой уже существует. Каждое из этих исключений имеет аналог среди кодов HTTP: 404 NOT FOUND и 409 CONFLICT . И вот как мы рассказываем JAX-RS об этом:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
package com.example.exceptions;import javax.ws.rs.WebApplicationException;import javax.ws.rs.core.Response;import javax.ws.rs.core.Response.Status;public class PersonAlreadyExistsException extends WebApplicationException { private static final long serialVersionUID = 6817489620338221395L; public PersonAlreadyExistsException( final String email ) { super( Response .status( Status.CONFLICT ) .entity( 'Person already exists: ' + email ) .build() ); }} |
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
package com.example.exceptions;import javax.ws.rs.WebApplicationException;import javax.ws.rs.core.Response;import javax.ws.rs.core.Response.Status;public class PersonNotFoundException extends WebApplicationException { private static final long serialVersionUID = -2894269137259898072L; public PersonNotFoundException( final String email ) { super( Response .status( Status.NOT_FOUND ) .entity( 'Person not found: ' + email ) .build() ); }} |
Полный проект размещен на GitHub . Давайте закончим со скучной частью и перейдем к сладкой: BDD . Не удивительно, что specs2 имеет хорошую поддержку стиля Given / When / Then , как описано в документации . Таким образом, используя specs2 , наш тестовый пример становится примерно таким:
|
01
02
03
04
05
06
07
08
09
10
11
|
'Create new person with email <a@b.com>' ^ br^ 'When I do POST to ${rest/api/people}' ^ post( Map( 'email' -> 'a@b.com', 'firstName' -> 'Tommy', 'lastName' -> 'Knocker' ) )^ 'Then I expect HTTP code ${201}' ^ expectResponseCode^ 'And HTTP header ${Location} to contain ${http://localhost:8080/rest/api/people/a@b.com}' ^ expectResponseHeader^ |
Неплохо, но что это за ^ , br , client , post , waitResponseCode и waitResponseHeader ? ^ , Br — это всего лишь несколько сахарных спецификаций2 для поддержки цепочки Given / When / Then . Другие, post , waitResponseCode и waitResponseHeader — это всего лишь пара функций / переменных, которые мы определяем для фактической работы. Например, клиент — это новый клиент JAX-RS 2.0 , который мы создаем следующим образом (используя синтаксис Scala ):
|
1
2
|
val client: Given[ Client ] = ( baseUrl: String ) => ClientBuilder.newClient( new ClientConfig().property( 'baseUrl', baseUrl ) ) |
BaseUrl взят из самого данного определения, он заключен в конструкцию $ {…} . Также мы можем видеть, что данное определение имеет строгий тип: Given [Client] . Позже мы увидим, что то же самое верно для When и Then , они оба имеют соответствующие сильные типы When [T, V] и Then [V] .
Поток выглядит так:
- начать с данного определения, которое возвращает клиента .
- переходите к определению When , которое принимает Client от Given и возвращает Response
- в конечном итоге с количеством определений « затем» , которые принимают ответ от « когда» и проверяют фактические ожидания
Вот как выглядит определение поста (само по себе, когда [Client, Response] ):
|
01
02
03
04
05
06
07
08
09
10
|
def post( values: Map[ String, Any ] ): When[ Client, Response ] = ( client: Client ) => ( url: String ) => client .target( s'${client.getConfiguration.getProperty( 'baseUrl' )}/$url' ) .request( MediaType.APPLICATION_JSON ) .post( Entity.form( values.foldLeft( new Form() )( ( form, param ) => form.param( param._1, param._2.toString ) ) ), classOf[ Response ] ) |
И, наконец, waitResponseCode и waitResponseHeader , которые очень похожи и имеют одинаковый тип Then [Response] :
|
1
2
3
4
5
|
val expectResponseCode: Then[ Response ] = ( response: Response ) => ( code: String ) => response.getStatus() must_== code.toInt val expectResponseHeader: Then[ Response ] = ( response: Response ) => ( header: String, value: String ) => response.getHeaderString( header ) should contain( value ) |
Еще один пример, проверка содержимого ответа по данным JSON:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
|
'Retrieve existing person with email <a@b.com>' ^ br^ 'When I do GET to ${rest/api/people/a@b.com}' ^ get^ 'Then I expect HTTP code ${200}' ^ expectResponseCode^ 'And content to contain ${JSON}' ^ expectResponseContent( ''' { 'email': 'a@b.com', 'firstName': 'Tommy', 'lastName': 'Knocker' } ''' )^ |
На этот раз мы выполняем запрос GET, используя следующую реализацию get :
|
1
2
3
4
5
|
val get: When[ Client, Response ] = ( client: Client ) => ( url: String ) => client .target( s'${client.getConfiguration.getProperty( 'baseUrl' )}/$url' ) .request( MediaType.APPLICATION_JSON ) .get( classOf[ Response ] ) |
Хотя specs2 имеет богатый набор сопоставителей для выполнения различных проверок полезных нагрузок JSON, я использую Spray-JSON, легкую, чистую и простую реализацию JSON в Scala (это правда!), И вот реализацияpectResponseContent:
|
1
2
3
4
5
6
|
def expectResponseContent( json: String ): Then[ Response ] = ( response: Response ) => ( format: String ) => { format match { case 'JSON' => response.readEntity( classOf[ String ] ).asJson must_== json.asJson case _ => response.readEntity( classOf[ String ] ) must_== json }} |
И последний пример (делает POST для существующей электронной почты):
|
1
2
3
4
5
6
7
8
9
|
'Create yet another person with same email <a@b.com>' ^ br^ 'When I do POST to ${rest/api/people}' ^ post( Map( 'email' -> 'a@b.com' ) )^ 'Then I expect HTTP code ${409}' ^ expectResponseCode^ 'And content to contain ${Person already exists: a@b.com}' ^ expectResponseContent^ |
Выглядит отлично! Хороший, выразительный BDD , использующий сильные типы и статическую компиляцию! Несомненно, интеграция JUnit доступна и прекрасно работает с Eclipse .
Не забывать о собственных отчетах specs2 (генерируемых maven-specs2-plugin ): mvn clean test
Пожалуйста, ищите полный проект на GitHub . Кроме того, обратите внимание, поскольку я использую последнюю веху JAX-RS 2.0 (окончательный вариант), API может немного измениться после выпуска.
Ссылка: Выразительное тестирование интеграции JAX-RS с Specs2 и клиентским API 2.0 от нашего партнера по JCG Андрея Редько в блоге Андрея Редько {devmind} .

