CLOVER🍀

That was when it all began.

JettyのLocalConnector/HttpTester/ServletTesterで、Webアプリケーションの動作確認をする

Jettyに、LocalConnectorというConnectorがあることを知りまして。

LocalConnector (Jetty :: Project 9.4.8.v20171121 API)

こちらはテスト目的のConnectorで、こんな感じに使うそうな。

  HttpTester.Request request = HttpTester.newRequest();
  request.setURI("/some/resource");
  HttpTester.Response response = 
      HttpTester.parseResponse(HttpTester.from(localConnector.getResponse(request.generate())));

これ以上、ドキュメントに載っていない気も…。

LocalConnector?

LocalConnectorは、どうやらこういうもののようです。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-server/src/main/java/org/eclipse/jetty/server/LocalConnector.java

  • 実際にポートをリッスンするわけではない
  • あたかもHTTPリクエストを受け取った体で動作する
  • リクエストは、文字列やバイト配列などからHttpTesterを使って作成する

なので、単体テストあたりで使う目的のものですね。簡単にJettyに載せたアプリケーションを確認できそうな感じです。

まあ、使ったイメージを見た方が早いと思うので、サンプルを書いていってみましょう。

とりあえず

Jetty上で動かすServletを作りましょう。

まずは、Maven依存関係から。

        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-servlet</artifactId>
            <version>9.4.8.v20171121</version>
        </dependency>

最小構成のServletでいきます。

こんな感じで。
src/test/java/org/littlewings/embedded/jetty/TestServlet.java

package org.littlewings.embedded.jetty;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class TestServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        execute(request, response);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        execute(request, response);
    }

    void execute(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response
                .getWriter()
                .write("Hello " + Optional.ofNullable(request.getParameter("name")).orElse("Servlet") + "!!");
    }
}

テストコードで確認するので、テスト系のライブラリも足しておきます。

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.0.3</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.9.0</version>
            <scope>test</scope>
        </dependency>

LocalConnector+HttpTester

では、LocalConnectorを使って、作成したServletでデプロイしてテストしてみます。

使用するのはLocalConnectorとHttpTester。HttpTesterを使うには、次の依存関係が必要です。

        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-http</artifactId>
            <classifier>tests</classifier>
            <scope>test</scope>
            <version>9.4.8.v20171121</version>
        </dependency>

classifierが「tests」の「jetty-http」です。こちらに、HttpTesterが含まれています。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-http/src/test/java/org/eclipse/jetty/http/HttpTester.java

なお、LocalConnectorについては、「jetty-server」に含まれており、こちらは「jetty-servlet」などを依存関係に入れると
一緒に引き込まれます。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-server/src/main/java/org/eclipse/jetty/server/LocalConnector.java

作成してみたテストコードは、こちら。
src/test/java/org/littlewings/embedded/jetty/JettyLocalConnectorTest.java

package org.littlewings.embedded.jetty;

import java.nio.charset.StandardCharsets;

import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpTester;
import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.server.LocalConnector;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletHandler;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class JettyLocalConnectorTest {
    @Test
    public void servletTestHttpGet() throws Exception {
        Server server = new Server();
        LocalConnector connector = new LocalConnector(server);
        server.addConnector(connector);

        ServletHandler handler = new ServletHandler();
        handler.addServletWithMapping(TestServlet.class, "/test");

        server.setHandler(handler);

        server.start();

        HttpTester.Request request = HttpTester.newRequest();
        request.setHeader(HttpHeader.HOST.asString(), "localhost");
        request.setURI("/test?name=Jetty");

        HttpTester.Response response =
                HttpTester.parseResponse(HttpTester.from(connector.getResponse(request.generate())));

        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200);
        assertThat(
                new String(response.getContentBytes(), StandardCharsets.UTF_8)
        ).isEqualTo("Hello Jetty!!");

        server.stop();
    }

    @Test
    public void servletTestHttpPost() throws Exception {
        Server server = new Server();
        LocalConnector connector = new LocalConnector(server);
        server.addConnector(connector);

        ServletHandler handler = new ServletHandler();
        handler.addServletWithMapping(TestServlet.class, "/test");

        server.setHandler(handler);

        server.start();

        HttpTester.Request request = HttpTester.newRequest();
        request.setMethod(HttpMethod.POST.asString());
        request.setHeader(HttpHeader.HOST.asString(), "localhost");
        request.setHeader(HttpHeader.CONTENT_TYPE.asString(), MimeTypes.Type.FORM_ENCODED.asString());
        request.setURI("/test");
        request.setContent("name=Jetty".getBytes(StandardCharsets.UTF_8));

        HttpTester.Response response =
                HttpTester.parseResponse(HttpTester.from(connector.getResponse(request.generate())));

        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200);
        assertThat(
                new String(response.getContentBytes(), StandardCharsets.UTF_8)
        ).isEqualTo("Hello Jetty!!");

        server.stop();
    }
}

Jetty自身のテストコードを参考にして、書いてみました。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/PostServletTest.java

使い方は、最初にJettyを表すServerを作成しますが、この時にポート指定は必要ありません。このServerを使ってLocalConnectorを
作成して、ConnectorをServerに登録します。

        Server server = new Server();
        LocalConnector connector = new LocalConnector(server);
        server.addConnector(connector);

今回は簡単にServletを登録して

        ServletHandler handler = new ServletHandler();
        handler.addServletWithMapping(TestServlet.class, "/test");

        server.setHandler(handler);

Server#start。

        server.start();

Serverが起動したら、HTTPリクエストを作成します。HttpTester.Requestを作りましょう。

GETリクエストの場合は、最低限これくらいの設定が必要です。

        HttpTester.Request request = HttpTester.newRequest();
        request.setHeader(HttpHeader.HOST.asString(), "localhost");
        request.setURI("/test?name=Jetty");

HttpTester#newRequestで作成されるHttpTester.Requestには、GET、リクエストパスが「/」、HTTP 1.1を使うこと、くらいの
設定しかないので、Hostヘッダから指定してあげる必要があります。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-http/src/test/java/org/eclipse/jetty/http/HttpTester.java#L70-L77

POSTの場合は、こんな感じ。

        HttpTester.Request request = HttpTester.newRequest();
        request.setMethod(HttpMethod.POST.asString());
        request.setHeader(HttpHeader.HOST.asString(), "localhost");
        request.setHeader(HttpHeader.CONTENT_TYPE.asString(), MimeTypes.Type.FORM_ENCODED.asString());
        request.setURI("/test");
        request.setContent("name=Jetty".getBytes(StandardCharsets.UTF_8));

HTTPボディは、HttpTester.Request#setContentで指定します。今回はKey&Value形式ですが、Content-Typeは
「application/x-www-form-urlencoded」にしておく必要があります、と。

HttpTester.Requestの作り方は、他にもHTTPリクエストそのものを文字列やByteBuffer、InputStreamからパースして
作成することもできます。が、いずれもHTTPでリクエストする内容を直接意識するものなので、抽象度はどれを使っても
それほど変わらないかも…。

Jettyのテストでは、HTTPリクエストを文字列で組み立てています。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/PostServletTest.java#L122-L149

リクエストはLocalConnector#getResponseに渡せばOKです。これで今回のServletからのレスポンスを得られるのですが、
返ってくるのがByteBufferだったりStringだったりするので、パースする必要があります。これには、HttpTester#fromとHttpTester#parseを
使うことになります。

つまり、こうなる、と。

        HttpTester.Response response =
                HttpTester.parseResponse(HttpTester.from(connector.getResponse(request.generate())));

LocalConnectorに直接リクエストを投げ込むんですよ、リッスン先がないからそうなののか…と。この時、HttpTester.Request#generateで、ここまで
構築したHTTPリクエストをByteBufferで渡しています。

結果の確認。

        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200);
        assertThat(
                new String(response.getContentBytes(), StandardCharsets.UTF_8)
        ).isEqualTo("Hello Jetty!!");

終わったら、Server#stop。

        server.stop();

こんな感じの使い方です。

なお、ここのLocalConnector#getResponseの部分ですが

        HttpTester.Response response =
                HttpTester.parseResponse(HttpTester.from(connector.getResponse(request.generate())));

内部的にはリクエストをQueueに入れ、それからサーバー側で処理した結果が入るか、指定時間まで待って終わらなかったら終了するようになっています。
今回のJettyのバージョン、「9.4.8.v20171121」だとなにも指定しないとサーバー側の応答を最大10秒待ちます。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-server/src/main/java/org/eclipse/jetty/server/LocalConnector.java#L224

これを調整するには、LocalConnector#getResponseに明示的に最大の待ち時間を指定します。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-server/src/main/java/org/eclipse/jetty/server/LocalConnector.java#L234

レスポンスがすぐ戻ってくればよいのですが、そうでない場合はしばらく待つことになるので、覚えておきましょう。

ServletTester

とまあ、こんな感じにServer+LocalConnectorを使うのですが、これをもうちょっと簡略したものにServletTesterがあります。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/ServletTester.java

ServletTesterは、「jetty-servlet」の「tests」classifierに含まれています。

        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-servlet</artifactId>
            <classifier>tests</classifier>
            <scope>test</scope>
            <version>9.4.8.v20171121</version>
        </dependency>

中身は、ServerとLocalConnectorを内包したものになります。

まあ、気持ちコードが短くなる程度ですね。
src/test/java/org/littlewings/embedded/jetty/JettyServletTest.java

package org.littlewings.embedded.jetty;

import java.nio.charset.StandardCharsets;

import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpTester;
import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletTester;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class JettyServletTest {
    @Test
    public void servletTestHttpGet() throws Exception {
        ServletTester tester = new ServletTester();

        ServletHandler handler = new ServletHandler();
        handler.addServletWithMapping(TestServlet.class, "/test");

        tester.getServer().setHandler(handler);

        tester.start();

        HttpTester.Request request = HttpTester.newRequest();
        request.setHeader(HttpHeader.HOST.asString(), "localhost");
        request.setURI("/test?name=Jetty");

        HttpTester.Response response =
                HttpTester.parseResponse(HttpTester.from(tester.getResponses(request.generate())));

        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200);
        assertThat(
                new String(response.getContentBytes(), StandardCharsets.UTF_8)
        ).isEqualTo("Hello Jetty!!");

        tester.stop();
    }

    @Test
    public void servletTestHttpPost() throws Exception {
        ServletTester tester = new ServletTester();

        ServletHandler handler = new ServletHandler();
        handler.addServletWithMapping(TestServlet.class, "/test");

        tester.getServer().setHandler(handler);

        tester.start();

        HttpTester.Request request = HttpTester.newRequest();
        request.setMethod(HttpMethod.POST.asString());
        request.setHeader(HttpHeader.HOST.asString(), "localhost");
        request.setHeader(HttpHeader.CONTENT_TYPE.asString(), MimeTypes.Type.FORM_ENCODED.asString());
        request.setURI("/test");
        request.setContent("name=Jetty".getBytes(StandardCharsets.UTF_8));

        HttpTester.Response response =
                HttpTester.parseResponse(HttpTester.from(tester.getResponses(request.generate())));

        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200);
        assertThat(
                new String(response.getContentBytes(), StandardCharsets.UTF_8)
        ).isEqualTo("Hello Jetty!!");

        tester.stop();
    }
}

コード上でServerとLocalConnectorが登場しなくなりますが、ServerもLocalConnectorもそれぞれ取得することはできます。

        ServletTester tester = new ServletTester();

        ServletHandler handler = new ServletHandler();
        handler.addServletWithMapping(TestServlet.class, "/test");

        tester.getServer().setHandler(handler);

startするのは、ServletTesterになります、と。

        tester.start();

stopするのも、ServletTesterのみです。

        tester.stop();

リクエストを投げ込むには、ServletTester#getResponses(複数形)を使います。それ以外のイメージは、LocalConnectorの時と同じです。

        HttpTester.Request request = HttpTester.newRequest();
        request.setHeader(HttpHeader.HOST.asString(), "localhost");
        request.setURI("/test?name=Jetty");

        HttpTester.Response response =
                HttpTester.parseResponse(HttpTester.from(tester.getResponses(request.generate())));

先のServer+LocalConnectorを使ったコードを見ておくと、そう違和感ない感じでしょう。

なお、ServletTesterには、ふつうのServerConnectorを作成するようにすることもできます。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/ServletTester.java#L235-L249

使うのかな…?

ServletTesterの存在自体は、Jettyのテストコードを見ていて気付きました。
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/tests/test-webapps/test-jetty-webapp/src/test/java/org/eclipse/jetty/DispatchServletTest.java
https://github.com/eclipse/jetty.project/blob/jetty-9.4.8.v20171121/jetty-servlets/src/test/java/org/eclipse/jetty/servlets/HeaderFilterTest.java

速度?

実行速度はこんな感じ。

コンパイル済みの「mvn test」の結果。

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running org.littlewings.embedded.jetty.JettyLocalConnectorTest
2018-02-18 13:22:12.824:INFO::main: Logging initialized @428ms to org.eclipse.jetty.util.log.StdErrLog
2018-02-18 13:22:12.928:INFO:oejs.Server:main: jetty-9.4.8.v20171121, build timestamp: 2017-11-22T06:27:37+09:00, git hash: 82b8fb23f757335bb3329d540ce37a2a2615f0a8
2018-02-18 13:22:12.956:INFO:oejs.AbstractConnector:main: Started LocalConnector@78ac1102{HTTP/1.1,[http/1.1]}
2018-02-18 13:22:12.957:INFO:oejs.Server:main: Started @561ms
2018-02-18 13:22:13.090:INFO:oejs.AbstractConnector:main: Stopped LocalConnector@78ac1102{HTTP/1.1,[http/1.1]}
2018-02-18 13:22:13.098:INFO:oejs.Server:main: jetty-9.4.8.v20171121, build timestamp: 2017-11-22T06:27:37+09:00, git hash: 82b8fb23f757335bb3329d540ce37a2a2615f0a8
2018-02-18 13:22:13.100:INFO:oejs.AbstractConnector:main: Started LocalConnector@55a561cf{HTTP/1.1,[http/1.1]}
2018-02-18 13:22:13.101:INFO:oejs.Server:main: Started @705ms
2018-02-18 13:22:13.103:INFO:oejs.AbstractConnector:main: Stopped LocalConnector@55a561cf{HTTP/1.1,[http/1.1]}
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.314 sec - in org.littlewings.embedded.jetty.JettyLocalConnectorTest
Running org.littlewings.embedded.jetty.JettyServletTest
2018-02-18 13:22:13.137:INFO:oejs.Server:main: jetty-9.4.8.v20171121, build timestamp: 2017-11-22T06:27:37+09:00, git hash: 82b8fb23f757335bb3329d540ce37a2a2615f0a8
2018-02-18 13:22:13.139:INFO:oejs.AbstractConnector:main: Started LocalConnector@27ae2fd0{HTTP/1.1,[http/1.1]}
2018-02-18 13:22:13.140:INFO:oejs.Server:main: Started @744ms
2018-02-18 13:22:13.143:INFO:oejs.AbstractConnector:main: Stopped LocalConnector@27ae2fd0{HTTP/1.1,[http/1.1]}
2018-02-18 13:22:13.146:INFO:oejs.Server:main: jetty-9.4.8.v20171121, build timestamp: 2017-11-22T06:27:37+09:00, git hash: 82b8fb23f757335bb3329d540ce37a2a2615f0a8
2018-02-18 13:22:13.149:INFO:oejs.AbstractConnector:main: Started LocalConnector@7a4ccb53{HTTP/1.1,[http/1.1]}
2018-02-18 13:22:13.149:INFO:oejs.Server:main: Started @754ms
2018-02-18 13:22:13.151:INFO:oejs.AbstractConnector:main: Stopped LocalConnector@7a4ccb53{HTTP/1.1,[http/1.1]}
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.022 sec - in org.littlewings.embedded.jetty.JettyServletTest

Results :

Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

もうちょっと重たいJetty構成(jetty-webappとか)でやったらよかったかも、と後で思いました…。

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.314 sec - in org.littlewings.embedded.jetty.JettyLocalConnectorTest
Running org.littlewings.embedded.jetty.JettyServletTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.022 sec - in org.littlewings.embedded.jetty.JettyServletTest

JSPとか入ると、一気に重くなるみたいですしねぇ…。