Статьи

Выразительное тестирование интеграции JAX-RS с Specs2 и клиентским API 2.0

Без сомнения, 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;
 
@Service
public 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 <[email protected]>' ^ br^
    'Given REST client for application deployed at ${http://localhost:8080}' ^ client^
    'When I do POST to ${rest/api/people}' ^ post(
        Map(
            'email' -> '[email protected]',
            'firstName' -> 'Tommy',
            'lastName' -> 'Knocker'
        )
    )^
    'Then I expect HTTP code ${201}'  ^ expectResponseCode^
    'And HTTP header ${Location} to contain ${http://localhost:8080/rest/api/people/[email protected]}' ^ 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 <[email protected]>' ^ br^
    'Given REST client for application deployed at ${http://localhost:8080}' ^ client^
    'When I do GET to ${rest/api/people/[email protected]}' ^ get^
    'Then I expect HTTP code ${200}' ^ expectResponseCode^
    'And content to contain ${JSON}' ^ expectResponseContent(
    '''
        {
            'email': '[email protected]',
            '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 <[email protected]>' ^ br^
    'Given REST client for application deployed at ${http://localhost:8080}' ^ client^
    'When I do POST to ${rest/api/people}' ^ post(
        Map(
            'email' -> '[email protected]'
        )
    )^
    'Then I expect HTTP code ${409}' ^ expectResponseCode^
    'And content to contain ${Person already exists: [email protected]}' ^ 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} .