테스트
테스트 하니스인 WebTest와 TestRequest는 별도의 모듈 spider-silk-test이므로, 프로덕션 jar에는 테스트 코드가 실리지 않는다:
testImplementation 'net.benelog.spidersilk:spider-silk-test:1.1.0'
WebTest로 하는 종단 간 테스트
WebTest는 빈 포트에서 앱을 시작하고, 쿠키를 유지하는 클라이언트를 건네주고, 테스트 본문이 예외를 던지는 경우를 포함해 앱을 다시 멈춘다:
@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와 postForm, postJson/putJson/patchJson은 모두 원시 HttpResponse<String>을 반환하므로, 단언은 프로젝트가 이미 쓰는 라이브러리에 그대로 머문다.
post, put, patch는 Content-Type 없이 원시 본문을 보낸다.
postJson, putJson, patchJson은 Content-Type: application/json을 보내며, jsonBody처럼 각각 JsonValue 트리나 원시 텍스트를 받는다.
애플리케이션이 자신의 JsonWriter로 쓰는 값은 client.postJson(path, writer.write(value))로 보낸다.
이 메서드들이 다루지 않는 요청은 send(builder → …)로 보내며, send(builder → …, bodyHandler)는 본문을 다른 방식으로 읽는다.
바이트로 읽는 것은 압축된 응답에 필요한데, gzip된 바이트를 텍스트로 디코딩하면 망가지기 때문이다.
TestRequest로 하는 핸들러 단독 테스트
TestRequest는 직접 호출할 핸들러 메서드에 넘길 WebRequest를 만들므로, 테스트는 핸들러에 도달하는 라우팅이 아니라 핸들러 자체를 검증한다:
@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");
}
포트도, 서블릿 컨테이너도, 목 라이브러리도 없다: queryParam, formParam, pathParam, header, cookie, body/jsonBody, file, secure(), remoteAddress(addr), session(key, value)가 요청이 담는 것을 서술하고, build()가 WebRequest를 돌려준다.
TestRequest.session(key, value)는 핸들러가 실행되기 전에 속성을 미리 두고, 핸들러 자신은 req.session().set(key, value)로 속성을 쓴다.
req.host()와 req.scheme()이 보고하는 호스트는 Host 헤더에서 오므로, 테스트는 header("Host", "shop.example.com:8080")으로 이를 서술한다.
jsonBody는 JsonValue 트리, 애플리케이션이 이미 선언한 JsonWriter를 가진 값, 또는 원시 텍스트를 받는다.
원시 텍스트는 어떤 라이터도 만들어 내지 않을 본문, 예컨대 400 테스트가 보내는 잘못된 본문을 위한 형태다.
라우트가 관여하지 않으므로 경로 변수는 매칭되지 않고 직접 지정된다: pathParam("deckId", "3")은 라우터가 해석했을 값이다.
핸들러가 구별할 수 있는 모든 것은 여전히 유지된다: 같은 이름의 쿼리 파라미터와 폼 필드는 따로 남고, 헤더 조회는 대소문자를 무시하며, 업로드가 없는 요청에서 req.file(…)은 멀티파트가 아닌 요청과 같은 방식으로 400으로 응답한다.
본문은 컨테이너 뒤에서와 같은 방식으로 읽힌다: 두 번째 req.body()는 첫 번째 호출이 보관한 텍스트를 돌려주고, 그 뒤의 req.bodyStream()은 IllegalStateException을 던진다.
TestRequest는 다른 한도를 가져올 App이 없으므로, 기본 본문 크기 한도 안에서 본문을 읽는다.
스텁은 리더 하나를 돌려주며 두 번째 호출에서는 이미 끝에 있으므로, raw()를 통해 본문을 두 번 읽는 핸들러는 프로덕션에서만 실패하는 대신 단위 테스트에서 실패한다.
formParam 필드도 본문이며, application/x-www-form-urlencoded로 URL 인코딩되므로, 폼은 body를 함께 받을 수 없다.
반대로 그 Content-Type 아래의 body는 컨테이너가 파싱하듯 필드로 파싱되며, 캡처한 요청을 옮겨 올 때의 형태가 이것이다.
UTF-8 아래의 %FF처럼 본문의 문자셋으로 디코딩되지 않는 이스케이프는 Jetty가 답하듯 U+FFFD가 아니라 400이다.
폼 필드는 모든 서버에서처럼 POST, PUT, PATCH에서 읽힌다.
파라미터를 읽으면 컨테이너의 폼 파싱처럼 폼 본문이 소비되므로, 그 뒤의 req.bodyStream()은 비어 있고, 폼 본문을 먼저 바이트로 읽으면 필드가 남지 않는다.
같은 이름으로 file을 두 번 호출하면 여러 파일을 담은 필드가 만들어지며, req.files(name)이 이를 다시 읽는다.
빈 파일 이름은 브라우저가 아무 파일도 고르지 않았을 때 보내는 입력이며, req.fileOrNull(name)은 이에 null로 답한다.