Introduction

The Ersatz Server is a HTTP client testing tool, which allows for request/response expectations to be configured in a flexible manner. The expectations will respond in a configured manner to requests and allow testing with different responses and/or error conditions without having to write a lot of boiler-plate code.

The "mock" server is not really mock at all, it is an embedded Undertow HTTP server which registers the configured expectations as routes and then responds according to the expected behavior. This approach may seem overly heavy; however, testing an HTTP client can involve a lot of internal state and interactions that the developer is generally unaware of (and should be) - trying to mock those interactions with a pure mocking framework will get out of hand very quickly.

Ersatz provides a balance of mock-like expectation behavior with a real HTTP interface and all of the underlying interactions in place. This allows for rich unit testing, which is what you were trying to do in the first place.

Ersatz is written in Groovy 2.4.x and requires a Java 8 VM due to its use of the modern functional libraries; however, the Ersatz library is written such that it may be used with Groovy or standard Java without pain or feature-loss. With that in mind, the expectation configuration allows two main forms, a Java-style chained builder, and a Groovy DSL, both of which may be used interchangeably or together, if you are using Groovy.

Ersatz is developed with testing in mind. It does not favor any specific testing framework, but it does work well with both the JUnit and Spock frameworks.

Getting Started

The ersatz library is available via Bintray (JCenter) and the Maven Central Repository; you can add it to your project using one of the following:

For Gradle:

testCompile 'com.stehno.ersatz:ersatz:1.2.0'

For Maven:

<dependency>
    <groupId>com.stehno.ersatz</groupId>
    <artifactId>ersatz</artifactId>
    <version>1.2.0</version>
    <scope>test</scope>
</dependency>

You could then use Ersatz in a Spock test as follows:

HelloSpec.groovy
class HelloSpec extends Specification {

    def 'say hello'(){
        setup:
        ErsatzServer ersatz = new ErsatzServer()

        server.expectations {
            get('/say/hello'){
                called equalTo(1)
                query 'name','Ersatz'
                responder {
                    content 'Hello Ersatz','text/plain'
                }
            }
        }

        ersatz.start()

        when:
        String result = "${ersatz.serverUrl}/say/hello?name=Ersatz".toURL().text

        then:
        result == 'Hello Ersatz'

        and:
        ersatz.verify()

        cleanup:
        ersatz.stop()
    }
}

The server is expecting a single call to GET /say/hello?name=Ersatz, and when it is received, the server will respond with the text/plain content Hello Ersatz. This code also verifies that the expected request was only called once (as requested) - if it was not called or called more than once, the verification and likewise the test, would fail.

A similar test could be written in JUnit with Java 8, as follows (using the provided ErsatzServerRule helper class:

HelloTest.java
public class HelloTest {

    @Rule
    public ErsatzServerRule ersatzServer = new ErsatzServerRule(ServerConfig::enableAutoStart);

    private OkHttpClient client;

    @Before
    public void before() {
        client = new OkHttpClient.Builder().build();
    }

    @Test
    public sayHello(){
        ersatzServer.expectations(expectations -> {
            expectations.get("/say/hello").called(1).query("name","Ersatz")
                .responder().content("Hello Ersatz", ContentType.TEXT_PLAIN)
        })

        String url = ersatzServer.getHttpUrl() + "/say/hello?name=Ersatz";
        okhttp3.Request request = new okhttp3.Request.Builder().url(url)).build();
        assertEquals("Hello Ersatz", client.newCall(request).execute().body().string());
    }
}

The two formats are interchangeable and equally supported.

Server Lifecycle

The lifecycle of an Ersatz server is broken down into four main states:

  1. Configure

  2. Test

  3. Verify

  4. Cleanup

they are detailed in the next sections.

Configure

The first step is "configuration", where the server is instantiated, request expectations are configured and the server is started. An Ersatz server is created by creating an instance of ErsatzServer with an optional ServerConfig passed in to provide initial configurations (such as global encoder and decoder configurations).

No HTTP server is started at this point, but the server is ready for configuration. Configuring the expectations on the server consists of calling one of the following methods:

ErsatzServer expectations(final Consumer<Expectations> expects)

ErsatzServer expectations(@DelegatesTo(Expectations) final Closure closure)

Expectations expects()

The first allows for configuration within a Consumer<Expectations> instance, which will have a prepared Expectations instance passed into it. This allows for a DSL-style configuration from Java.

The second method is the entry point for the Groovy DSL configuration. The provided Closure will delegate to an instance of Expectations for defining the configurations.

The third method is a simplified builder-style approach for single request method expectation-building.

Once the request expectations are configured, the server must be started by calling the ErsatzServer start() method. This will start the underlying embedded HTTP server and register the configured expectations. If the server is not started, you will receive connection errors during testing.

Testing

After configuration, the server is running and ready for test interactions. Any HTTP client can make HTTP requests against the server to retrieve configured responses. The ErsatzServer object provides helper methods to retrieve the server port and URL, with getHttpPort() and getHttpUrl() respectively (there are also versions for HTTPS). Note that the server will always be started on an ephemeral port so that a random one will be chosen to avoid collisions.

Verify

Once testing has been performed, it may be desirable to verify whether or not the expected number of request calls were matched. The Expectations interface provides a called method to add call count verification per configured request, something like:

post('/user').body(content, 'application/json').called(1)
    .responds().content(successContent, 'application/json')

This would match a POST request to "/user" with request body content matching the provided content and expect that matched call only once. When verify() is called it will return true if this request has only been matched once, otherwise it will return false. This allows testing to ensure that requests are not made more often than expected or at unexpected times.

Verification is optional and may simply be skipped if not needed.

Cleanup

After testing and verification, when all test interactions have completed, the server must be stopped in order to free up resources. This is done by calling the stop() method of the ErsatzServer class. This is an important step as odd test failures have been noticed during multi-test runs if the server is not properly stopped. In Spock you can create the ErsatzServer with the @AutoCleanup annotation to aid in proper management:

@AutoCleanup('stop') ErsatzServer server = new ErsatzServer()

likewise, in a JUnit test (Groovy or Java) you may use the ErsatzServerRule class, which is a JUnit Rule implementation delegating to an ErsatzServer; it automatically calls the stop() method after each test method, though the start() method must still be called manually.

@Rule ErsatzServerRule ersatzServer = new ErsatzServerRule()

@Test public void hello(){
    ersatzServer.expectations(expectations -> {
        expectations.get("/testing").responds().content("ok");
    }).start();

    okhttp3.Response response = new OkHttpClient().newCall(
        new Request.Builder().url(format("%s/testing", ersatzServer.getHttpUrl())).build()
    ).execute();

    assertEquals(200, response.code());
    assertEquals("ok", response.body().string());
}

The server may be restarted after it has been stopped; however, be aware that expectation configuration is additive and existing configuration will remain on server start even if new expectations are configured.

Expectations

Request expectations are the core of the Ersatz server functionality; conceptually, they are HTTP server request routes which are used to match an incoming HTTP request with a request handler or to respond with a status of 404, if no matching request was configured. The expectations are configured on an instance of the Expectations interface, which provides multiple configuration methods for each of the supported HTTP request methods (GET, HEAD, POST, PUT, DELETE, and PATCH), with the method name corresponding to the HTTP request method name. The four general types of methods are:

  • One taking a String path returning an instance of the Request interface

  • One taking a String path and a Consumer<Request> returning an instance of the Request interface

  • One taking a String path and a Groovy Closure returning an instance of the Request interface

  • All of the above with the String path replaced by a Hamcrest Matcher<String> for matching the path

The Consumer<Request> methods will provide a Consumer<Request> implementation to perform the configuration on a Request instance passed into the consumer function.

The Closure support is similar to that of the consumer; however, this is a Groovy DSL approach where the Closure operations are delegated onto the a Request instance in order to configure the request.

All of the expectation method types return an instance of the request being configured (Request or RequestWithContent).

There is also an any request method matcher configuration which will match a request regardless of the request method, if it matches the rest of the configured criteria.

The primary role of expectations is to provide a means of matching incoming requests in order to respond in a desired and repeatable manner. They are used to build up matching rules based on request properties to help filter and route the incoming request properly. Hamcrest Matcher support allows for flexible request matching based on various request properties.

The configuration interfaces support three main approaches to configuration, a chained builder approach, such as:

head('/foo')
    .query('a','42')
    .cookie('stamp','1234')
    .respond().header('ok','true')

where the code is a chain of builder-style method calls used to wire up the request expectation. The second method is available to users of the Groovy language, the Groovy DSL approach would code the same thing as:

head('/foo'){
    query 'a', '42'
    cookie 'stamp', '1234'
    responder {
        header 'ok', "true"
    }
}

which can be more expressive, especially when creating more complicated expectations. A third approach is a Java-based approach more similar to the Groovy DSL, using the Consumer<?> methods of the interface, this would yield:

head('/foo', req -> {
    req.query("a", "42")
    req.cookie("stamp", "1234")
    req.responder( res-> {
        res.header("ok", "true")
    })
})

Any of the three may be used in conjunction with each other to build up expectations in the desired manner.

Tip
The matching of expectations is perform in the order the expectations are configured, such that if an incoming request could be matched by more than one expectation, the first one configured will be applied.

Request expectations may be configured to respond differently based on how many times a request is matched, for example, if you wanted the first request of GET /something to respond with Hello and second (and all subsequent) request of the same URL to respond with Goodbye, you would configure multiple responses, in order:

get('/something'){
    responder {
        content 'Hello'
    }
    responder {
        content 'Goodbye'
    }
    called 2
}

Adding the called configuration adds the extra safety of ensuring that if the request is called more than our expected two times, the verification will fail (and with that, the test).

Hamcrest Matchers

Many of the expectation methods accept Hamcrest Matcher instances as an alternate argument. Hamcrest matchers allow for a more rich and expressive matching configuration. Consider the following configuration:

server.expectations {
    get( startsWith('/foo') ){
        called greaterThanOrEqualTo(2)
        query 'user-key', notNullValue()
        responder {
            content 'ok', TEXT_PLAIN
        }
    }
}

This configuration would match a GET request to a URL starting with /foo, with a non-null query string "user-key" value. This request matcher is expected to be called at least twice and it will respond with a text/plain response of ok.

The methods that accept matchers will have a non-matcher version which provides a sensible default matcher (e.g. get(Matcher) has get(String) which provides delegates to get( equalTo( string ) ) to wrap the provided path string in a matcher.

Url-Encoded Form Requests

Url-encoded form requests are supported by default when the request content-type is specified as application/x-www-form-urlencoded. The request body expectation configuration will expect a Map<String,String> equivalent to the name-value pairs specified in the request body content. An example would be:

server.expectations {
    post('/form') {
        body([alpha: 'some data', bravo: '42'], 'application/x-www-form-urlencoded')
        responder {
            content 'ok'
        }
    }
}

where the POST content data would look like:

alpha=some+data&bravo=42

Multipart Request Content

Ersatz server supports multipart file upload requests (multipart/form-data content-type) using the Apache File Upload library on the "server" side. The expectations for multipart requests are configured using the MultipartRequestContent class to build up an equivalent multipart matcher:

ersatz.expectataions {
    post('/upload') {
        decoders decoders
        decoder MULTIPART_MIXED, Decoders.multipart
        decoder IMAGE_PNG, Decoders.passthrough
        body multipart {
            part 'something', 'interesting'
            part 'infoFile', 'info.txt', TEXT_PLAIN, infoText
            part 'imageFile', 'image.png', IMAGE_PNG, imageBytes
        }, MULTIPART_MIXED
        responder {
            content 'ok'
        }
    }
}

which will need to exactly match the incoming request body in order to be considered a match. There is also a MultipartRequestMatcher used to provide a more flexible Hamcrest-based matching of the request body:

server.expectations {
    post('/upload') {
        decoders decoders
        decoder MULTIPART_MIXED, Decoders.multipart
        decoder IMAGE_PNG, Decoders.passthrough
        body multipartMatcher {
            part 'something', notNullValue()
            part 'infoFile', endsWith('.txt'), TEXT_PLAIN, notNullValue()
            part 'imageFile', endsWith('.png'), IMAGE_PNG, notNullValue()
        }, MULTIPART_MIXED
        responder {
            content 'ok'
        }
    }
}

This will configure a match of the request body content based on the individual matchers, rather than overall equivalence.

A key point in multipart request support are the "decoders", which are used to decode the incoming request content into an expected object type. Decoders are simply BiFunction<byte[], DecodingContext, Object> implementations - taking the incoming byte array, and a DecodingContext and returning the decoded Object instance. Decoders may be registered in a shared instance of RequestDecoders, configured globally across the server instance or configured on a per-request basis.

Tip
No decoders are provided by default, any used in the request content must be provided in configuration.

Some common reusable decoders are provided in the Decoders utility class.

Multipart Response Content

Multipart response content is supported, though most browsers do not fully support it - the expected use case would be a RESTful or other HTTP-based API. The response content will have the standard multipart/form-data content type and format. The response content parts are provided using an instance of the MultipartResponseContent class along with the Encoders.multipart multipart response content encoder (configured on the server or response).

The content parts are provided as "field" parts with only a field name and value, or as "file" parts with a field name, content-type, file name and content object. These configurations are made on the MultipartResponseContent object via DSL or functional interface.

The part content objects are serialized for data transfer as String content using configured encoders, which are simply instances of Function<Object,String> used to do the object to string conversion. These are configured either on a per-response basis or by sharing a ResponseEncoders instance between multipart configurations - the shared encoders will be used if not explicitly overridden by the multipart response configuration. No part encoders are provided by default.

An example multipart response with a field and an image file would be something like:

ersatz.expectations {
    get('/data') {
        responder {
            encoder ContentType.MULTIPART_MIXED, MultipartResponseContent, Encoders.multipart
            content(multipart {
                // configure the part encoders
                encoder TEXT_PLAIN, CharSequence, { o -> o as String }
                encoder IMAGE_JPG, File, { o -> ((File)o).bytes.encodeBase64() }

                // a field part
                field 'comments', 'This is a cool image.'

                // a file part
                part 'image', 'test-image.jpg', IMAGE_JPG, new File('/test-image.jpg'), 'base64'
            })
        }
    }
}

The resulting response body would look like the following (as a String):

--WyAJDTEVlYgGjdI13o
Content-Disposition: form-data; name="comments"
Content-Type: text/plain

This is a cool image.
--WyAJDTEVlYgGjdI13o
Content-Disposition: form-data; name="image"; filename="test-image.jpg"
Content-Transfer-Encoding: base64
Content-Type: image/jpeg

... more content follows ...

which could be decoded in the same manner a multipart request content (an example using the Apache File Upload multipart parser can be found in the unit tests).

Matching Cookies

There are four methods for matching cookies associated with a request (found in the com.stehno.ersatz.Request interface):

By Name and Matcher

The cookie(String name, Matcher<Cookie> matcher) method configures the specified matcher for the cookie with the given name.

server.expectations {
    get('/somewhere'){
        cookie 'user-key', CookieMatcher.cookieMatcher {
            value startsWith('key-')
            domain 'mydomain.com'
        }
        responds().code(200)
    }
}

The Hamcrest matcher used may be a custom Matcher implementation, or the provided com.stehno.ersatz.CookieMatcher.

By Name and Value

The cookie(String name, String value) method is a shortcut for configuring simple name/value matching where the cookie value must be equal to the specified value. An example:

server.expectations {
    get('/somewhere').cookie('user-key', 'key-23435HJKSDGF86').responds().code(200)
}

This is equivalent to calling the matcher-based version of the method:

server.expectations {
    get('/somewhere'){
        cookie 'user-key', CookieMatcher.cookieMatcher {
            value equalTo('key-23435HJKSDGF86')
        }
        responds().code(200)
    }
}

Multiple Cookies

The cookies(Map<String,Object>) method provides a means of configuring multiple cookie matchers (as value `String`s or cookie `Matcher`s). In the following example matchers are configured to match the 'user-key' cookie for values "starting with" the specified value, the request should also have an 'app-id' cookie with a value of "user-manager", and finally the request should not have the 'timeout' cookie specified.

server.expectations {
    get('/something'){
        cookies([
            'user-key': cookieMatcher {
                value startsWith('key-')
            },
            'appid': 'user-manager',
            'timeout': nullValue()
        ])
        responds().code(200)
    }
}

Overall Matcher

The cookies(Matcher<Map<String,Cookie>) method is used to specify a Matcher for the map of cookie names to com.stehno.ersatz.Cookie objects. The matcher may be any custom matcher, or the com.stehno.ersatz.NoCookiesMatcher may be used to match for the case where no cookies should be defined in the request:

server.expectations {
    get('/something'){
        cookies NoCookiesMatcher.noCookies()
        responds().code(200)
    }
}

Encoder/Decoder Chains

The request/response content body decoders/encoders are configured in a layered manner so that they may be configured and shared across multiple instances without copying the configuration.

  • Encoders/Decoders configured in the ErsatzServer constructor are considered "global" and will be used if no overriding handlers are configured.

  • Encoders/Decoders configured in the request/response itself are considered "local" and will override any other configured handlers

  • Other configurations are applied in a layered order based on where they are applied in the configuration DSL - the handlers are maintained as separate isolated instances and the actual handler is resolved at runtime.

Request / Response Compression

Ersatz supports GZip and Deflate compression seamlessly as long as the Accept-Encoding header is specified as gzip or deflate. If the response is compressed, a Content-Encoding header will be added to the response with the appropriate compression type as the value.

HTTPS Request Support

The ErsatzServer supports HTTPS requests when the https() configuration is set (either as https() or as https true). This will setup both an HTTP and HTTPS listener both of which will have access to all configured expectations. In order to limit a specific request expectation to HTTP or HTTPS, apply the procotol(String) matcher method with the desired protocol, for example:

server.expectations {
    get('/something').protocol('https').responding('thing')
}

which will match an HTTPS request to GET /something and send a response of thing.

Note
the HTTPS support is rudimentary and meant to test HTTPS endpoints, not any explicit features of HTTPS itself. Also your client will need to be able to ignore any self-signed certification issues in one way or another.

Creating a Custom Keystore

A default keystore is provided with the Ersatz library, and it should suffice for most cases; however, you may wish to provide your own custom keystore for whatever reason. A supported keystore file may be created using the following command:

./keytool -genkey -alias <NAME> -keyalg RSA -keystore <FILE_LOCATION>

where <NAME> is the key name and <FILE_LOCATION> is the location where the keystore file is to be created. You will be asked a few questions about the key being created. The default keystore name is ersatz and it has the following properties:

CN=Ersatz, OU=Ersatz, O=Ersatz, L=Nowhere, ST=Nowhere, C=US

Obviously, it is only for testing purposes.

The keystore should then be provided during server configuration as:

ErsatzServer server = new ErsatzServer({
    https()
    keystore KEYSTORE_URL, KEYSTORE_PASS
})

where KEYSTORE_URL is the URL to your custom keystore file, and KEYSTORE_PASS is the password (maybe omitted if you used ersatz as the password).

Authentication

Ersatz support two forms of built-in server authentication, BASIC and DIGEST. Both authentication methods are exclusive and global meaning that they cannot be configured together on the same server and that when configured, they apply to all end points configured on the server.

If more fine-grained control of which URLs are authenticated is desired, you will need to configured multiple Ersatz Servers for the different configuration sets.

BASIC Authentication

HTTP BASIC Authentication is supported by applying the basic authentication configuration to the server.

def ersatz = new ErsatzServer({
    authentication {
        basic 'admin', 'my-password'
    }
})

This configuration causes the configured request expectations to require BASIC authentication (username and password) as part of their matching.

DIGEST Authentication

HTTP DIGEST Authentication is supported by applying the digest authentication to the server.

def ersatz = new ErsatzServer({
    authentication {
        digest 'guest', 'other-password'
    }
})

This configuration causes the configured request expectations to require DIGEST authentication (username and password) as part of their matching.