Introduction
The Ersatz Server is an HTTP client testing tool, which allows for request and response expectations to be configured in a flexible manner. The expectations will respond to each request in a configured manner allowing tests with different responses and/or error conditions without having to write a lot of boilerplate code.
The "mock" server is not really a mock at all, it is an embedded Undertow HTTP server which registers the configured expectations as routes and then responds according to the configured expectation 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, and Undertow starts up 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 Java 15 due to its use of the modern functional libraries; however, there is an extension library (ersatz-groovy) which provides a Groovy DSL and extensions to the base library.
Lastly, 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.
Note
|
The code examples throughout this document are written with either Java or Groovy code. Please note that all features are available to both languages and will be configured in a similar manner in each. |
What’s New
In 4.0
-
Updated Groovy support to 4.x and switched to the new coordinates - it should still work with older Groovy versions, please file an issue if you are unable to use 3.x versions.
-
Added an updated version of the old websockets support - still limited at this time (only non-secure connections).
-
Re-removed the old standalone proxy server - the forwarding functionality replaces this.
-
Verification timeouts using time+unit are deprecated (or removed) in favor of versions using a new
WaitFor
utility object - this provides most of the standard configurations, as well as aFOREVER
waiting time. -
General testing and code cleanup (mostly checkstyle and codenarc formatting)
In 3.2
-
Added generic
request
expectations to theExpectations
interface - this allows programmatic specification of the request method. -
Added static constants for commonly used HTTP headers and response status codes (see
HttpHeaders
andStatusCode
). -
Added JSON encoder and decoder implementations to the Groovy extension (based on Groovy internal support). These used to be in the core library, but were removed when it was converted to Java.
-
Added request forwarding, such that a matched request can be forwarded to another server, and have that response returned as the response to the original request.
-
Resurrected the
ErsatzProxyServer
stand-alone proxy server component (temporarily) - the represented functionality will be replaced by the aforementioned request forwarding. -
Added more functionality to the
ErsatzServerExtension
JUnit 5 helper class, and added aSharedErsatzServerExtension
which uses a shared server instance for all test methods in a test class - rather than creating and destroying it for each test. -
Minor tweaks, fixes, and updates.
In 3.1
-
Reduced the number of IO and Worker threads configured in Undertow by default.
-
Provided a means of configuring the IO/Worker threads in the underlying server (see
ServerConfig::serverThreads(int,int)
). -
Big refactoring of how the internal matchers were used and configured as well as provided a more robust matcher framework for the API itself.
-
Added support for
Predicate
-based matchers. -
Added global Request Requirements - now you can configure request attributes at the global level
In 3.0
-
Not directly backward compatible with the 2.x codebase (see migration notes below).
-
Requires Java 17+
-
Removed support for legacy JUnit — now only 5+ is directly supported.
-
Added new option,
ServerConfig::logResponseContent()
, to enable response content rendering (disabled by default). -
Removed Web Sockets support –- this may be re-implemented later with a more stable API if there is any interest.
-
Removed the
ErsatzProxy
component – this may be re-implemented later if there is any interest. -
Removed built-in support for JSON encoding/decoding to remove the external dependency. See the Encoding and Decoding sections for example source for implementing your own.
-
Extracted the Groovy API into a separate library (
ersatz-groovy
) so that the main library could be implemented in Java without Groovy dependencies. -
Added more predefined ContentType constants.
-
Added more encoders and decoders for common scenarios.
-
Updated the dependencies and fixed some exposed dependency isolation issues.
-
No longer published in Bintray (since it’s dead) - now only Maven Central (thanks JFrog)
-
Removed build-in authentication support — there is helper to implement BASIC, if needed.
-
There are more unit tests and they are organized in a more sane manner
-
Repackaged the project from
com.stehno.
→io.github.cjstehno.
-
Replaced
protocol(String)
request matcher withsecure(boolean)
Migrating to 3.0
-
You will need to update your dependencies to reflect the new group name, as well as the version.
-
You will need to change the package for Ersatz classes to the new
io.github.cjstehno.
root package (replacingcom.stehno.
withio.github.cjstehno.
should do the trick). -
If you use the proxy or web sockets testing support, there is no upgrade path. Please create an Issue or start a Discussion to show that you are interested in one or both of these features.
-
If you use Groovy for development, you will need to change your dependency references from using the
ersatz
library to use the newersatz-groovy
library. The same change applies if you are using the safe version of the library. You will also want to change yourErsatzServer
uses to theGroovyErsatzServer
to use the full Groovy DSL. -
If you are using the legacy JUnit support helper, you will either need to implement the support class yourself (optionally using the source from the 2.x codebase), or you can submit an Issue or start a Discussion to show that you are interested in one or both of these features.
-
If you are using the built-in JSON encoder or decoder, you will need to replace them with your own implementation (documentation and sample code are provided in the Decoders and Encoders sections).
-
If you are using the
protocol(String)
request matcher (with 'HTTP' or 'HTTPS') you can simply change it to the newsecure(boolean)
matcher, where 'HTTPS' istrue
and 'HTTP' isfalse
.
In 2.0
-
Not directly backward compatible with 1.x codebase (see migration notes below).
-
Requires Java 11+.
-
Refactored code and packaging as well as code-conversion from Groovy to Java (no loss of support for using with Groovy).
-
Removed deprecated methods.
-
Refactored the HTTP method names to be uppercase.
-
Added optional timeout for standard request verify calls.
-
Converted the underlying response content from String to byte[] (also changed response encoder API)
-
Refactored the underlying server into a more abstract integration so that it may be swapped out in the future.
-
Pulled external Closure helper API code into codebase (to avoid breaking maven-central support)
-
Refactored the JUnit support (4 and 5)
Migrating to 2.0
-
Change all HTTP method names to uppercase (e.g. if you have a head('/foo') call, change it to HEAD('/foo')).
-
Replace any deprecated method usages with appropriate replacements.
-
If you use the ErsatzProxy or JUnit helper classes, you will need to change the package information.
-
Most of the DSL classes were repackaged and will require updates to the package names imported.
In 1.9
-
Corrections to the closure variable scoping.
-
Support for configuring the server port - though, in general, this is not recommended.
-
Some added usage documentation
In 1.8
-
Variable scope changes – the configuration Groovy DSL closures had incorrect (or inadequate) resolution strategies specified which caused variables to be resolved incorrectly in some situations. All of the closures now use DELEGATE_FIRST; however, beware this may cause some issues with existing code.
-
Deprecation of the Response::content(…) methods in favor of the new body(…) methods.
-
ANSI color codes were added to the match failure reports to make them a bit more readable.
-
A couple of helper methods were added to ErsatzServer to facilitate simple URL string building – see httpUrl(String) and httpsUrl(String).
-
A JUnit 5 Extension was added to make server management simple with JUnit 5, similar to what already existed for JUnit 4.
-
Support for "chunked" responses with fixed or random delays between chunks has been added.
Getting Started
The ersatz libraries are available via the Maven Central Repository; you can add them to your project using one of the following methods:
Gradle
For Gradle, add the following to your build.gradle
file dependencies
block:
testImplementation 'io.github.cjstehno.ersatz:ersatz:4.0.1'
// or, for the Groovy extensions
testImplementation 'io.github.cjstehno.ersatz:ersatz-groovy:4.0.1'
Maven
For Maven, add the code below to your pom.xml
file <dependencies>
section:
<dependency>
<groupId>io.github.cjstehno.ersatz</groupId>
<artifactId>ersatz</artifactId>
<version>4.0.1</version>
<scope>test</scope>
</dependency>
<!-- or, for the Groovy extensions -->
<dependency>
<groupId>io.github.cjstehno.ersatz</groupId>
<artifactId>ersatz-groovy</artifactId>
<version>4.0.1</version>
<scope>test</scope>
</dependency>
Note
|
If you are using Groovy, you need only add the ersatz-groovy dependency, as it will pull in the core ersatz library as a dependency of itself.
|
Writing a Test
Once you have configured the library dependency, you can use it in a JUnit 5 test as follows:
import io.github.cjstehno.ersatz.ErsatzServer;
import io.github.cjstehno.ersatz.junit.ErsatzServerExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import java.net.URI;
import java.net.http.HttpRequest;
import static io.github.cjstehno.ersatz.cfg.ContentType.TEXT_PLAIN;
import static java.net.http.HttpClient.newHttpClient;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(ErsatzServerExtension.class)
class HelloTest {
@Test void sayHello(final ErsatzServer server) throws Exception {
server.expectations(expect -> {
expect.GET("/say/hello", req -> {
req.called(1);
req.query("name", "Ersatz");
req.responder(res -> {
res.body("Hello, Ersatz", TEXT_PLAIN);
});
});
});
final var request = HttpRequest
.newBuilder(new URI(server.httpUrl("/say/hello?name=Ersatz")))
.GET()
.build();
final var response = newHttpClient().send(request, ofString());
assertEquals(200, response.statusCode());
assertEquals("Hello, Ersatz", response.body());
assertTrue(server.verify());
}
}
The server is configured to expect a single GET /say/hello
request with name=Ersatz
on the query string. When it receives that request, the server will respond with status code 200 (by default), and a response with content-type "text/plain"
and "Hello, Ersatz"
as the body content. If the server does not receive the expected request, the verify()
call will fail, likewise, the expected response content would not be returned.
The test above could be written similarly in Groovy, using the Groovy extensions (i.e. ersatz-groovy
library):
import io.github.cjstehno.ersatz.GroovyErsatzServer
import io.github.cjstehno.ersatz.junit.ErsatzServerExtension
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import java.net.http.HttpRequest
import static io.github.cjstehno.ersatz.cfg.ContentType.TEXT_PLAIN
import static java.net.http.HttpClient.newHttpClient
import static java.net.http.HttpResponse.BodyHandlers.ofString
import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertTrue
@ExtendWith(ErsatzServerExtension)
class HelloGroovyTest {
@Test void 'say hello'(final GroovyErsatzServer server) {
server.expectations {
GET('/say/hello') {
called 1
query 'name', 'Ersatz'
responder {
body 'Hello, Ersatz', TEXT_PLAIN
}
}
}
final var request = HttpRequest
.newBuilder(new URI(server.httpUrl('/say/hello?name=Ersatz')))
.GET()
.build()
final var response = newHttpClient().send(request, ofString())
assertEquals 200, response.statusCode()
assertEquals 'Hello, Ersatz', response.body()
assertTrue server.verify()
}
}
Note that the configuration is almost identical between the two, though with Groovy it’s just a bit cleaner. Also note that for the Groovy version, the GroovyErsatzServer
is used instead of the ErsatzServer
— this provides additional Groovy DSL support.
JUnit Extension
The framework provides two JUnit 5 Extensions:
-
ErsatzServerExtension
. A configurable server management extension that creates and destroys the server after each test method. -
SharedErsatzServerExtension
. A server management extension that creates the server at the start of the test class, and destroys it when all tests are done.
ErsatzServerExtension
Note
|
This is the extension that has been around for a while - the new extension was created with a different name so as not to break existing tests. |
The main functionality of the extension is to provide two test lifecycle hooks:
-
beforeEach
: Before each test method is executed, the server will be created, configured, and started. -
afterEach
: After each test method is done executing, the server will be stopped, and the expectations cleared.
Configuration of the test server can take one of the following forms (in order of precedence):
-
Annotated Method. If the test method is annotated with the
@ApplyServerConfig(method)
annotation, the specified method will be called to populate aServerConfig
instance, which will be used to configure an instance ofErsatzServer
, which will be exposed as a test method parameter. -
Annotated Class. If the test class is annotated with the
@ApplyServerConfig(method)
annotation, the specified method will be called to populate aServerConfig
instance, which will be used to configure an instance ofErsatzServer
, which will be exposed as a test method parameter. -
Typed field without value. If a field of type
ErsatzServer
is added to the test, with no explicit value, the server will be created and used to populate that field. The created server will also be exposed as a test method parameter. -
Typed field with value. If a field of type
ErsatzServer
is added to the test with a value, that value will be used as the server, and also exposed as a test method parameter. -
No configuration. If no explicit configuration is added to the test, it will create an instance of
ErsatzServer
and expose it as an available test method parameter.
The @ApplyServerConfig
annotation may be added to the test class, for global default configuration, or to a test method, to define the configuration to be used by a single test method. The value of the annotation should be the name of a method of the test class which accepts a single argument of type ServerConfig
. The method should use the provided ServerConfig
instance and configure it.
Lastly, if a test method adds a parameter of type ErsatzServer
, it will be populated with the currently configured server instance for the test - this removes the need to store the server instance in a field of the test class.
An example with some of the available configuration could look like the following:
@ExtendWith(ErsatzServerExtension.class)
@ApplyServerConfig("defaultConfig")
class SomeTesting {
@Test void defaultTest(final ErsatzServer server){
// do some testing with the server - defaultConfig
}
@Test @ApplyServerConfig("anotherConfig")
void anotherTest(final ErsatzServer server){
// do some more testing with the server - anotherConfig
}
private void defaultConfig(final ServerConfig conf){
// apply some config
}
private void anotherConfig(final ServerConfig conf){
// apply some other config
}
}
The legacy style of test configuration will continue to work, but now you have some additional options to help clean things up a bit.
SharedErsatzServerExtension
This JUnit extension provides some of the features of the other extension, though its main purpose is to provide a means of simple and fast server management.
Note
|
While theoretically, creating and starting the server once per test class should be faster than doing it for each test method, your results may vary… and it will be highly dependant on how many tests you have in the class. As a general rule, I would suggest starting with this extension and then switching to the other one if/when you need more flexible configuration. |
This extension differ from the other in that it creates the server instance in beforeAll
and destroys it in afterAll
, so that there is one server running and available for all of the tests in the class. This cuts down on the startup and shutdown time, at the cost of being able to configure the server for each test method.
The server expectations are cleared after each test method completes (e.g. afterEach
).
Configuration of the server instance may be done using a class-level @ApplyServerConfig
annotation (not method level), or, if none is provided, the default configuration will be used to create the server. Also, note that for this extension, the configuration method must be static
.
Test methods should add an ErsatzServer
or GroovyErsatzServer
typed parameter to the test methods that require access to the server instance.
A simple example (similar to the one above), would be:
@ExtendWith(SharedErsatzServerExtension.class)
@ApplyServerConfig
class SomeTesting {
@Test void defaultTest(final ErsatzServer server){
// do some testing with the server - defaultConfig
}
private static void serverConfig(final ServerConfig conf){
// apply some config
}
}
Server Lifecycle
The core component of the Ersatz Server framework is the ErsatzServer
class. It is used to manage the server lifecycle as well as providing the configuration interface.
The lifecycle of the server can be broken down into four states: Configuration, Matching, Verification, and Cleanup. Each is detailed in the following sections.
Configuration
The first lifecycle state is "configuration", where the server is instantiated, request expectations are configured and the server is started.
An Ersatz server is created as an instance of either ErsatzServer
or GroovyErsatzServer
with optional configuration performed by providing a Consumer<ServerConfig>
or a Closure
respectively. Both will have an instance of ServerConfig
passed into them for the configuration to be applied.
Global decoders and encoders may also be configured with the server, as such they will be used as defaults across all configured expectations.
At this point, there is no HTTP server running, and it is ready for further configuration, as well specifying the request expectations (using the expectations(…)
and expects()
methods).
Once the request expectations are configured, if auto-start is enabled (the default), the server will automatically start. If auto-start is disabled (using autoStart(false)
), the server will need to be started using the start()
method. If the server is not started, you will receive connection errors during testing.
The server is now ready for "matching".
Matching
The second state of the server, is "matching", where request/response interactions are made against the server.
Any HTTP client can be used to make requests against an Ersatz server. The ErsatzServer
instance has some helpful methods for use by the client, in order to get the URLs, ports as exposed by the server.
Verification
Once the testing has been performed, it may be desirable to verify whether the expected requests were matched the expected number of times (using the Request::called(…)
methods) rather than just that they were called at all.
To execute verification, one the ErsatzServer::verify(…)
must be called, which will return a boolean value of true if the verification passed.
Verification is optional and may simply be skipped if you have no need for counting the executed requests.
Cleanup
After matching and verification, when all test interactions have completed, the server must be stopped in order to free up resources and close connections. This is done by calling the ErsatzServer::stop()
method or its alias close()
. This is an important step, as odd test failures have been noticed during multi-test runs if the server is not properly stopped.
If you use JUnit 5, you can use the ErsatzServerExtension
to perform server instantiation and cleanup for you.
For Spock, you can use the @AutoCleanup
annotation on the ErsatzServer
(or GroovyErsatzServer
) to perform the cleanup automatically.
Note
|
A stopped server may be restarted, though if you want to clean out expectations, you may want to call the ErsatzServer::clearExpectations() method before starting it again.
|
Server Configuration
The ServerConfig
interface provides the configuration methods for the server and requests, at the global level.
Ports
It is recommended to let the server find the best available port for your run — it starts on an ephemeral port by default. There are some cases when you need to explicitly specify the HTTP or HTTPS server port and you can do so in the following manner:
final var server = new ErsatzServer(cfg -> {
cfg.httpPort(1111);
cfg.httpsPort(2222);
));
Warning
|
The danger of doing this is that with a fixed port you run the risk of having port collisions with other services or even other running instances of your tests. This should only be used in extremely rare cases. |
Auto-Start
The auto-start flag is enabled by default to allow the server to start automatically once the expectations have been applied (e.g. after the expectations(…)
method has been called. This removes the need to explicitly call the start()
method in your tests.
If you want to disable the auto-start feature, you can do the following:
final var server = new ErsatzServer(cfg -> {
cfg.autoStart(false);
));
HTTPS
The server supports HTTPS requests when the https(…)
feature flag is enabled. When enabled, the server will set up both an HTTP and HTTPS listener which will have access to all configured expectations.
final var server = new ErsatzServer(cfg -> {
cfg.https();
));
In order to limit a specific request expectation to HTTP or HTTPS, apply the secure(boolean)
request matcher with a value of true
for HTTPS and false
for HTTP, similar to the following:
server.expectations(expect -> {
expect.GET("/something").secure(true).responding("stuff");
});
The code above will match an HTTPS request to GET /something
and send a response with "stuff" as its body; however, it will not match an HTTP request to the same method and path.
Warning
|
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 certificate issues in one way or another. |
Keystore
A default keystore is provided with the Ersatz library, and it should suffice for most test cases; however, you may need/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 then needs to be provided during the server configuration, as follows:
final var server = new ErsatzServer(cfg -> {
cfg.https();
cfg.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).
Request Timeout
The server request timeout configuration may be specified using the timeout(…)
configuration methods.
final var server = new ErsatzServer(cfg -> {
cfg.timeout(15, TimeUnit.SECONDS);
));
This will allow some wiggle room in tests with high volumes of data or having complex matching logic to be resolved.
Note
|
This timeout is a bit of a shotgun approach, as it sets a handful of timeout options on the server to the specified value. See the API docs for more details, if required. |
Report-to-Console
If the report-to-console flag is enabled (disabled by default), additional details will be written to the console when request matching fails (in addition to writing it in the logs, as it always does).
final var server = new ErsatzServer(cfg -> {
cfg.reportToConsole();
));
The rendered report would be written to the console similar to following:
# Expectations Expectation 0 (3 matchers): + HTTP method matches <GET> + Path matches "/say/hello" X Query string name matches a collection containing "Ersatz" (3 matchers: 2 matched, 1 failed)
Logging Response Content
By default, the content of a response is only logged as its length (in bytes). If the log-response-content feature flag is enabled, the entire content of the response will be written to the logs. This is helpful when debugging issues with tests.
final var server = new ErsatzServer(cfg -> {
cfg.logResponseContent();
));
Server Threads
By default (as of 3.1), the underlying server has 2 IO threads and 16 Worker threads configured (based on the recommended configuration for the underlying Undertow server). If you need to configure these values, you can use one of the serverThreads
methods:
final var server = new ErsatzServer(cfg -> {
cfg.serverThreads(3);
});
Note
|
With the standard use case being a server setup to handle only a minimal number of requests, and most likely not asynchronous, the underlying Undertow server does not need to use as many threads as a production instance would require. |
Content Transformation
The transformation of request/response body content is performed using:
-
Request Decoders to convert incoming request body content into a desired type for comparison.
-
Response Encoders to convert outgoing response objects into HTTP response byte[] data.
These decoders and encoders are configured in a layered manner so that they may be configured and shared across multiple request/response interactions while still allowing them to be overridden as needed.
-
Decoders/Encoders configured on the
ServerConfig
instance are considered "global" and will be used if no overriding transformers are configured elsewhere. -
Decoders/Encoders configured in the request/response expectations are considered "local" and will override any other matching transformers for the same content.
Refer to the Request Decoders and Response Encoders sections for more details on the configuration and usage of decoders and encoders.
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
, PATCH
, OPTIONS
, and TRACE
), with the method name corresponding to the HTTP request method name.
Refer to the Request Expectations section for a more detailed discussion of the configuration and usage of the expectations framework.
Requirements
Request requirements allow for expectation-like request verification at the global level so that common expectation matching code does not need to be duplicated in multiple expectations. Similar to expectations, the requirements determine whether there is a matching requirement configured (by request method and path), if so, the configured requirement must be matched or the request will be rejected.
Requirements serve only to allow quick-rejection of bad requests, and provide no responders.
Refer to the Request Requirements section for a more detailed discussion of the configuration and useage of the requirements framework.
Request Decoders
The decoders are used to convert request content bytes into a specified object type for matching in the expectations. They are implemented as a BiFunction<byte[], DecodingContext, Object>
, where byte[]
is the request content and the Object
is the result of transforming the content. The DecodingContext
is used to provide additional information about the request being decoded (e.g. content-length, content-type, character-encoding), along with a reference to the decoder chain.
Decoders are defined at the various levels with the same method signature:
ServerConfig decoder(String contentType, BiFunction<byte[], DecodingContext, Object> decoder)
See the API docs for more details.
Tip
|
As a design decision, no decoders are defined and registered on the server by default. If you need one, you need to register it. |
Provided Decoders
The API provides a handful of commonly used decoders. These are defined in the io.github.cjstehno.ersatz.encdec.Decoders
class, and described below:
-
Pass-through. A decoder that simply passes the content byte array through as an array of bytes.
-
Strings. A few decoders that convert the request content bytes into a
String
object, with optionalCharset
specification. -
URL-Encoded. Decoder that converts request content bytes in a url-encoded format into a map of name/value pairs.
-
Multipart. Decoder that converts request content bytes into a
MultipartRequestContent
object populated with the multipart request content.
JSON Decoders
As of v3.0.0 the built-in JSON decoder was removed to avoid dependency lock-in. Below are some simple/common implementations that could be used.
Groovy
A decoder (deserializer) implemented with the Groovy JsonSlurper
would look like the following (in Groovy):
decoder(ContentType.APPLICATION_JSON){ content, context ->
new JsonSlurper().parse(content ?: '{}'.bytes)
}
Tip
|
If you are using the Groovy extension library, this decoder is available as the JsonDecoder class.
|
Jackson
A decoder (deserializer) implemented with the Jackson ObjectMapper
would be implemented as (in Java):
decoder(ContentType.APPLICATION_JSON, (content, context) -> {
return new ObjectMapper().readValue(content, Map.class);
});
Response Encoders
The Encoders are used to convert response configuration data types into the outbound request content string. They are implemented as a`Function<Object,byte[]>` with the input Object
being the configuration object being converted, and the byte[]
is the return type.
The various configuration levels have the same method signature:
ServerConfig encoder(String contentType, Class objectType, Function<Object, byte[]> encoder)
The contentType
is the response content type to be encoded and the objectType
is the type of configuration object to be encoded - this allows for the same content-type to have different encoders for different configuration object types.
A simple example of an encoder would be the default "text" encoder (provided in the io.github.cjstehno.ersatz.encdec.Encoders
class):
static Function<Object, byte[]> text(final Charset charset) {
return obj -> (obj != null ? obj.toString() : "").getBytes(charset);
}
Simply stated, the response content object has toString()
called on it and the result is then converted to bytes of the specified character set.
To use this encoder, you can configure it in a similar manner in both the server configuration block or in the response configuration block itself, which is shown below:
server.expectations(expect -> {
expect.POST('/submit', req -> {
req.responder(res -> {
res.encoder(TEXT_PLAIN, String.class, Encoders.text(UTF_8));
req.body("This is a string response!", TEXT_PLAIN);
});
});
});
Tip
|
As a design decision, no encoders are defined and registered on the server by default. If you need one, you need to register it. The only caveat to this is that if no encoder is specified, an attempt will be made to convert the body content to a byte array, if such a method is available. |
Provided Encoders
The API provides a handful of commonly used encoders. These are defined in the io.github.cjstehno.ersatz.encdec.Encoders
class, and described below:
-
Text. Encodes the object as a
String
of text, with optional character set specification. -
Content. Loads the content at the specified destination and returns it as a byte array. The content destination may be specified as an
InputStream
,String
,Path
,File
,URI
, orURL
. -
Binary Base64. Encodes a byte array, InputStream or other object with a "getBytes()" method into a base-64 string.
-
Multipart. Encodes a
MultipartResponseContent
object to its multipart string representation.
JSON Encoders
As of 3.0.0 the built-in JSON Encoder was removed (to avoid dependency lock-in). Below are provided some simple/common implementations of them for use in your code:
Groovy
An encoder (serializer) implemented with the Groovy JsonOutput
could be implemented as:
public static final Function<Object, byte[]> json = obj → (obj != null ? toJson(obj) : "{}").getBytes(UTF_8);
encoder(ContentType.APPLICATION_JSON, MyType){ obj ->
JsonOutput.toJson(obj).getBytes(UTF_8)
}
Tip
|
If you are using the Groovy extension library, this encoder is available as the JsonEncoder class.
|
Jackson
An encoder (serializer) implemented using the Jackson JSON library could be implemented as:
encoder(ContentType.APPLICATION_JSON, MyType.class, obj -> {
return new ObjectMapper().writeValueAsBytes(obj);
});
Request Requirements
Global request requirements allow for common configuration of expectation matching requirements in the ServerConfig
rather than in the individual expectations.
Requirements have no response mechanism, they only serve to provide a common interface for shared request matching logic. Consider the case where every request to a server must have some security token configured as a header (say "Security-Token") and that all requests must use HTTPS. We could accomplish this with expectations with something like:
var server = new ErsatzServer(cfg -> cfg.https());
server.expectations(expect -> {
expect.GET("/retrieve", req -> {
req.secure();
req.header("Security-Token", expectedToken);
req.responder(res -> {
res.body(aResponse, APPLICATION_JSON);
});
});
});
In this instance, it’s not all that bad, but consider now the case where you have multiple endpoints and you want to do some thorough testing against them… you will be writing a lot of expectations and repeating a lot of that boiler-plate code simply to provide the request matcher.
The requirements framework allows this to be simplified. For the same scenario above, with requirements, we will have:
var server = new ErsatzServer(cfg -> {
cfg.requirements(require -> {
require.that(ANY, anyPath(), and -> {
and.secure();
and.header("Security-Token", expectedToken);
});
});
});
This code defines a requirement that any request made using any request method or request path must use HTTPS and have the configured security token header. Any request that does not meet these requirements will be rejected just as if the configuration had been done in the expectations.
Now, you can write your expectations without the boilerplate code. The earlier code becomes:
server.expectations(expect -> {
expect.GET("/retrieve", req -> {
req.responder(res -> {
res.body(aResponse, APPLICATION_JSON);
});
});
});
Requirements provide a similar interface to the expectations, but provide no means of configuring a response.
The configured requirements are matched using the configured method and path matchers. If a request comes in matching the method and path matchers for a requirement, that request must then also match the configured request parameter requirements.
Request Expectations
The expectation definition methods take four common forms:
-
One taking a
String path
returning an instance of theRequest
interface -
One taking a
String path
and aConsumer<Request>
returning an instance of theRequest
interface -
One taking a
String path
and a GroovyClosure
returning an instance of theRequest
interface -
All the above with the
String path
replaced by a HamcrestMatcher<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 path
strings in the verb methods may be called with as a wildcard value - this will match any request with that request method (e.g.
GET('')
would match any GET request while any('*')
could be used to match ANY request made on the server).
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 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 for building 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 performed 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).
Expectations may be cleared from the server using the clearExpectations()
method. This is useful when you need to redefine expectations for one test only, but all the others have a common set of expectations.
Request Methods
The Ersatz server supports all the standard HTTP request headers along with a few non-standard ones. The table below denotes the supported methods their contents.
Method |
Request Body |
Response Body |
Reference |
GET |
N |
Y |
|
HEAD |
N |
N |
|
OPTIONS |
N |
N |
|
POST |
Y |
Y |
|
PUT |
Y |
N |
|
DELETE |
N |
N |
|
PATCH |
Y |
N |
|
TRACE |
N |
Y |
The following sections describe how each method is supported with a simple example.
While Ersatz does constrain the content of the request and response based on the request method, it is generally up to the mocker to provide the desired and/or appropriate responses (including most headers). This implementation leniency is intentional, and is meant to allow for endpoint implementations that do not necessarily follow the published specification, but likewise still need to be tested as they really exist rather than how they should exist.
HEAD
A HEAD
request is used to retrieve the headers for a URL, basically a GET
request without any response body. An Ersatz mocking example would be:
ersatzServer.expectations {
HEAD('/something').responds().header('X-Alpha','Interesting-data').code(200)
}
which would respond to HEAD /something
with an empty response and the response header X-Alpha
with the specified value.
GET
The GET
request is a common HTTP request, and what browsers do by default. It has no request body, but it does have response content. You mock GET
requests using the get()
methods, as follows:
ersatzServer.expectations {
GET('/something').responds().body('This is INTERESTING!', 'text/plain').code(200)
}
In a RESTful interface, a GET
request is usually used to "read" or retrieve a resource representation.
OPTIONS
The OPTIONS
HTTP request method is similar to an HEAD
request, having no request or response body. The primary response value in an OPTIONS
request is the content of the Allow
response header, which will contain a comma-separated list of the request methods supported by the server. The request may be made against a specific URL path, or against *
in order to determine what methods are available to the entire server.
In order to mock out an OPTIONS
request, you will want to respond with a provided Allow
header. This may be done using the
Response.allows(HttpMethod…)
method in the responder. An example would be something like:
ersatzServer.expectations {
OPTIONS('/options').responds().allows(GET, POST).code(200)
OPTIONS('/*').responds().allows(DELETE, GET, OPTIONS).code(200)
}
This will provide different allowed options for /options
and for the "entire server" (*
). You can also specify the Allow
header as a standard response header.
Note
|
Not all client and servers will support the OPTIONS request method.
|
POST
The POST
request is often used to send browser form data to a backend server. It can have both request and response content.
ersatzServer.expectations {
POST('/form'){
body([first:'John', last:'Doe'], APPLICATION_URLENCODED)
responder {
body('{ status:"saved" }', APPLICATION_JSON)
}
}
}
In a RESTful interface, the POST
method is generally used to "create" new resources.
PUT
A PUT
request is similar to a POST
except that while there is request content, there is no response body content.
ersatzServer.expectations {
PUT('/form'){
query('id','1234')
body([middle:'Q'], APPLICATION_URLENCODED)
responder {
code(200)
}
}
}
In a RESTful interface, a PUT
request if most often used as an "update" operation.
DELETE
A DELETE
request has not request or response content. It would look something like:
ersatzServer.expectations {
DELETE('/user').query('id','1234').responds().code(200)
}
In a RESTful interface, a DELETE
request may be used as a "delete" operation.
PATCH
The PATCH
request method creates a request that can have body content; however, the response will have no content.
ersatzServer.expectations {
PATCH('/user'){
query('id','1234')
body('{ "middle":"Q"}', APPLICATION_JSON)
responder {
code(200)
}
}
}
In a RESTful interface, a PATCH
request may be used as a "modify" operation for an existing resource.
ANY
The ANY
request method creates a request expectation that can match any HTTP method - the body of the expectation will
have the same format as the HTTP method expectations described earlier.
server.expectations(expect -> {
expect.ANY("/something", req -> {
req.secure();
req.called(1);
req.responder(res -> res.body(responseText, TEXT_PLAIN));
});
});
Generic Method
In version 3.2 a generic set of request expectation methods were added to allow the definition of request expectations based on variable HTTP request methods:
Request request(final HttpMethod method, final String path) Request request(final HttpMethod method, final Matcher<String> matcher) Request request(final HttpMethod method, final String path, Consumer<Request> consumer) Request request(final HttpMethod method, final Matcher<String> matcher, final Consumer<Request> consumer) Request request(final HttpMethod method, final PathMatcher pathMatcher) Request request(final HttpMethod method, final PathMatcher pathMatcher, Consumer<Request> consumer)
These expectation methods work in the same manner as the method-specific interfaces described above.
This functionality is useful when writing tests for an endpoint that may accept multiple HTTP methods for the same underlying endpoint resource.
server.expectations(expect -> {
expect.request(GET, "/something", req -> {
req.secure();
req.called(1);
req.responds().body(responseText, TEXT_PLAIN);
});
});
TRACE
The TRACE
method is generally meant for debugging and diagnostics. The request will have no request content; however, if the request is valid, the response will contain the entire request message in the entity-body, with a Content-Type of message/http
. With that in mind, the TRACE
method is implemented a bit differently than the other HTTP methods. It’s not available for mocking, but it will provide an echo of the request as it is supposed to. For example the following request (raw):
TRACE / HTTP/1.1 Host: www.something.com
would respond with something like the following response (raw):
HTTP/1.1 200 OK Server: Microsoft-IIS/5.0 Date: Tue, 31 Oct 2006 08:01:48 GMT Connection: close Content-Type: message/http Content-Length: 39 TRACE / HTTP/1.1 Host: www.something.com
Since this functionality is already designed for diagnostics purposes, it was decided that it would be best to simply implement and support the request method rather than allow it to be mocked.
Making a TRACE
request to Ersatz looks like the following (Groovy):
ersatzServer.start()
URL url = new URL("${ersatzServer.httpUrl}/info?data=foo+bar")
HttpURLConnection connection = url.openConnection() as HttpURLConnection
connection.requestMethod = 'TRACE'
assert connection.contentType == MESSAGE_HTTP.value
assert connection.responseCode == 200
assert connection.inputStream.text.readLines()*.trim() == """TRACE /info?data=foo+barHTTP/1.1
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive
User-Agent: Java/1.9.0.1_121
Host: localhost:${ersatzServer.httpPort}
""".readLines()*.trim()
The explicit start()
call is required since there are no expectations specified (auto-start won’t fire). The HttpUrlConnection
is used to make the request, and it can be seen that the response content is the same as the original request content.
The TRACE
method is supported using the built-in HttpTraceHandler
provided by the embedded Undertow server.
Note
|
At some point, if there are valid use cases for allowing mocks of TRACE it could be supported. Feel free to
create an Issue ticket describing your use case, and it will be addressed.
|
Verification
A timeout (and unit) parameter is available on the verify
method so that a failed verification can fail-out in a timely manner, while still waiting for messages that are not coming.
Request Matching
When a request comes into the server an attempt is made to match it against the configured request expectations. When a match is found, the configured response it returned to the client; however, when no expectation matches the request a 404 response will be returned and a mismatch report will be written to the logs, an example is shown below:
# Unmatched Request
HTTP GET /alpha/foo ? selected=[one, two], id=[1002]
Headers:
- alpha: [bravo-1, bravo-2]
- charlie: [delta]
- Content-Type: [text/plain]
Cookies:
- ident (null, null): asdfasdfasdf
Character-Encoding: UTF-8
Content-type: text/plain
Content-Length: 1234
Content:
[84, 104, 105, 115, 32, 105, 115, 32, 115, 111, 109, 101, 32, 116, 101, 120, 116, 32, 99, 111, 110, 116, 101, 110, 116]
# Expectations
Expectation 0 (2 matchers):
X HTTP method matches <POST>
✓ Path matches "/alpha/foo"
(2 matchers: 1 matched, 1 failed)
Expectation 1 (3 matchers):
X HTTP method matches <PUT>
X Path matches a string starting with "/alpha/bar"
X Protocol matches equalToIgnoringCase("HTTPS")
(3 matchers: 0 matched, 3 failed)
It will show the incoming request that was not matched with all of its known details, as well as a detailed explanation of the configured expectations and each matcher it provides. Successful matches are marked with a checkmark (✓
), and mis-matches with an X
.
Alternately, you may specify the reportToConsole true
configuration in the server config. This will cause the report to be written to the standard output console as well as into the log output. This is useful for cases when you might have logging turned off.
Tip
|
Be aware that any Request Requirements are tested before expectation request matchers. |
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 {
body '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.
If you are using Groovy, you can actually replace Hamcrest matchers with a Closure
emulating the same interface - basically a method that takes the parameter and returns whether the condition was matched. The same example above could be re-written as:
server.expectations {
GET({ p-> p.startsWith('/foo') }){
called { i-> i >= 2 }
query 'user-key', notNullValue()
responder {
body 'ok', TEXT_PLAIN
}
}
}
This allows for additional flexibility in configuring expectations.
Specialized Matchers
There are a handful of specialized Hamcrest matchers defined and used in the API. They are used as the underlying internal matchers, but are also useful in your code - these may be found in the io.github.cjstehno.ersatz.match
package:
-
BodyMatcher
- provides various matching methods for body content. -
BodyParamMatcher
- provides matching methods for request body paramters. -
PathMatcher
- provides matching methods for request paths. -
QueryParamMatcher
- provides matching methods for request query string parameters. -
HeaderMatcher
- provides matching methods for request header values. -
RequestCookieMatcher
- provides matching methods for request cookie data. -
PredicateMatcher
- provides an adapter for wrapping simplePredicate
functions as matchers.
Matching Cookies
There are four methods for matching cookies associated with a request (found in the io.github.cjstehno.ersatz.cfg.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 io.github.cjstehno.ersatz.match.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 io.github.cjstehno.ersatz.cfg.Cookie
objects. The matcher may be any custom matcher, or the io.github.cjstehno.ersatz.match.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)
}
}
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') {
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 {
body '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') {
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 {
body '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.
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.
Response Building
The responds(…)
, responder(…)
, and forward(…)
methods of the Request
matcher allow for the customization of the response to the request. Basic response properties such as headers, status code, and content body are available, as well as some more advanced configuration options, described below:
Request / Response Compression
Ersatz supports GZip compression seamlessly as long as the Accept-Encoding
header is specified as gzip
. If the response is compressed, a Content-Encoding
header will be added to the response with the appropriate compression type as the value.
Chunked Response
A response may be configured as a "chunked" response, wherein the response data is sent to the client in small bits along with an additional response header, the Transfer-encoding: chunked
header. For testing purposes, a fixed or randomized range of time delay may be configured so that the chunks may be sent slowly, to more accurately simulate a real environment.
To configure a chunked response, provide a ChunkingConfig
to the response configuration:
ersatzServer.expectations {
GET('/chunky').responder {
body 'This is chunked content', TEXT_PLAIN
chunked {
chunks 3
delay 100..500
}
}
}
In the example, the response content will be broken into 3
roughly equal chunks, each of which is sent to the client after a random delay between 100 and 500 milliseconds. This delay
value may also be a fixed number of milliseconds, or omitted to send the content as fast as possible.
Tip
|
The Transfer-encoding response header will be set automatically when a chunked configuration is specified on the response.
|
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 byte[]
content using configured encoders, which are simply instances of
Function<Object,byte[]>
used to do the object to byte array 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
body(multipart {
// configure the part encoders
encoder TEXT_PLAIN, CharSequence, { o -> (o as String).bytes }
encoder IMAGE_JPG, File, { o -> ((File)o).bytes }
// 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).
Request Forwarding
The forward(String)
response configuration method causes the incoming request to be forwarded to another server - the String
parameter is the scheme, host, and port of the target server. The response generated by the same incoming request, on that server, is then returned to the original client. As an example:
ersatz.expectations(expect -> {
expect.GET("/api/widgets/list", req -> {
req.called();
req.query("partial", "true");
req.forward("http://somehost:1234");
});
});
This will expect that a GET request to /api/widgets/list?partial=true
will be called once, and that its response will be the response from making the same request against http://somehost:1234/api/widgets/list?partial=true
.
This feature allows you to ensure that a request is made, with optional expectations, but that the response comes from the other source.
This feature works with both HTTP and HTTPS requests, though the target URI must reflect the desired scheme.
Web Sockets
Note
|
The websocket support is very rudimentary at this time and only supports non-secure (ws://) connections. Please file an issue if this is blocking your usage of it. |
WebSocket support is provided in the "expectations" configuration. You can expect that a websocket is connected-to, and that it receives a specified message. You can also "react" to the connection or inbound message by sending a message back to the client.
An example would be something like:
ersatz.expectations(expects -> {
expects.webSocket("/game", ws -> {
ws.receives(pingBytes).reaction(pongBytes, BINARY);
});
});
In this example, the server expects that a websocket connection occurs for "/game", when that connection occurs, the server will expect to receive a message of pingBytes
. When it receives that message, it will respond with a message pongBytes
(in BINARY
format).
If the client does not make the "/game" request with the expected message, it will not receive the reaction message, and the test should fail - verification will also fail.
Verification Timeouts. Sometimes, when running asynchronous network tests, such as these, you can run into issues on different environments - your laptop might burn through the tests quickly and have no issues, but your over-worked build server might take more time and cause tests to fail by timing out. The verify
methods accept a WaitFor
parameter which allows you to configure a wait time. One useful value here is FOREVER
which causes the test verification to wait for the expected conditions. When this fails, it will hang your test environment - not a great condition, but at least then you know you have a real problem, rather than having to come up with some arbitrary timeout value.
Shadow Jar
The embedded version of Undertow used by Ersatz has caused issues with some server frameworks which also use Undertow (e.g. Grails, and Spring-boot).
If you run into errors using the standard jar distribution, please try using the safe
distribution, which is a shadowed jar that includes the Undertow library and many of the other dependencies repackaged in the jar. You can use this version in the manner described below for your build system.
Note
|
There are examples of various usage configurations in the Ersatz Usage Tests project. |
Gradle
A Gradle gradle.build
file would have the following defined in the dependencies
block:
testImplementation('io.github.cjstehno.ersatz:ersatz:4.0.1:safe'){
exclude group:'io.undertow', module:'undertow-core'
exclude group:'javax.servlet', module:'javax.servlet-api'
exclude group:'commons-fileupload', module:'commons-fileupload'
}
// or for the Groovy DSL
testImplementation('io.github.cjstehno.ersatz:ersatz-groovy:4.0.1:safe'){
exclude group:'io.undertow', module:'undertow-core'
exclude group:'javax.servlet', module:'javax.servlet-api'
exclude group:'commons-fileupload', module:'commons-fileupload'
}
testImplementation 'org.codehaus.groovy:groovy:3.0.9'
testImplementation 'javax.activation:activation:1.1.1'
testImplementation 'org.hamcrest:hamcrest-library:2.2'
The inclusions and exclusions are required to truly isolate and configure the artifact, due to some odd maven publishing requirements.
Maven
For a Maven pom.xml
entry, this would be:
<dependency>
<groupId>io.github.cjstehno.ersatz</groupId>
<artifactId>ersatz</artifactId>
<classifier>safe</classifier>
<version>4.0.1</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>io.undertow</groupId>
<artifactId>undertow-core</artifactId>
</exclusion>
<exclusion>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</exclusion>
<exclusion>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- or, for the Groovy DSL -->
<dependency>
<groupId>io.github.cjstehno.ersatz</groupId>
<artifactId>ersatz-groovy</artifactId>
<classifier>safe</classifier>
<version>4.0.1</version>
<type>jar</type>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>io.undertow</groupId>
<artifactId>undertow-core</artifactId>
</exclusion>
<exclusion>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</exclusion>
<exclusion>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>3.0.9</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-library</artifactId>
<version>2.2</version>
<scope>test</scope>
</dependency>
The inclusions and exclusions are required to truly isolate and configure the artifact, due to some odd maven publishing requirements.
Common Usage Examples
This section contains some recipe-style usage examples.
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(expect -> {
expect.POST("/form", req -> {
req.body(Map.of(
"alpha", "some data",
"bravo", "42"
), ContentType.APPLICATION_URLENCODED);
req.responder(res -> {
res.body("ok");
});
});
});
where the POST
content data would look like:
alpha=some+data&bravo=42
File Upload (POST)
You can set up an expectation for a file upload POST using the multipart
support, something like:
import io.github.cjstehno.erstaz.ErsatzServer
import io.github.cjstehno.ersatz.MultipartRequestContent
import static io.github.cjstehno.ersatz.ContentType.TEXT_PLAIN
def ersatz = new ErsatzServer({
encoder TEXT_PLAIN, File, Encoders.text
})
def file = new File(/* some file */)
ersatz.expectations {
POST('/upload') {
decoder TEXT_PLAIN, Decoders.utf8String
decoder MULTIPART_MIXED, Decoders.multipart
body MultipartRequestContent.multipart {
part 'fileName', file.name
part 'file', file.name, 'text/plain; charset=utf-8', file.text
}, MULTIPART_MIXED
responder {
body 'ok'
}
}
}
This will expect the posting of the given file content to the /upload
path of the server.
File Download (GET)
Setting up an expectation for a GET request to respond with a file to download can be done as follows:
import io.github.cjstehno.erstaz.ErsatzServer
import static io.github.cjstehno.ersatz.ContentType.TEXT_PLAIN
def ersatz = new ErsatzServer({
encoder TEXT_PLAIN, File, Encoders.text
})
def file = new File(/* some file */)
ersatz.expectations {
GET('/download'){
responder {
header 'Content-Disposition', "attachment; filename=\"${file.name}\""
body file, TEXT_PLAIN
}
}
}
This will respond to the request with file download content.
Kotlin Usage
You can use the Ersatz Server from the Kotlin programming language just as easily as Java or Groovy:
val ersatz = ErsatzServer { config -> config.autoStart(true) }
ersatz.expectations { expectations ->
expectations.GET("/kotlin").called(1).responder { response ->
response.body("Hello Kotlin!", ContentType.TEXT_PLAIN).code(200)
}
}
val http = OkHttpClient.Builder().build()
val request: okhttp3.Request = okhttp3.Request.Builder().url("${ersatz.httpUrl}/kotlin").build()
println( http.newCall(request).execute().body().string() )
which will print out "Hello Kotlin!" when executed.
Matching XML Body Content
An example of how to use the Hamcrest matchers in a request (in a Groovy test using Spock).
import io.github.cjstehno.ersatz.encdec.DecodingContext
import io.github.cjstehno.ersatz.ErsatzServer
import okhttp3.MediaType
import okhttp3.Response
import spock.lang.AutoCleanup
import spock.lang.Specification
import javax.xml.parsers.DocumentBuilderFactory
import static io.github.cjstehno.ersatz.encdec.ContentType.TEXT_XML
import static io.github.cjstehno.ersatz.encdec.Decoders.utf8String
import static io.github.cjstehno.ersatz.encdec.Encoders.text
import static okhttp3.RequestBody.create
import static org.hamcrest.CoreMatchers.equalTo
import static org.hamcrest.xml.HasXPath.hasXPath
class BodyContentMatcherSpec extends Specification {
@AutoCleanup private final ErsatzServer server = new ErsatzServer()
private final HttpClient http = new HttpClient()
void 'matching part of body content'() {
setup:
String requestXml = '<request><node foo="bar"/></request>'
String responseXml = '<response>OK</response>'
server.expectations {
POST('/posting') {
decoder('text/xml; charset=utf-8') { byte[] bytes, DecodingContext ctx ->
DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(new ByteArrayInputStream(bytes))
}
body(
hasXPath('string(//request/node/@foo)', equalTo('bar')),
'text/xml; charset=utf-8'
)
called 1
responder {
body responseXml, TEXT_XML
encoder TEXT_XML, String, text
}
}
}
when:
Response response = http.post(
server.httpUrl('/posting'),
create(MediaType.get('text/xml; charset=utf-8'), requestXml)
)
then:
response.body().string() == responseXml
when:
response = http.post(
server.httpUrl('/posting'),
create(
MediaType.get('text/xml; charset=utf-8'),
'<request><node foo="blah"/></request>'
)
)
then:
response.code() == 404
and:
server.verify()
}
}
This test sets up a POST expectation with the XML request body content being used as one of the matching criteria. Hamcrest provides an XPath-based matcher, hasXPath(String, Matcher)
, which works well here. A custom XML-decoder was installed to parse the request into the XML document format required by the matcher.
The test shows two requests made to the server, one with the expected content and one without - the results verify that only the correct call was actually matched.
See the Hamcrest documentation for more details about pre-existing and custom `Matcher`s.
Forwarding to Another Server for Response
A test case may arise where you have a real server running, where you want to verify the contents of your request, but then respond with the real server response to that request. The "request forwarding" functionality can do that:
server.expectations(expect -> {
expect.GET("/endpoint/get", req -> {
req.secure();
req.called();
req.query("foo", "bar");
req.forward("https://someother:9753");
});
});
In this example, a GET request is expected at the /endpoint/get
path. It should be an HTTPS request, with the query string foo=bar
. The Ersatz server will match the request, and if it matches it will forward the request to the configured server (https://somother:9753/endpoint/get?foo=bar
in this case). The response from that request will be returned as the response from the Ersatz server.
With this, you can verify that you sent the expected request, once, and that it retrieves the expected response from the server.
Using Test-Things with Ersatz
In the test io.github.cjstehno.ersatz.examples.ErsatzThingsTest
example, you can see how well the Test-Things library integrates with Ersatz (yes, it’s another project of mine). The example is a bit contrived, but is shows how you can use the SharedRandomExtension
, and ResourcesExtension
with the ErsatzExtension
to simplify random value generation and resource loading.
The example configures a GET request that will respond with JPG image content, when a secret header value is matched in the request - the header value is randomly generated. Yes, there are simpler means of generating a single random number, but this is just to show how the randomizers might be useful with Ersatz.
package io.github.cjstehno.ersatz.examples;
import io.github.cjstehno.ersatz.ErsatzServer;
import io.github.cjstehno.ersatz.junit.SharedErsatzServerExtension;
import io.github.cjstehno.ersatz.util.HttpClientExtension;
import io.github.cjstehno.ersatz.util.HttpClientExtension.Client;
import io.github.cjstehno.testthings.junit.Resource;
import io.github.cjstehno.testthings.junit.ResourcesExtension;
import io.github.cjstehno.testthings.junit.SharedRandomExtension;
import lombok.val;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static io.github.cjstehno.ersatz.cfg.ContentType.IMAGE_JPG;
import static io.github.cjstehno.testthings.rando.NumberRandomizers.aFloat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* A bit of a contrived example to show how you can use the
* <a href="https://cjstehno.github.io/test-things/">Test-Things</a> testing library with Ersatz, also written by me.
*/
@ExtendWith({
// provides a means of pinning randomness
SharedRandomExtension.class,
// provides access to resources
ResourcesExtension.class,
// provides the server management
SharedErsatzServerExtension.class,
// provides a pre-wired test client for ersatz (internal only)
HttpClientExtension.class
})
public class ErsatzThingsTest {
private static final String SECRET_HEADER = "X-Secret";
// loads the image resource as a byte array
@Resource("/test-image.jpg") private static byte[] IMAGE_CONTENT;
// stores the http client instance
private Client client;
@Test void things(final ErsatzServer ersatz) throws Exception {
// generates a random secret value
val secret = aFloat().one().toString();
ersatz.expectations(expect -> {
expect.GET("/images/42", req -> {
req.called();
req.header(SECRET_HEADER, secret);
req.responder(res -> {
res.body(IMAGE_CONTENT, IMAGE_JPG);
res.code(200);
});
});
});
// make the request
val response = client.get("/images/42", builder -> builder.header(SECRET_HEADER, secret));
assertEquals(200, response.code());
assertEquals(721501, response.body().bytes().length);
ersatz.assertVerified();
}
}
Note
|
The HttpClientExtension show in the example is an internal client management extension used in testing Ersatz itself, but the general idea is that it provides an HTTP client wired up to the configured Ersatz server. Yes. even my test tools, have test tools.
|
Appendices
A. Development Philosophy
As this project starts its seventh year (started in December 2016) it’s doing well, and it is stable, but I have less time to devote to it. I figured it would be a good time to outline my general development philosophy/strategy for the project as it moves forward.
The primary goal of the 3.x release was increased simplicity and ease of development. This is why I removed some of the more niche features in favor of keeping it more maintainable. I also removed some automated development tools (e.g. Coveralls and Travis). There is some maintenance effort involved in keeping them current, and I am a one-developer team - the information provided by these tools can easily be discovered by building the project (and will be published with the releases).
Being that this is a project used in writing unit tests, I don’t generally feel the need for strict backwards compatability as long as there is a simple upgrade path. That being said, if some change causes a major problem, I am not against cutting a new release with changes that make the transition easier.
The audience for this project is very small and there are very few bugs and feature requests, so if that continues, I will plan on putting out a new release once a year to keep up with current JDK and dependency versions.
B. License
This project is licensed under the Apache 2.0 License.
Copyright (C) 2023 Christopher J. Stehno Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.