JavaFX + JUnit で JavaScript のユニットテストをする

(追記) コードが間違っていたので、次の日に修正版を書きました。


今日はちょっとマニアックなネタです。

というのも、@mike_neck が悩んでいるようだったので...

JavaFX には WebView というブラウザーのコントロールがあります。それを使って JavaScript のテストをしてしまおうというのが今日のお題です。

ただ、問題は @mike_neck も悩んでいるように、スレッドの問題です。

JUnitJUnit のスレッドで動作し、JavaFXJavaFX のイベントディスパッチスレッド (EDT) で動作します。

問題はその間をどうやって取り持ってあげるかです。

JUnit は自身のスレッド @Before のメソッドを実行して、@Test のメソッドを実行していきます。つまり、主導権を握っているのは JUnit であって、テストされる側が自分で JUnit のスレッドをコントロールすることはできません。

一方の JavaFX は Application.launch メソッドで EDT を起動すると、後は EDT が start メソッドをコールし、イベント駆動でよしなにやってくれます。

でも、テストをするためには JUnit のスレッドから JavaFX のスレッドをコントロールしなくてはいけません。

じゃあ、どうするか。JavaFX の EDT から JUnit のスレッドにアクセスすることはできそうにないので、JUnit のスレッドから JavaFX の EDT に アクセスするようにします。

そのために使用するのが Platforrm.runLater メソッドです。

なんか聞いたことがあるようなメソッド名ではないですか。Swing 知っている人は分かると思うのですが、SwingUtilities.invokeLater メソッドの JavaFX 版なのです。

runLater メソッドの引数には Runnable オブジェクトを記述します。すると、Runnable インタフェースの run メソッドが EDT で実行されます。

この runLater メソッドを使って JUnit のスレッドから JavaFX の EDT にアクセスします。しかし、runLater メソッドの引数は Runnable インタフェースだというのが問題です。

つまり、引数も戻り値もないのです。

そこで、どうするか。ここではブロッキングキューを使用しました。引数用のブロッキングキュート、戻り値用のブロッキングキューを用意し、それらを経由してスレッド間の値の引き渡しを行っています。

そういえば、ちょっと注意すべき所に、Application.launch メソッドがブロックするメソッドだということです。なので、不用意に JUnit のスレッドで Application.launch メソッドをコールしてしまうと、次に進まなくなってしまいます。

今回、テストするのは次の JavaScript です (mike_neck が使っているものそのままです)。ファイル名は test.js となっています。

function numberTest(arg) {
    return 1 + arg;
}

function stringTest(arg) {
    return arg + "_" + "test";
}

function objectTest(arg) {
    arg.test = 'value';
    return arg;
}

(function() {
    var element = document.getElementById('loaded');
    element.innerText = 'loaded';
})();

この test.js を読み込んでいるのが index.html です。

<!DOCTYPE HTML>
<html lang="ja">
<head>
    <title>test page</title>
</head>
<body>
<h1 id="title">title</h1>
<p id="test">test</p>
<p id="loaded"></p>
<script lang="javascript" type="text/javascript" src="test.js"></script>
</body>
</html>

この index.html を JavaFX で読み込んでユニットテストを行います。

では、順々に解説していきます。まずテストのクラスです。

public class JSTest {
    private DummyApplication dummy;
    private ExecutorService service;
    
    public JSTest() {}

    @Before
    public void setUp() throws Exception {
        service = Executors.newFixedThreadPool(1);
        service.submit(new Runnable() {
            @Override
            public void run() {
                Application.launch(test.DummyApplication.class);
            }
        });
        
        Thread.sleep(1000L);
        
        dummy = DummyApplication.getInstance();
    }

まず、JavaFX の EDT を起動しなくてはいけないので、@Before のメソッドで行うことにします。

(追記) @Before ではなくて、@BeforeClass です。詳細はこちらにあります。


前述したように Application.launch メソッドはブロックしてしまうので、JUnit のスレッドとは別のスレッドで起動します。

ここでは Concurrency Utils の ExecutorService インタフェースを使いましたけど、昔ながらの Thread クラスと Runnable インタフェースで書いてもかまいません。

JavaFX では必ず Application クラスのサブクラスを作成して、Application.launch メソッドに渡す必要があります。そこで、ここではダミーのクラスである DummyApplication クラスを作成しました。

(ちなみに、launch メソッドの引数にクラスクラスを指定しない場合は、launch メソッドを記述したクラスを使用するようになっています。)

これで JavaFX の EDT は起動できたのですが、問題は EDT を扱うために Application オブジェクトを取得しておかなくてはいけないということです。

ところが、Application オブジェクトを生成するのは EDT なので、そのままだと取得できないのです。そこでオブジェクトを取得するため、シングルトンのように getInstance メソッドを定義しました。とはいっても、シングルトンではないですが。

DummyApplication クラスの該当部分を以下に示しておきます。

public class DummyApplication extends Application {
    private static DummyApplication instance;
    
    private WebEngine engine;
    
    @Override
    public void start(Stage stage) throws Exception {
        instance = this;

        engine = new WebEngine();
        engine.load( [index.htmlのURL] );
    }
    
    public static DummyApplication getInstance() {
        return instance;
    }

JavaFX では start メソッドでシーングラフを作成するのですが、ここでは表示する必要はないので HTML ファイルをパースする WebEngine オブジェクトだけをロードしてあります。

そういえば、WebEngine クラスって、WebView クラスとペアで使わなくてはいけないと思っている人が多いようですが、単独でも使えるんです。

では、本命のユニットテストです。

ここでは、test.js の numberTest 関数をテストするメソッドを作成しました。

    @Test
    public void numberTest() throws InterruptedException {
        Integer result = (Integer)dummy.numberTest(new Integer(3));
        assertThat(result, is(new Integer(4)));

        result = (Integer)dummy.numberTest("23");
        assertThat(result, is(new Integer(24)));
    }

実際に numberTest 関数を実行しているのは DummyApplication クラスです。

こうやって見ると、ごくごく普通のテストメソッドのようですね。

では DummyApplication クラスの numberTest メソッドを見てみましょう。

    private BlockingQueue<Object> arguments = new LinkedBlockingQueue<>(); 
    private BlockingQueue<Object> results = new LinkedBlockingQueue<>(); 

    public Object numberTest(Object obj) throws InterruptedException {
        arguments.put(obj);        

        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                try {
                    Object obj = arguments.take();
                    Object result = (Integer)engine.executeScript("numberTest("+obj+");");
                    results.put(result);
                } catch (InterruptedException ex) {}
            }
        });
            
        return results.take();
    }

前述したように JUnit のスレッドと JavaFX の EDT 間の値の引き渡しにはブロッキングキューを使用します。ここでは引数用の arguments 変数、戻り値用に results 変数を使用しています。

numberTest メソッドでは、まずブロッキングキューの arguments に引数をエンキューしています。

その後に Platform.runLater メソッドです。

run メソッドでは arguments から引数をデキューします。ブロッキングキューはその名の通り、キューに要素がなければ take メソッドがブロックします。これで、スレッド間の同期を図ることができます。

arguments から取り出した引数を使用して、WebEngine クラスの executeScript メソッドをコールします。executeScript メソッドが HTML に含まれるスクリプトを実行するためのメソッドになります。

executeScript の戻り値は、今度は results にエンキューします。そして、JUnit のスレッドにもどって results からデキューし、それをメソッドの戻り値として返します。

ここでは簡単化のために戻り値が null の場合を扱っていないのですが、本格的にテストする場合は考慮しなくてはダメですね。

これで、後は JUnit 側で assertThat メソッドで検証すればいいということになります。

ここまではやったので、Jetty を組み合わせる部分などはまかせた > @mike_neck

最後に今回使用したテストクラスの JSTest クラスと DummyApplication クラスの全体を示しておきます。

まず、JSTest クラスです。

package test;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javafx.application.Application;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.assertThat;
import static org.hamcrest.CoreMatchers.is;

public class JSTest {
    private DummyApplication dummy;
    private ExecutorService service;
    
    public JSTest() {}

    @Before
    public void setUp() throws Exception {
        service = Executors.newFixedThreadPool(1);
        service.submit(new Runnable() {
            @Override
            public void run() {
                Application.launch(test.DummyApplication.class);
            }
        });
        
        Thread.sleep(1000L);
        
        dummy = DummyApplication.getInstance();
    }

    @After
    public void tearDown() {
        service.shutdown();
        dummy.shutdown();
    }

    @Test
    public void numberTest() throws InterruptedException {
        Integer result = (Integer)dummy.numberTest(new Integer(3));
        assertThat(result, is(new Integer(4)));

        result = (Integer)dummy.numberTest("23");
        assertThat(result, is(new Integer(24)));
    }
}

次に DummyApplication クラスです。

package test;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.web.WebEngine;
import javafx.stage.Stage;

public class DummyApplication extends Application {
    private static DummyApplication instance;
    
    private WebEngine engine;
    private BlockingQueue<Object> arguments = new LinkedBlockingQueue<>(); 
    private BlockingQueue<Object> results = new LinkedBlockingQueue<>(); 
    
    @Override
    public void start(Stage stage) throws Exception {
        instance = this;

        engine = new WebEngine( [index.htmlのURL] );
        engine.load();
    }
    
    public static DummyApplication getInstance() {
        return instance;
    }
    
    public void shutdown() {
        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                Platform.exit();
            }
        });
    }

    public Object numberTest(Object obj) throws InterruptedException {
        arguments.put(obj);        

        Platform.runLater(new Runnable() {
            @Override
            public void run() {
                try {
                    Object obj = arguments.take();
                    Object result = (Integer)engine.executeScript("numberTest("+obj+");");
                    results.put(result);
                } catch (InterruptedException ex) {}
            }
        });
            
        return results.take();
    }
}