Ratpack is a developer friendly and productivity focused web framework. That’s quite a claim to make. We’ll explore how Ratpack’s rich testing facilities strongly support this statement.
1. Intro
-
Test framework Agnostic (Spock, JUnit, TestNG)
-
Core fixutres in Java 8+, first-class Groovy Support available
-
Most fixtures implement
java.lang.AutoCloseable
-
Need to either close yourself or use in
try-with-resources
-
Provides points of interaction that utilize an execute around pattern in cases where you need the fixture once.
-
2. Hello World
2.1. Dependencies
plugins { (1)
id 'io.ratpack.ratpack-groovy' version '1.3.3' (2)
}
repositories {
jcenter()
}
dependencies {
runtime "org.apache.logging.log4j:log4j-slf4j-impl:${log4j}"
runtime "org.apache.logging.log4j:log4j-api:${log4j}"
runtime "org.apache.logging.log4j:log4j-core:${log4j}"
runtime 'com.lmax:disruptor:3.3.2'
testCompile ratpack.dependency('groovy-test') (3)
testCompile ('org.spockframework:spock-core:1.0-groovy-2.4') {
exclude module: "groovy-all"
}
testCompile 'junit:junit:4.12'
testCompile 'org.testng:testng:6.9.10'
}
1 | Use Gradle’s incubating Plugins feature |
2 | Pull in and apply Ratpack’s Gradle plugin from Gradle’s Plugin Portal |
3 | Pull in 'io.ratpack:ratpack-groovy-test from Bintray |
2.2. Hello World Under Test
import static ratpack.groovy.Groovy.ratpack
ratpack {
handlers {
get {
render 'Hello GR8ConfUS 2016!'
}
}
}
import groovy.transform.CompileStatic
import ratpack.func.Action
import ratpack.groovy.handling.GroovyContext
import ratpack.groovy.handling.GroovyHandler
import ratpack.handling.Chain
import ratpack.server.RatpackServer
import ratpack.server.RatpackServerSpec
@CompileStatic
class MainClassApp {
public static void main(String[] args) throws Exception {
RatpackServer.start({ RatpackServerSpec serverSpec -> serverSpec
.handlers({ Chain chain ->
chain.get({GroovyContext ctx ->
ctx.render 'Hello GR8ConfUS 2016!'
} as GroovyHandler)
} as Action<Chain>)
} as Action<RatpackServerSpec>)
}
}
2.3. Verify
$ curl localhost:5050
Hello Greach 2016!
3. assert true
3.1. Spock Hello World
import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import ratpack.test.MainClassApplicationUnderTest
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.Unroll
class HelloWorldSpec extends Specification {
// tag::GroovyScriptAUT[]
@AutoCleanup
@Shared
GroovyRatpackMainApplicationUnderTest groovyScriptApplicationunderTest = new GroovyRatpackMainApplicationUnderTest()
// end::GroovyScriptAUT[]
// tag::MainClassAUT[]
@AutoCleanup
@Shared
MainClassApplicationUnderTest mainClassApplicationUnderTest = new MainClassApplicationUnderTest(MainClassApp)
// end::MainClassAUT[]
@Unroll
def 'Should render \'Hello GR8ConfUS 2016!\' from #type'() {
when:
def getText = aut.httpClient.getText()
then:
getText == 'Hello GR8ConfUS 2016!'
where:
aut | type
groovyScriptApplicationunderTest | 'ratpack.groovy'
mainClassApplicationUnderTest | 'MainClassApp.groovy'
}
}
3.2. Junit Test
import groovy.transform.CompileStatic
import org.junit.Assert
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.Test
import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import ratpack.test.MainClassApplicationUnderTest
import ratpack.test.http.TestHttpClient
import static org.junit.Assert.assertEquals
@CompileStatic
class HelloJunitTest {
static GroovyRatpackMainApplicationUnderTest groovyScriptApplicationunderTest
static MainClassApplicationUnderTest mainClassApplicationUnderTest
@BeforeClass
static void setup() {
groovyScriptApplicationunderTest = new GroovyRatpackMainApplicationUnderTest()
mainClassApplicationUnderTest = new MainClassApplicationUnderTest(MainClassApp)
}
@AfterClass
static void cleanup() {
groovyScriptApplicationunderTest.close()
mainClassApplicationUnderTest.close()
}
@Test
def void testHelloWorld() {
[
groovyScriptApplicationunderTest,
mainClassApplicationUnderTest
].each { aut ->
TestHttpClient client = aut.httpClient
assertEquals('Hello GR8ConfUS 2016!', client.getText())
}
}
}
4. Unit testing
4.1. GroovyRequestFixture
4.1.1. Testing Standalone Handlers
import groovy.transform.CompileStatic
import ratpack.groovy.handling.GroovyContext
import ratpack.groovy.handling.GroovyHandler
@CompileStatic
class ImportantHandler extends GroovyHandler {
@Override
protected void handle(GroovyContext context) {
context.render 'Very important handler'
}
}
import static ratpack.groovy.Groovy.ratpack
ratpack {
handlers {
get(new ImportantHandler()) (1)
}
}
1 | Bind our ImportantHandler to GET / |
def 'should render \'Very important handler\''() {
when:
HandlingResult result = GroovyRequestFixture.handle(new ImportantHandler()) {}
then:
result.bodyText == 'Very important handler (1)
}
1 | Consult the HandlingResult for response body |
This test will fail |
What happened?
Context#render(Object)
uses Ratpack’s rendering framework.
GroovyRequestFixture
does not actually serialize rendered objects to Response
of HandlingResult
.
For this test to pass you must either modify the Handler or modify the expectation:
Modify the handler:
import groovy.transform.CompileStatic
import ratpack.groovy.handling.GroovyContext
import ratpack.groovy.handling.GroovyHandler
@CompileStatic
class ImportantSendingHandler extends GroovyHandler {
@Override
protected void handle(GroovyContext context) {
context.response.send('Very important handler')
}
}
Modify the expectation:
def 'should render \'Very important handler\''() {
when:
HandlingResult result = GroovyRequestFixture.handle(new ImportantHandler()) {}
then:
String rendered = result.rendered(String) (1)
rendered == 'Very important handler'
}
1 | Retrieve the rendered object by type from HandlingResult |
Everday use:
4.1.2. Modify request attributes
import groovy.transform.CompileStatic
import ratpack.groovy.handling.GroovyContext
import ratpack.groovy.handling.GroovyHandler
@CompileStatic
class HeaderExtractionHandler extends GroovyHandler {
@Override
protected void handle(GroovyContext context) {
String specialHeader = context.request.headers.get('special') ?: 'not special' (1)
context.render "Special header: $specialHeader"
}
}
1 | Extract HTTP header and render a response to client |
import ratpack.test.handling.HandlingResult
import spock.lang.Specification
import ratpack.groovy.test.handling.GroovyRequestFixture
import spock.lang.Unroll
class HeaderExtractionHandlingSpec extends Specification {
@Unroll
def 'should render #expectedValue with special header value'() {
when:
HandlingResult result = GroovyRequestFixture
.handle(new HeaderExtractionHandler(), requestFixture)
then:
def rendered = result.rendered(CharSequence)
rendered == "Special header: $expectedValue"
where:
expectedValue | requestFixture
'gr8confUS2016' | { header('special', 'gr8confUS2016') } (1)
'not special' | {}
}
}
1 | You can get a chance to configure the properties of the request to be made, can configure HTTP method, headers, request body, etc. |
4.1.3. Modify and make assertions against context registry:
import groovy.transform.Canonical
import groovy.transform.CompileStatic
import ratpack.groovy.handling.GroovyContext
import ratpack.groovy.handling.GroovyHandler
import ratpack.registry.Registry
@CompileStatic
class ProfileLoadingHandler extends GroovyHandler {
@Override
protected void handle(GroovyContext context) {
String role = context.request.headers.get('role') ?: 'guest' (1)
String secretToken = context.get(String) (2)
context.next(Registry.single(new Profile(role: role, token: secretToken))) (3)
}
}
@Canonical
class Profile {
String role
String token
}
1 | Extract role from request header, defaulting to 'guest' |
2 | Extract a String from the context registry |
3 | Delegate to the next Handler in the chain and pass a new single Registry with a newly constructed Profile object |
We can make use of RequestFixture
to populate the Registry with any entries our stand-alone Handler may be expecting, such as a token in the form of a String.
def 'handler should populate context registry with Profile'() {
when:
HandlingResult result = GroovyRequestFixture.handle(new ProfileLoadingHandler()) {
header('role', 'admin') (1)
registry { add(String, 'secret-token') } (2)
}
then:
result.registry.get(Profile) == new Profile('admin', 'secret-token') (3)
}
1 | Use RequestFixture#header to add Headers to the HTTP Request |
2 | Use RequestFixture#registry to add a String to the Context registry |
3 | Consult the HandlingResponse to ensure that the context was populated with a Profile object and that it meets our expectations |
Let’s put our ProfileLoadingHandler
in a chain with a dummy Map renderer:
def 'should be able to render Profile as map from Registry'() {
when:
HandlingResult result = GroovyRequestFixture.handle(new GroovyChainAction() { (1)
@Override
void execute() throws Exception {
all(new ProfileLoadingHandler()) (2)
get { (3)
Profile profile = get(Profile)
render([profile: [role: profile.role, token: profile.token]])
}
}
}) {
header('role', 'admin')
registry { add(String, 'secret-token') }
}
then:
result.rendered(Map) == [profile: [role: 'admin', 'token': 'secret-token']] (4)
}
5. GroovyEmbeddedApp
GroovyEmbeddedApp
represents an isolated subset of functionality that stands up a full Ratpack server.
It represents a very bare server that binds to an ephemeral port and has no base directory by default.
GroovyEmbeddedApp
is also AutoCloseable
.
If you plan on making more than a few interactions it may help to grab a TestHttpClient
from the server, otherwise you can make use of EmbeddedApp#test(TestHttpClient)
which will ensure that the EmbeddedApp
is shut down gracefully.
Javadocs for Ratpack are 100% tested and make use of EmbeddedApp
to demonstrate functionality.
The EmbeddedApp
is also useful in creating a test fixture that represents some network based resource that returns canned or contrived responses.
def 'can create simple hello world'() {
expect:
GroovyEmbeddedApp.fromHandler { (1)
render 'Hello GR8ConfUS 2016!'
} test {
assert getText() == 'Hello GR8ConfUS 2016!' (2)
}
}
1 | Creates a full Ratpack server with a single handler |
2 | Ratpack provides us with a TestHttpClient that is configured to submit requests to EmbeddedApp . When the closure is finished executing Ratpack will take care of cleaning up the EmbeddedApp . |
6. TestHttpClient
For testing, Ratpack provides TestHttpClient
which is a blocking, synchronous http client for making requests against a running ApplicationUnderTest
. This is intentionally designed in order to make testing deterministic and predictable.
def 'demonstrate ByMethodSpec'() {
given:
GroovyEmbeddedApp app = GroovyEmbeddedApp.fromHandlers { (1)
path {
byMethod {
get {
render 'GET'
}
post {
render 'POST'
}
}
}
}
and:
TestHttpClient client = app.httpClient (2)
expect: (3)
client.getText() == 'GET'
client.postText() == 'POST'
client.put().status.code == 405
client.delete().status.code == 405
cleanup: (4)
app.close()
}
1 | Create GroovyEmbeddedApp from a chain |
2 | Retrieve a configured TestHttpClient for making requests against the EmbeddedApp |
3 | Make some assertions about the application as described by the chain |
4 | Have Spock invoke EmbeddedApp#close to gracefully shutdown the server. |
The TestHttpClient
has some basic support for manipulating request configuration as well as handling redirects and cookies.
def 'should handle redirects and cookies'() {
expect:
GroovyEmbeddedApp.fromHandlers { (1)
get {
render request.oneCookie('foo') ?: 'empty'
}
get('set') {
response.cookie('foo', 'foo')
redirect '/'
}
get('clear') {
response.expireCookie('foo')
redirect '/'
}
} test {
assert getText() == 'empty' (2)
assert getCookies('/')*.name() == []
assert getCookies('/')*.value() == []
assert getText('set') == 'foo'
assert getCookies('/')*.name() == ['foo']
assert getCookies('/')*.value() == ['foo']
assert getText() == 'foo'
assert getText('clear') == 'empty'
assert getCookies('/')*.name() == []
assert getCookies('/')*.value() == []
assert getText() == 'empty'
assert getCookies('/')*.name() == []
assert getCookies('/')*.value() == []
}
}
1 | Create sample app that reads and writes cookies |
2 | Issue requests that ensures cookie setting/expiring and redirect functionality |
7. Async Testing
Ratpack is asynchronous and non-blocking from the ground up. This means that not only is Ratpack’s api asynchronous but it expects that your code should be asynchronous as well.
Let’s say we have a ProfileService
that’s responsible for retrieving Profile
s:
import groovy.transform.Canonical
import ratpack.exec.Blocking
import ratpack.exec.Operation
import ratpack.exec.Promise
class ProfileService {
final List<Profile> store = []
Promise<List<Profile>> getProfiles() {
Promise.value(store)
}
Operation add(Profile p) {
Blocking.op {
store.add(p)
}
}
Operation delete() {
Blocking.op {
store.clear()
}
}
}
@Canonical
class Profile {
String role
String token
}
If you were to test this Service without any assistance from Ratpack you will run into the well known UnmanagedThreadException
:
ratpack.exec.UnmanagedThreadException: Operation attempted on non Ratpack managed thread
7.1. ExecHarness
ExecHarness
is the utility that Ratpack provides to test any kind of asynchronous behavior.
Unsurprisingly ExecHarness
is also an AutoCloseable
.
It utilizes resources that manage an EventLoopGroup
and an ExecutorService
so it’s important to make sure these resources get properly cleaned up.
import ratpack.exec.ExecResult
import ratpack.exec.Promise
import ratpack.test.exec.ExecHarness
import spock.lang.AutoCleanup
import spock.lang.Specification
class ProfileServiceSpec extends Specification {
@AutoCleanup
ExecHarness execHarness = ExecHarness.harness() (1)
def 'can add/retrieve/remove profiles from service'() {
given:
ProfileService service = new ProfileService()
when:
ExecResult<Promise<List<Profile>>> result = execHarness.yield { service.profiles } (2)
then:
result.value == []
when:
execHarness.execute { service.add(new Profile(role: 'admin', token: 'secret')) }
and:
List<Profile> profiles = execHarness.yield { service.profiles }.value
then:
profiles == [new Profile(role: 'admin', token: 'secret')]
when:
execHarness.execute { service.delete() }
then:
execHarness.yield { service.profiles }.value == []
}
}
1 | Create an ExecHarness and tell Spock to clean up when we are finished |
2 | Use ExecHarness#yield to wrap all of our service calls so that our Promises and Operations can be resolved on a Ratpack managed thread. |
8. Functional testing
8.1. MainClassApplicationUnderTest
GroovyRatpackMainApplicationUnderTest
-
For testing
ratpack.groovy
backed applications
@AutoCleanup
@Shared
GroovyRatpackMainApplicationUnderTest groovyScriptApplicationunderTest = new GroovyRatpackMainApplicationUnderTest()
MainClassApplicationUnderTest
-
For testing class backed applications
@AutoCleanup
@Shared
MainClassApplicationUnderTest mainClassApplicationUnderTest = new MainClassApplicationUnderTest(MainClassApp)
Our sample Ratpack application for testing:
import static ratpack.groovy.Groovy.ratpack
ratpack {
serverConfig {
sysProps() (1)
require('/api', ApiConfig) (2)
}
bindings {
bind(ConfService) (3)
}
handlers {
get { ConfService confService ->
confService.conferences.map { (4)
"Here are the best conferences: $it"
} then(context.&render)
}
}
}
1 | Pull configuration from System properties |
2 | Create an ApiConfig object and put into the registry |
3 | Bind ConfService using Guice |
4 | Use ConfService to retrieve list of awesome Groovy Conferences |
class ApiConfig {
String url
}
Simple object to contain our configuration data related to an API
import com.google.inject.Inject
import ratpack.exec.Promise
import ratpack.http.client.HttpClient
class ConfService {
final HttpClient httpClient
final ApiConfig apiConfig
@Inject
ConfService(ApiConfig apiConfig, HttpClient httpClient) { (1)
this.apiConfig = apiConfig
this.httpClient = httpClient
}
Promise<List<String>> getConferences() { (2)
httpClient.get(new URI(apiConfig.url))
.map { it.body }
.map { it.text.split(',').collect { it } }
}
}
1 | Receive ApiConfig and HtpClient from Guice |
2 | Define an asynchronous service method to retrieve data from remote service |
8.2. Configuration
We can take advantage of system properties to change how the Ratpack application configures its services.
import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import ratpack.groovy.test.embed.GroovyEmbeddedApp
import ratpack.test.ApplicationUnderTest
import ratpack.test.http.TestHttpClient
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
class FunctionalSpec extends Specification {
@Shared
@AutoCleanup
ApplicationUnderTest aut = new GroovyRatpackMainApplicationUnderTest() (1)
@Delegate
TestHttpClient client = aut.httpClient (2)
@Shared
@AutoCleanup
GroovyEmbeddedApp api = GroovyEmbeddedApp.fromHandler { (3)
render 'GR8Conf, Greach, Gradle Summit'
}
def setup() {
System.setProperty('ratpack.api.url', api.address.toURL().toString()) (4)
}
def 'can get best conferences'() { (5)
when:
get()
then:
response.statusCode == 200
and:
response.body.text.contains('GR8Conf')
}
}
1 | Create our ApplicationUnderTest and tell Spock to clean up when we’re done |
2 | Retrieve TestHttpClient and make use of @Delegate to make tests very readable |
3 | Create a simple service that response with a comma separated list of Groovy Conferences |
4 | Set system property to point to our stubbed service |
5 | Write a simple test to assure that our Ratpack app can make a succcessful call to the remote api |
8.3. Impositions
Impositions
allow a user to provide overrides to various aspects of the Ratpack application bootstrap phase.
-
ServerConfigImposition
allows to override server configuration -
BindingsImposition
allows to provide Guice binding overrides -
UserRegistryImposition
allows you to provide alternatives for items in the registry
import ratpack.exec.Promise
import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import ratpack.impose.UserRegistryImposition
import ratpack.impose.ImpositionsSpec
import ratpack.test.ApplicationUnderTest
import ratpack.test.http.TestHttpClient
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
import ratpack.guice.Guice
class ImpositionSpec extends Specification {
@Shared
@AutoCleanup
ApplicationUnderTest aut = new GroovyRatpackMainApplicationUnderTest() {
@Override
protected void addImpositions(ImpositionsSpec impositions) { (1)
impositions.add(
UserRegistryImposition.of(Guice.registry {
it.add(new ConfService(null, null) {
Promise<List<String>> getConferences() {
Promise.value(['GR8ConfUS'])
}
})
}))
}
}
@Delegate
TestHttpClient client = aut.httpClient
def 'can get list of gr8 conferences'() {
when:
get()
then:
response.statusCode == 200
and:
response.body.text.contains('GR8ConfUS')
}
}
1 | Override addImpositions method to provide a UserRegistryImposition that supplies our own dumb implementation of ConfService that does not need to make any network connections |
8.4. RemoteControl
Authored by Luke Daley; originally for Grails
Used to serialize commands to be executed on the ApplicationUnderTest
testCompile ratpack.dependency('remote-test')
Here we add a test compile dependency on io.ratpack:ratpack-remote-test
which includes a dependency on remote-control
import io.remotecontrol.client.UnserializableResultStrategy
import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import ratpack.guice.BindingsImposition
import ratpack.impose.ImpositionsSpec
import ratpack.remote.RemoteControl
import ratpack.test.ApplicationUnderTest
import ratpack.test.http.TestHttpClient
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
class RemoteControlSpec extends Specification {
@Shared
@AutoCleanup
ApplicationUnderTest aut = new GroovyRatpackMainApplicationUnderTest() {
@Override
protected void addImpositions(ImpositionsSpec impositions) {
impositions.add(BindingsImposition.of {
it.bindInstance RemoteControl.handlerDecorator() (1)
})
}
}
@Delegate
TestHttpClient client = aut.httpClient
ratpack.test.remote.RemoteControl remoteControl = new ratpack.test.remote.RemoteControl(aut, UnserializableResultStrategy.NULL) (2)
def 'should render profiles'() {
when:
get()
then:
response.body.text == '[]'
when:
remoteControl.exec { (3)
ratpack.exec.Blocking.on (get(ProfileService)
.add(new Profile('admin')).promise())
}
and:
get()
then:
response.body.text.startsWith('[{')
}
}
1 | We use BindingsImposition here to add a hook into the running ApplicationUnderTest that allows us to run remote code on the server |
2 | We tell RemoteControl not to complain if the result of the command is not Serializable |
3 | We use remote control here to grab the ProfileService and manually add a profile |
8.5. EphemeralBaseDir
A utility that provides a nice way to interact with files that would provide the basis of a base directory for Ratpack applications.
It is also an AutoCloseable
so you’ll need to make sure to clean up after use.
import ratpack.groovy.test.embed.GroovyEmbeddedApp
import ratpack.test.embed.EphemeralBaseDir
import spock.lang.Specification
import java.nio.file.Path
class EphemeralSpec extends Specification {
def 'can supply ephemeral basedir'() {
expect:
EphemeralBaseDir.tmpDir().use { baseDir ->
baseDir.write("mydir/.ratpack", "")
baseDir.write("mydir/assets/message.txt", "Hello Ratpack!")
Path mydir = baseDir.getRoot().resolve("mydir")
ClassLoader classLoader = new URLClassLoader((URL[]) [mydir.toUri().toURL()].toArray())
Thread.currentThread().setContextClassLoader(classLoader);
GroovyEmbeddedApp.of { serverSpec ->
serverSpec
.serverConfig { c -> c.baseDir(mydir) }
.handlers { chain ->
chain.files { f -> f.dir("assets") }
}
}.test {
String message = getText("message.txt")
assert "Hello Ratpack!" == message
}
}
}
}