Testing
The test harness, WebTest and TestRequest, is its own module, spider-silk-test, so the production jar carries no test code:
testImplementation 'net.benelog.spidersilk:spider-silk-test:1.1.0'
End to end, with WebTest
WebTest starts the app on a free port, hands you a client that keeps cookies, and stops it again, including when the test body throws:
@Test
void createsADeck() {
WebTest.test(app, client -> {
var created = client.postForm("/decks", Map.of("name", "English"));
assertThat(created.statusCode()).isEqualTo(302); // redirects are not followed
assertThat(client.get("/api/decks").body()).contains("English");
});
}
get/post/put/patch/delete/head/options, plus postForm and postJson/putJson/patchJson, all return the raw HttpResponse<String>, so assertions stay in whatever library the project already uses.
post, put, and patch send a raw body with no Content-Type.
postJson, putJson, and patchJson send Content-Type: application/json, and each takes a JsonValue tree or raw text, as jsonBody does.
A value the application writes through its own JsonWriter is sent as client.postJson(path, writer.write(value)).
send(builder → …) sends any request these methods do not cover, and send(builder → …, bodyHandler) reads the body some other way.
Reading it as bytes is what a compressed answer needs, since decoding gzipped bytes as text corrupts them.
The handler alone, with TestRequest
TestRequest builds the WebRequest for a handler method you call directly, so the test covers the handler itself rather than the routing that reaches it:
@Test
void createDeckRespondsWith201() {
JsonObject requestBody = Json.object().put("name", "Spanish");
WebResponse response = controller.createDeck(TestRequest.post("/api/decks")
.jsonBody(requestBody)
.build());
assertThat(response.status()).isEqualTo(HttpStatus.CREATED);
assertThat(deckService.getDeck(idFrom(response)).name()).isEqualTo("Spanish");
}
No port, no servlet container, and no mock library: queryParam, formParam, pathParam, header, cookie, body/jsonBody, file, secure(), remoteAddress(addr), and session(key, value) state what the request carries, and build() hands back a WebRequest.
TestRequest.session(key, value) puts an attribute in place before the handler runs, and the handler itself writes one with req.session().set(key, value).
The host that req.host() and req.scheme() report comes from the Host header, so header("Host", "shop.example.com:8080") is how a test states it.
jsonBody takes a JsonValue tree, a value with the JsonWriter the application already declares, or raw text.
Raw text is the form for a body no writer would produce, such as the malformed one a 400 test sends.
Path variables are supplied rather than matched, since no route is involved: pathParam("deckId", "3") is what the router would have resolved.
Everything a handler can tell apart still holds: a query parameter and a form field of the same name stay separate, header lookup ignores case, and req.file(…) on a request with no upload answers 400 the way a non-multipart request does.
The body is read the way it is behind a container: a second req.body() answers the text the first one kept, and req.bodyStream() after it throws IllegalStateException.
A TestRequest reads its body under the default body size limits, since it has no App to take other limits from.
The stub hands back one reader, already at its end on the second call, so a handler that reads the body twice through raw() fails the unit test rather than only failing in production.
formParam fields are the body as well, URL-encoded under application/x-www-form-urlencoded, so a form cannot also take body.
A body under that Content-Type is parsed into fields instead, as a container parses it, which is the shape a captured request is ported in.
An escape that will not decode in the body’s charset, such as %FF under UTF-8, is a 400 rather than U+FFFD, as Jetty answers it.
Form fields are read on POST, PUT, and PATCH, as on every server.
Reading a parameter consumes a form body as a container’s form parsing does, so req.bodyStream() after it is empty, and a form body read as bytes first leaves no fields.
file called twice with one name builds the field that carries several files, which req.files(name) reads back.
An empty file name is the input a browser sends when nothing was chosen, and req.fileOrNull(name) answers null for it.