Enterprise Java

Expressive JAX-RS integration testing with Specs2 and client API 2.0

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:

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();
    }
}

Very simple JAX-RS service to manage people. All basic HTTP verbs are present and backed by Java implementation: GET, PUT, POST and DELETE. To be complete, let me also include some methods of the service layer as these ones raise some exceptions of our interest.

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 );
        }
    }
}

Very simple but working implementation based on ConcurrentMap. The PersonNotFoundException is being raised in a case when person with requested e-mail doesn’t exist. Respectively, the PersonAlreadyExistsException is being raised in a case when person with requested e-mail already exists. Each of those exceptions have a counterpart among HTTP codes: 404 NOT FOUND and 409 CONFLICT. And it’s the way we are telling JAX-RS about that:

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()
        );
    }
}
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()
        );
    }
}

The complete project is hosted on GitHub. Let’s finish with boring part and move on to the sweet one: BDD. Not a surprise that specs2 has a nice support for Given/When/Then style, as described in the documentation. So using specs2, our test case becomes something like this:

'Create new person with email <a@b.com>' ^ br^
    'Given REST client for application deployed at ${http://localhost:8080}' ^ client^
    '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^

Not bad, but what are those ^, br, client, post, expectResponseCode and expectResponseHeader? The ^, br is just some sugar specs2 brings to support Given/When/Then chain. Others, post, expectResponseCode and expectResponseHeader are just couple of functions/variables we define to do actual work. For example, client is a new JAX-RS 2.0 client, which we create like that (using Scala syntax):

val client: Given[ Client ] = ( baseUrl: String ) => 
    ClientBuilder.newClient( new ClientConfig().property( 'baseUrl', baseUrl ) )

The baseUrl is taken from Given definition itself, it’s enclosed into ${…} construct. Also, we can see that Given definition has a strong type: Given[ Client ]. Later we will see that same is true for When and Then, they both do have respective strong types When[ T, V ] and Then[ V ].

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

Here is how post definition looks like (which itself is When[ Client, Response ]):

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 ] 
        )

And finally expectResponseCode and expectResponseHeader, which are very similar and have the same type Then[ Response ]:

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 )

Yet another example, checking response content against JSON payload:

'Retrieve existing person with email <a@b.com>' ^ br^
    'Given REST client for application deployed at ${http://localhost:8080}' ^ client^
    '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' 
        }            
    '''
    )^

This time we are doing GET request using following get implementation:

val get: When[ Client, Response ] = ( client: Client ) => ( url: String ) =>  
    client
        .target( s'${client.getConfiguration.getProperty( 'baseUrl' )}/$url' )
        .request( MediaType.APPLICATION_JSON )
        .get( classOf[ Response ] )

Though specs2 has rich set of matchers to perform different checks against JSON payloads, I am using spray-json, a lightweight, clean and simple JSON implementation in Scala (it’s true!) and here is the expectResponseContent implementation:

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):

'Create yet another person with same email <a@b.com>' ^ br^
    'Given REST client for application deployed at ${http://localhost:8080}' ^ client^
    '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^

Looks great! Nice, expressive BDD, using strong types and static compilation! For sure, JUnit integrations is available and works great with Eclipse.

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.
 

Reference: Expressive JAX-RS integration testing with Specs2 and client API 2.0 from our JCG partner Andrey Redko at the Andriy Redko {devmind} blog.

Andrey Redko

Andriy is a well-grounded software developer with more then 12 years of practical experience using Java/EE, C#/.NET, C++, Groovy, Ruby, functional programming (Scala), databases (MySQL, PostgreSQL, Oracle) and NoSQL solutions (MongoDB, Redis).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button