No doubts, JAX-RS is an outstanding piece of technology. And upcoming specification JAX-RS 2.0 brings even more great features, especially concerning client API. Topic of today's post is integration testing of the JAX-RS services.
There are a bunch of excellent test frameworks like REST-assured to help with that, but the way I would like to present it is by using expressive BDD style. Here is an example of what I mean by that: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
Looks like typical Given/When/Then style of modern BDD frameworks. How close we can get to this on JVM, using statically compiled language? It turns out, very close, thanks to great specs2 test harness.
One thing to mention, specs2 is a Scala framework. Though we are going to write a bit of Scala, we will do it in a very intuitive way, familiar to experienced Java developer. The JAX-RS service under the test is the one we've developed in previous post. Here it is:
1 2 3 4 5 6 7 8 9 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(); } } |
1 2 3 4 5 6 7 8 9 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 ); } } } |
1 2 3 4 5 6 7 8 9 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() ); } } |
1 2 3 4 5 6 7 8 9 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() ); } } |
1 2 3 4 5 6 7 8 9 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^ |
1 2 | val client: Given[ Client ] = ( baseUrl: String ) => ClientBuilder.newClient( new ClientConfig().property( "baseUrl" , baseUrl ) ) |
The flow looks like this:
- start from Given definition, which returns Client.
- continue with When definition, which accepts Client from Given and returns Response
- end up with number of Then definitions, which accept Response from When and check actual expectations
1 2 3 4 5 6 7 8 9 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 ] ) |
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 ) |
1 2 3 4 5 6 7 8 9 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" } "" " )^ |
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 ] ) |
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 } } |
And the last example (doing POST for existing e-mail):
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^ |
Not to forget about own specs2 reports (generated by maven-specs2-plugin): mvn clean test
Please, look for complete project on GitHub. Also, please note, as I am using the latest JAX-RS 2.0 milestone (final draft), the API may change a bit when be released.
I am still learning along the way but I like it so far.