新世代のビルドツール Bazel で Java プロジェクトのビルドからユニットテストまで

この記事はアプレッソ Advent Calendar 2016 19日目のエントリーです。

新世代のビルドツール Bazel を使って、Java プロジェクトをビルドしてみたいと思います。

Bazel とは?

Bazel とは、Google 社内で使われているビルドツール Blaze をオープンソース化したものです。
2016.12 現在 Beta 版として公開されており、2017.4Q 公開予定の Stable 版へ向けて着々と開発が進んでおります。

Bazel の特徴

公式ページ によると、Speed、Scalability、Flexibility、Correctness、Reliability、Repeatability という6つの軸で語られています。

  • 最適化された依存性分析、高度なキャッシング、ビルドアクションの並列化による Speed
  • ヤン○ーディー○ルばりに「小さなものから大きなものまで」カバーできる Scalability
  • さまざまな言語やプラットフォームをカバーし、拡張可能なルールフレームワークから来る Flexibility
  • タイムスタンプだけでなく依存関係グラフを調べてビルドタイミングを決定する Correctness
  • Google のエンジニアリング環境で長年に渡って洗練されてきた Reliability
  • 明示的に宣言されている入力ファイルのみを使用し、必要最小限のファイルのみを含むサンドボックス環境で実行されることによる Repeatability

Java で作られてはいますが、ビルド対象の言語は Java に限らず、C/C++Objective-CPythonC#、Go、Scala など様々な言語を組み込みでサポートしており、AndroidiOS を含むクライアントサイドから AppEngine などのサーバサイドまで広範囲なプラットフォームに対応する方針が取られています。

また、独自のビルドルールを開発可能なフレームワークも用意されているようで、拡張性も問題なさそうです。

ビルド環境の構築

能書きはこのくらいにして、さっそく試してみましょう。まずはビルド環境を構築してみます。
Beta 版時点で Bazel がサポートしているビルドマシン環境は以下になります。

ソースからコンパイルすることで上記以外の環境でも使えるっぽいです。
今回は AWS 上に構築した Ubuntu で環境を作ってみます。Ubuntu 自体の構築はマネジメントコンソールからポチポチやるだけなので省略します。

JDK 8 のインストール

Bazel を使用するためには、JDK 8 のインストールが必要になります。JDK 7 でもイチオー使えるらしいですが、非推奨になっています。

Ubuntu 上で以下のコマンドを実行します。

$ sudo add-apt-repository ppa:webupd8team/java
$ sudo apt-get update
$ sudo apt-get install oracle-java8-installer
$ java -version
java version "1.8.0_111"
Java(TM) SE Runtime Environment (build 1.8.0_111-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.111-b14, mixed mode)

これで /usr/bin/java が作られるので、環境変数の追加は不要です。
ってゆーか apt-get install で入れると /etc/profile.d/jdk.sh まで作ってくれるんですね。rpm もこれやってくれませんかね。。。

Bazel のインストール

続いて Bazel 自体のインストールです。次のコマンドを実行します。

$ echo "deb [arch=amd64] http://storage.googleapis.com/bazel-apt stable jdk1.8" | sudo tee /etc/apt/sources.list.d/bazel.list
$ curl https://bazel.build/bazel-release.pub.gpg | sudo apt-key add -
$ sudo apt-get update && sudo apt-get install bazel
$ sudo apt-get upgrade bazel
$ bazel version
Build label: 0.4.2
Build target: bazel-out/local-fastbuild/bin/src/main/java/com/google/devtools/build/lib/bazel/BazelServer_deploy.jar
Build time: Wed Dec 7 18:47:11 2016 (1481136431)
Build timestamp: 1481136431
Build timestamp as int: 1481136431

こちらも /usr/bin/bazel まで作ってくれるので環境変数の追加は不要です。
これで Bazel を試してみる環境が出来上がりました。簡単ですね!

Java プロジェクトのビルド ~基本編~

それでははりきって、Java プロジェクトをビルドしてみましょう。

構成はこんな感じです。

f:id:willard379:20161219001517p:plain

Java ソースコード

HelloBazel.java

package willard379.bazel.sample;

public class HelloBazel {

    public static void main(String[] args) {
        if (args.length > 0) {
            System.out.println(String.format("Hello, %s!", args[0]));
        }
    }
}

なんの変哲もない、はろ~わ~るどです。

WORKSPACE ファイル

ここからが Bazel 固有の設定になります。

Bazel でビルドをするためには、ビルド対象の資材が含まれるトップディレクトリに WORKSPACE という名前のファイルを作る必要があります。 Bazel に対して「ここがルートディレクトリやで!」と教えてあげるわけですね。

後で出てくる依存性の追加などはこのファイルに設定することになります。現時点では特に設定するものがないので、空ファイルを置いておきます。たとえ設定するものがなくてもファイル自体は存在している必要があるのでご注意を。

WORKSPACE

(空ファイル)
BUILD ファイル

ルートディレクトリにもうひとつ、BUILD という名前のファイルを作成します。これは Bazel に行わせるビルドの命令をを記述するファイルで、Python に似た DSL でルールと呼ばれるビルドシーケンスを記述します。詳しくはこちら を参照。

BUILD

java_binary(
    name = "HelloBazel",
    srcs = glob(["src/main/java/**/*.java",]),
    main_class = "willard379.bazel.sample.HelloBazel",
)

main() メソッドを持つ Java アプリケーションをビルドするためのルールは、 java_binary() になります。

name 属性で任意の名前を定義します。後々ここで定義した名前を使って Bazel のビルドコマンドを叩いたりするので、アプリケーションの一意な識別子という認識でよいでしょう。
srcs 属性では、ビルド対象のソースファイルを指定します。glob() 関数を使うことで、パターンにマッチするファイルを一括で指定することができます。
main_class 属性では、 main() メソッドを持つ Java クラスを FQCN で指定します。これが後述の bazel run コマンドから呼び出されるエントリポイントになります。

ビルドの実行

これで最低限ビルドが実行できる準備が整いましたので、ビルドを実行してみましょう。 Ubuntu で WORKSPACE ファイルが置かれているルートディレクトリに移動し、以下のコマンドを実行します。

$ bazel build //:HelloBazel
INFO: Found 1 target...
Target //:HelloBazel up-to-date:
  bazel-bin/HelloBazel.jar
  bazel-bin/HelloBazel
INFO: Elapsed time: 9.064s, Critical Path: 1.05s

ビルドの指示は bazel build コマンドで行います。"//:HelloBazel" の部分は、「"//" + BUILD ファイルが配置されたディレクトリへのパス + ":" + java_binary()name で指定した識別子」になります。
今回、BUILD ファイルは WORKSPACE ファイルと同じ階層に置かれているので、"//" の後にすぐ ":" がきますが、マルチプロジェクト構成の場合など BUILD ファイルを複数作る場合には、ルートディレクトリから BUILD ファイルが配置されているディレクトリへの相対パスを指定します。
ちなみに今回のように WORKSPACE ファイルと BUILD ファイルが同じ階層に置かれている場合には "//:" を省略することもできます。

BUILD SUCCESSFUL 的なメッセージがほしいところですが、エラーが出ていないのでビルドが成功したようです。 ビルドの成果物を見てみましょう。

$ ll
total 52
drwxrwxr-x 4 ubuntu ubuntu 4096 Dec 18 12:09 ./
drwxrwxr-x 5 ubuntu ubuntu 4096 Dec 18 04:15 ../
lrwxrwxrwx 1 ubuntu ubuntu  122 Dec 18 12:09 bazel-bin -> /home/ubuntu/.cache/bazel/_bazel_ubuntu/9ec7fed1ae4ad1d75540085b131e2506/execroot/HelloBazel/bazel-out/local-fastbuild/bin/
lrwxrwxrwx 1 ubuntu ubuntu  127 Dec 18 12:09 bazel-genfiles -> /home/ubuntu/.cache/bazel/_bazel_ubuntu/9ec7fed1ae4ad1d75540085b131e2506/execroot/HelloBazel/bazel-out/local-fastbuild/genfiles/
lrwxrwxrwx 1 ubuntu ubuntu   90 Dec 18 12:09 bazel-HelloBazel -> /home/ubuntu/.cache/bazel/_bazel_ubuntu/9ec7fed1ae4ad1d75540085b131e2506/execroot/__main__/
lrwxrwxrwx 1 ubuntu ubuntu  100 Dec 18 12:09 bazel-out -> /home/ubuntu/.cache/bazel/_bazel_ubuntu/9ec7fed1ae4ad1d75540085b131e2506/execroot/__main__/bazel-out/
lrwxrwxrwx 1 ubuntu ubuntu  127 Dec 18 12:09 bazel-testlogs -> /home/ubuntu/.cache/bazel/_bazel_ubuntu/9ec7fed1ae4ad1d75540085b131e2506/execroot/HelloBazel/bazel-out/local-fastbuild/testlogs/
-rw-rw-r-- 1 ubuntu ubuntu  132 Dec 18 12:06 BUILD
-rw-rw-r-- 1 ubuntu ubuntu  236 Dec 18 12:06 .classpath
drwxrwxr-x 8 ubuntu ubuntu 4096 Dec 18 12:07 .git/
-rw-rw-r-- 1 ubuntu ubuntu  195 Dec 18 12:06 .gitignore
-rw-rw-r-- 1 ubuntu ubuntu  369 Dec 18 12:06 .project
drwxrwxr-x 3 ubuntu ubuntu 4096 Dec 18 12:06 src/
-rw-rw-r-- 1 ubuntu ubuntu    0 Dec 18 12:06 WORKSPACE

シンボリックリンクがいくつかできています。Bazel では、ビルドの成果物はワークスペースの下ではなく、~/.cache/bazel の下に出力されます。jar ファイルは bazel-bin シンボリックリンクの先にあります。

$ ll bazel-bin/
total 160
drwxrwxr-x 4 ubuntu ubuntu   4096 Dec 18 12:22 ./
drwxrwxr-x 5 ubuntu ubuntu   4096 Dec 18 12:22 ../
-r-xr-xr-x 1 ubuntu ubuntu   8175 Dec 18 12:22 HelloBazel*
-r-xr-xr-x 1 ubuntu ubuntu   1173 Dec 18 12:22 HelloBazel.jar*
-r-xr-xr-x 1 ubuntu ubuntu    990 Dec 18 12:22 HelloBazel.jar-2.params*
-r-xr-xr-x 1 ubuntu ubuntu     96 Dec 18 12:22 HelloBazel.jar_manifest_proto*
-r-xr-xr-x 1 ubuntu ubuntu     42 Dec 18 12:22 HelloBazel.jdeps*
drwxrwxr-x 4 ubuntu ubuntu   4096 Dec 18 12:22 HelloBazel.runfiles/
-r-xr-xr-x 1 ubuntu ubuntu 121528 Dec 18 12:22 HelloBazel.runfiles_manifest*
drwxrwxr-x 3 ubuntu ubuntu   4096 Dec 18 12:22 _javac/
動作確認

java_binary() ルールでビルドした Java アプリケーションを実行する方法はいくつかあります。

  • bazel run コマンドで実行する
  • bazel build で作成される、bazel-bin/HelloBazel スクリプトを実行する
  • java コマンドで実行する

いずれも実行結果は変わりません(たぶん)。ここではもっとも簡単な bazel run コマンドで実行してみましょう。

$ bazel run HelloBazel willard379
INFO: Found 1 target...
Target //:HelloBazel up-to-date:
  bazel-bin/HelloBazel.jar
  bazel-bin/HelloBazel
INFO: Elapsed time: 0.204s, Critical Path: 0.02s

INFO: Running command line: bazel-bin/HelloBazel willard379
Hello, willard379!

上で少し説明しましたが、"HelloBazel" は "//:HelloBazel" を短縮したものです。その後に続く "willard379" は、Javamain() メソッドに渡すコマンドライン引数です。 最後の行に main() メソッドの実行結果 "Hello, willard379!" が出力されていますね。このように java_binary() ルールでビルドしたものは bazel run コマンドで起動することができます。

次は、Maven リポジトリ上に上がっているライブラリを使った依存性の追加をやってみましょう。

Java プロジェクトのビルド ~依存性の追加編~

Java ソースコード

HelloBazel.java

package willard379.bazel.sample;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;

public class HelloBazel {

    public static void main(String[] args) {
        String name = (ArrayUtils.isNotEmpty(args) || StringUtils.isNotEmpty(args[0])) ? args[0] : "Bazel";
        System.out.println(String.format("Hello, %s!", name));
    }
}

import 文に commons-lang の ArrayUtils と StringUtils を追加しています。 これらを使ってコマンドライン引数の存在チェックを行い、省略されていなければ指定された文字列を、省略されていたらデフォルト値 "Bazel" を使ってはろ~わ~るどするように変更しました。

WORKSPACE ファイル

WORKSPACE

maven_jar(
    name = "commons_lang3",
    artifact = "org.apache.commons:commons-lang3:3.5",
)

WORKSPACE ファイルには、 Maven リポジトリ上の commons-lang3 への依存性を追加しています。
name 属性はこの依存性への識別子で、ワークスペース内でごっちゃにならなければ何でもよいです(たぶん)。
それなら artifactId をそのまま使えば良いっしょ!と考えてしまいますが、そこには大きな落とし穴があります(はいここ重要ですよ)。なんと maven_jar()name 属性にはハイフンが使えないのです! なので本来ならば commons-lang3 と書きたいところですが、ハイフンの代わりにアンダーバーを使って commons_lang3 としています。ここで指定した識別子は後で BUILD ファイルの方で使います。
artifact 属性には "<groupId>:<artifactId>:<version>" の書式、つまり Gradle と同じ書式を指定すれば良く、一番楽なのは Maven Central Repository のページからコピペしてくることです。

BUILD ファイル

BUILD

java_binary(
    name = "HelloBazel",
    srcs = glob(["src/main/java/**/*.java",]),
    main_class = "willard379.bazel.sample.HelloBazel",
    deps = ["@commons_lang3//jar",],
)

deps 属性には、配列形式で「"@" + maven_jar()name 属性で指定した識別子 + "//jar"」を指定します。これによってコンパイル時にパスが通るようになります。

ビルドの実行と動作確認

前回のビルド結果が残っていますので、いったんキレイにしてから再ビルド、動作確認をしてみましょう。

$ bazel clean
INFO: Starting clean (this may take a while). Consider using --expunge_async if the clean takes more than several minutes.
$ bazel run HelloBazel
INFO: Found 1 target...
Target //:HelloBazel up-to-date:
  bazel-bin/HelloBazel.jar
  bazel-bin/HelloBazel
INFO: Elapsed time: 5.340s, Critical Path: 1.86s

INFO: Running command line: bazel-bin/HelloBazel
Hello, Bazel!

前回のビルド結果をキレイに消し去るには、 bazel clean コマンドを実行します。

そういえば説明し忘れてましたが、 Bazel も Gradle と同様に、前回ビルド時と入力ファイルの差分がなければ、出力ファイルにも差分は発生しないはずと見做され、UP-TO-DATE としてビルドがスキップされます。 今回はソースファイルその他に差分があるので bazel clean しなくても UP-TO-DATE にはなりませんが、気持ち的にフレッシュな気持ちで臨みたいのでクリーンしてみました。

でビルドの実行ですが、今回は bazel build を省略しておもむろに bazel run してみました。動作確認をするためには事前にビルドが必要なわけですが、 bazel run の実行時にビルド結果がない場合は自動的にビルドが実行されます。ここら辺も Gradle と同じですね。

動作確認の実行結果ですが、コマンドライン引数を省略しているので、デフォルト値が使われて "Hello, Bazel!" と出力されていることが確認できます。ちゃんと commons-lang が使われていますね! 尚、bazel run コマンド(または bazel-bin/HelloBazel シェル)で実行すると、commons-lang3-3.5.jar をクラスパスに通す設定をする必要もないので、java コマンドで実行するよりも楽ちんです!

次はいよいよ、ユニットテストを作成してみましょう。

Java プロジェクトのビルド ~ユニットテスト編~

構成はこんなカンジです。

f:id:willard379:20161219001539p:plain

Java ソースコード

HelloBazelTest.java

package willard379.bazel.sample;

import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;

import org.apache.commons.lang3.ArrayUtils;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

public class HelloBazelTest {

    private ConsoleTestFixture console = new ConsoleTestFixture();

    @Before
    public void setUp() {
        console.startMonitoring();
    }

    @After
    public void tearDown() {
        console.stopMonitoring();
    }

    @Test
    public void null空文字以外の文字列を渡すと_挨拶を返す() throws Exception {
        // set up fixture
        String[] args = { "willard379", };

        // exercise SUT
        HelloBazel.main(args);

        // verify outcome
        assertThat(console.getStdout(), is("Hello, willard379!"));
    }

    @Test
    public void null配列を渡すと_デフォルトの挨拶を返す() throws Exception {
        // set up fixture
        String[] args = null;

        // exercise SUT
        HelloBazel.main(args);

        // verify outcome
        assertThat(console.getStdout(), is("Hello, Bazel!"));
    }

  // ry)
}

HelloBazel.java に対するユニットテストを作成しました。HelloBazel.java の実行結果は標準出力に出力されるので、ConsoleTestFixture なるクラスを作って標準出力に出力された文字列をアサートできるようにしています。
このような「手続き上仕方がなくやらなければならない処理」というのはテストの本質ではないので、別クラスに追い出してテストケースクラスからは極力意識させないようにするに限ります。 ユニットテストは「どういう状況で、何を実行し、どうなっていれば合格か」だけが書かれているのが理想です。それ以外のあらゆる手続きはノイズです(と僕は考えます)。

今回はバグを見つけることが目的ではなく、ユニットテストを試すことが目的なので、テストケース足りてないのは問題なしとします。

ConsoleTestFixture.java

package willard379.bazel.sample;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;

import org.apache.commons.lang3.StringUtils;

public class ConsoleTestFixture {

    private final PrintStream originalOut;

    private ByteArrayOutputStream mockStdout;

    public ConsoleTestFixture() {
        originalOut = System.out;
    }

    public void startMonitoring() {
        mockStdout = new ByteArrayOutputStream();
        System.setOut(new PrintStream(mockStdout));
    }

    public void stopMonitoring() {
        System.setOut(originalOut);
        mockStdout = null;
    }

    public String getStdout() {
        return StringUtils.chomp(new String(mockStdout.toByteArray(), StandardCharsets.UTF_8));
    }
}

テストケースクラスから追い出した処理です。

WORKSPACE ファイル

WORKSPACE

maven_jar(
    name = "commons_lang3",
    artifact = "org.apache.commons:commons-lang3:3.5",
)

maven_jar(
    name = "junit",
    artifact = "junit:junit:4.12",
)

maven_jar(
    name = "hamcrest_all",
    artifact = "org.hamcrest:hamcrest-all:1.3",
)

JUnit と Hamcrest の依存関係を追加しました。

BUILD ファイル

BUILD

java_binary(
    name = "HelloBazel",
    srcs = glob(["src/main/java/**/*.java",]),
    main_class = "willard379.bazel.sample.HelloBazel",
    deps = ["@commons_lang3//jar",],
)

java_library(
    name = "HelloBazelApi",
    srcs = glob(["src/main/java/**/*.java",]),
    deps = ["@commons_lang3//jar",],
)

java_test(
    name = "HelloBazelTest",
    deps = [
        ":HelloBazelApi",
        "@commons_lang3//jar",
        "@junit//jar",
        "@hamcrest_all//jar",
    ],
    srcs = glob(["src/test/java/**/*.java",]),
    test_class = "willard379.bazel.sample.HelloBazelTest",
)

java_library() ルールと java_test() ルールを追加しました。
困ったことに、Bazel では java_binary() ルールで定義されたプロジェクトのユニットテストはできません。 そのため、ほぼ同じ設定の java_library() ルールを定義しています。識別子は "HelloBazelApi" としました。 java_binary() で定義した場合と異なり bazel run コマンドでの実行はできませんが、ユニットテストが目的なので問題ありません(定義が重複してしまっているのは誠に遺憾ですが)。

ユニットテストの設定は java_test ルールで行っています。
deps 属性の依存性に、SUTである ":HelloBazelApi" とそれが依存する commons-lang3、テスティングフレームワークである JUnit と Hamcrest マッチャーを設定しています。 test_class 属性は、実行するテストケースクラスの FQCN です。Bazel では "*Test.java" のパターンを持つクラスをテストケースとみなして全て実行してくれるような親切心はありません(ぉ。実行するテストケースをきちんと指定してあげる必要があります(ここは改善を強く望むところ)。

ユニットテストの実行

実行は以下のコマンドで行います。

$ bazel clean
INFO: Starting clean (this may take a while). Consider using --expunge_async if the clean takes more than several minutes.
$ bazel test HelloBazelTest
INFO: Found 1 test target...
Target //:HelloBazelTest up-to-date:
  bazel-bin/HelloBazelTest.jar
  bazel-bin/HelloBazelTest
INFO: Elapsed time: 5.517s, Critical Path: 2.92s
//:HelloBazelTest                                                        PASSED in 0.4s

Executed 1 out of 1 test: 1 test passes.
There were tests whose specified size is too big. Use the --test_verbose_timeout_warnings command line option to see which ones these are.

やはり BUILD SUCCESSFUL が(ry
テストが成功したようです。テスト結果のレポートは bazel-testlogs/HelloBazelTest/ に出力されます。

$ ll bazel-testlogs/HelloBazelTest/
total 20
drwxrwxr-x 2 ubuntu ubuntu 4096 Dec 18 13:41 ./
drwxrwxr-x 3 ubuntu ubuntu 4096 Dec 18 13:41 ../
-r-xr-xr-x 1 ubuntu ubuntu  166 Dec 18 13:41 test.cache_status*
-r-xr-xr-x 1 ubuntu ubuntu  358 Dec 18 13:41 test.log*
-rw-r--r-- 1 ubuntu ubuntu 1209 Dec 18 13:41 test.xml

test.xml でテストケースごとの合否を確認することができます。

すべてのユニットテストをまとめて実行する

やっぱり java_test() ルールの test_class 属性でテストケースクラスをいちいち指定しないといけないのはやってらんないので、すべてのテストをまとめて実行する方法を考えてみましょう。

TestSuite クラス

AllTests

package willard379.bazel.sample;

import org.junit.extensions.cpsuite.ClasspathSuite;
import org.junit.extensions.cpsuite.ClasspathSuite.ClassnameFilters;
import org.junit.extensions.cpsuite.ClasspathSuite.IncludeJars;
import org.junit.runner.RunWith;

@RunWith(ClasspathSuite.class)
@ClassnameFilters({".*Test"})
@IncludeJars(true)
public class AllTests {}

すべてのテストケースをまとめて実行するための TestSuite です。takari_cpsuite というライブラリを使っています。

BUILD ファイル

BUILD (デカくなってきたので差分のみ)

java_test(
    name = "AllTests",
    deps = [
        ":HelloBazelApi",
        "@commons_lang3//jar",
        "@junit//jar",
        "@hamcrest_all//jar",
        ":takari_cpsuite",
    ],
    srcs = glob(["src/test/java/**/*.java",]),
)

java_import(
    name = "takari_cpsuite",
    jars = [
        "lib/takari-cpsuite-1.2.7.jar",
    ],
)

今回は WORKSPACE ファイルに maven_jar() ルールを追加するのではなく、プロジェクト内に jar ファイルを配置して、その jar を使ってくれるように設定してみました。

java_import() ルールがその設定です。
jars 属性で jar ファイルを配置したパスを指定します。lib/takari-cpsuite-1.2.7.jar (ルートディレクトリからの相対パス) に配置しています。

java_test() ルールでは、 deps 属性に先ほどの "takari_cpsuite" を追加したこと以外に、name 属性を "AllTests"とし、 test_class 属性を削除しました。 実は test_class を省略した場合、 name で指定した識別子と同じ名前を持つクラスが test_class として使われます。 これによって test_class に "AllTests" の FQCN を指定したのと同じ意味になります。

AllTests の実行
$ bazel clean
INFO: Starting clean (this may take a while). Consider using --expunge_async if the clean takes more than several minutes.
$ bazel test AllTests
INFO: Found 1 test target...
Target //:AllTests up-to-date:
  bazel-bin/AllTests.jar
  bazel-bin/AllTests
INFO: Elapsed time: 5.766s, Critical Path: 2.91s
//:AllTests                                                              PASSED in 0.5s

Executed 1 out of 1 test: 1 test passes.

java_test()name を "AllTests" に変更したので、 bazel test で指定する識別子も "AllTests" になります。 これですべてのユニットテストがまとめて実行されるようになりました。

スローテスト問題

Bazel には、時間のかかるテストケースを指定の時間でタイムアウトさせる機能が備わっています。 java_test() ルールに size 属性を指定することで、サイズごとに決められた時間でユニットテストタイムアウト(FAILURE)するようになります。

BUILD ファイル

BUILD (差分のみ)

java_test(
    name = "AllTests",
    size = "small",
    deps = [
        ":HelloBazelApi",
        "@commons_lang3//jar",
        "@junit//jar",
        "@hamcrest_all//jar",
        ":takari_cpsuite",
    ],
    srcs = glob(["src/test/java/**/*.java",]),
)

size に指定できるのは以下の4種類で、タイムアウト以外にも RAM や CPU 数にも影響を与えるようです(詳細はここここ を参照)。

size タイムアウト
small 60秒
medium 300秒
large 900秒
enormous 3600秒

"small" を指定しているので、ユニットテストが60秒でタイムアウトするようになります。

Java ソースコード

SlowTest.java

package willard379.bazel.sample;

import org.junit.Test;

public class SlowTest {

    @Test
    public void _90秒かかるテスト() throws Exception {
        Thread.sleep(90 * 1_000);
    }
}

ベタに90秒間スリープさせています。

ユニットテストの実行
$ bazel clean
INFO: Starting clean (this may take a while). Consider using --expunge_async if the clean takes more than several minutes.
$ bazel test AllTests
INFO: Found 1 test target...
TIMEOUT: //:AllTests (see /home/ubuntu/.cache/bazel/_bazel_ubuntu/9ec7fed1ae4ad1d75540085b131e2506/execroot/HelloBazel/bazel-out/local-fastbuild/testlogs/AllTests/test.log).
Target //:AllTests up-to-date:
  bazel-bin/AllTests.jar
  bazel-bin/AllTests
INFO: Elapsed time: 65.251s, Critical Path: 62.47s
//:AllTests                                                             TIMEOUT in 60.1s
  /home/ubuntu/.cache/bazel/_bazel_ubuntu/9ec7fed1ae4ad1d75540085b131e2506/execroot/HelloBazel/bazel-out/local-fastbuild/testlogs/AllTests/test.log

Executed 1 out of 1 test: 1 fails locally.

狙い通り、テストがタイムアウトしました。
では、一時的にタイムアウト時間の上限を上げたくなった場合はどうしたらよいのでしょう? ご安心ください。実行時オプションの --test_timeout で秒数を指定することで、タイムアウト時間の変更が可能です。

$ bazel clean
INFO: Starting clean (this may take a while). Consider using --expunge_async if the clean takes more than several minutes.
$ bazel test AllTests --test_timeout=100
INFO: Found 1 test target...
Target //:AllTests up-to-date:
  bazel-bin/AllTests.jar
  bazel-bin/AllTests
INFO: Elapsed time: 95.190s, Critical Path: 92.90s
//:AllTests                                                              PASSED in 90.5s

Executed 1 out of 1 test: 1 test passes.

タイムアウト時間を100秒に設定したことで、ユニットテストが無事成功しました。

実は事前検証したとき、ここでちょっと困ったことが起きていました。 --test_timeout 実行時オプションを指定しただけでは入力ファイルに差分がないため、UP-TO-DATE となってユニットテストが実行されなかったのです。 上のように事前に bazel clean を実行することでユニットテストが実行されるようになりましたが、命令実行後すこしの間画面を監視していないと、「ユニットテスト流してる間にコンビニでも行くか~! ⇒ コンビニから戻ったら UP-TO-DATE で空振り ⇒ orz」の極悪コンボの餌食になります。

終わりに

まだ Beta 版ということで至らない点は多々ありますし、JUnit 5 でのユニットテストTravis CI との連携など試していないことがまだまだあって、手になじむようになるにはもう少し時間が必要そうですが、個人的には好きです。 Ant の build.xmlMaven の pom.xml よりも簡潔に記述できますし。自由度の観点では Gradle の build.gradle に及びませんが、その分属人的になりにくいため、誰が書いても大体同じものになるのではないかと思います。

とりあえす mvn archetype:generate や gradle init に相当する機能はぜひ実装していただきたいです。