新世代のビルドツール 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 に相当する機能はぜひ実装していただきたいです。

Agile Japan 2016 に参加してきました。

5/31に浅草橋ヒューリックホールで開催された「Agile Japan 2016 あなたとつくるアジャイル」に参加してきました(会社のカネで)。
特に印象に残ったセッションについてレポートします。

スクラムイノベーションを加速する 〜ソフトウェア以外にも適用されはじめたアジャイル

Joe Justice 氏 (President Scrum in Hardware, Scrum Inc. )

Awesome! という掛け声とともに登壇したJoeさん。平鍋さんが通訳をしながらのセッションとなりました。

アジャイルがソフトウェア開発以外の分野で取り入れられている事例が紹介されていました。

米国ホワイトハウスや日本の国土地理院和歌山県といった行政分野でGitHubが取り入れられていることからも伺えるように、プロセスをよりより良くすること、良いものをすばやく提供しようという取り組みは、ソフトウェア分野に限ったことではなく、業界問わずに起こっているムーブメントなのだということに感動しました。

また、「スクラムマスターの1番の仕事はチームをハッピーにすること」という言葉が強く印象に残っています。 スクラムを導入しようとした組織が、最初は失敗したものの、スクラムマスター/アジャイルコーチが突破口となって最終的には成功した、という例はよく耳にします。

この講演を聞いて、スクラムマスター/アジャイルコーチというのは、オーケストラでいう「指揮者」の役割に似ているな、と感じました。

メンバーがそれぞれ高い演奏技術を持ち、「良い音を奏でる」という目的を共有していたとしても、それだけで良い演奏ができるというわけではありませんよね。ひとりひとりが意志も個性もある演奏家です。各パートのテンポや大きさなどを俯瞰し、指揮棒や身振りによって演奏者に伝え、全体としてひとつにまとめ上げる。そんな指揮者の役割もオーケストラの不可欠な要素です。

ましてスキルレベル、得意分野、プロダクトに対する知識、ポジション、など不揃いな要素が多いソフトウェア開発においては尚更その必要性が高いのではないかと思いました。 ミーティングひとつとっても、全員が気兼ねなく思ったことを発言できる会議と、一部の声の大きい人、影響力の強い人、立場の高い人ばかりが発言する会議とは、議事運営のしかたによって大きく左右されます。そもそも前者の空気感を醸成するというのもアジャイルの取り組みではあるのですが、「全員がプレイヤー」という状況で最初からそれができている組織であれば、きっとアジャイルでなくても上手くいくのではないかと思うのです。

人の弱さ、強さを認め、考え方や感性の異なる個性を調和させ、目的を指し示し、文化を根付かせ、エンジニアの集合体を単なる烏合の衆である「グループ」から、同じゴールを目指す「チーム」に昇華させる。そこがアジャイルコーチの活躍どころなのではないかなと。

そんなことを考えさせられるセッションでした。

日本型ハイブリッドアジャイルの導入と実践

長瀬 嘉秀 氏 (株式会社テクノロジックアート)

アジャイルは組織変革にも及ぶ。もともとアジャイルが向いている組織なら導入は容易だけど、大抵の組織はそうではないのでハイブリッド型が必要だよね、というお話。
日立ソリューションズでのウォーターフォールアジャイルなハイブリッド型手法について紹介されていました。

  • 見積り
    確定している要件については確定的に数字を出し、不確定な要件はバッファとして持っておく形で見積りをしたとのこと。この「不確定な要件」についてはおそらく、過去プロジェクトから似たようなケースを抽出して経験則的に見積もったのではないかと。従来型開発を前提とした見積りと、そうかけ離れた数字にはならなかったのではないかと推測。

  • 要件定義
    従来通りウォーターフォールで。要件定義書や業務フロー、ユースケース図などを作成する。

  • 基本設計、詳細設計、製造、単体試験
    ここをイテレーションで回す。
    業務フローとユースケース図からユーザーストーリーを抽出して、TDDをやった。 このTDDというのはたぶん、設計手法としてのTDDというよりも、顧客テストの側面が強いテストファーストのことだろう。

  • 総合試験
    ここもウォーターフォール

チーム体制としては従来とあまり変わらず、いままでどおりPMもいる。

「生産性の富士通、品質の日立」と言われるくらい品質に力を入れているHISOLであれば、総合試験が従来手法になるのは必然ですよね。 一括請負型の受託開発構造であるSIはアジャイルとの相性が良くないですが、従来型開発で培ってきた品質管理能力を継承した、日立だからこそのハイブリッド型なのだなと感じました。

文化の壁をぶち壊せ!日本でも出来る本物の DevOps ジャーニー!

牛尾 剛 氏 (マイクロソフトコーポレーション)
野澤 英歩 氏 (株式会社カラダメディカ)

「DevOps! DevOps! DevOps!」こういう湘北高校みたいなノリ、好きですw

DevOpsは本番環境へのリリースリードタイムを短くして、早くフィードバックを受け、いかに顧客の望むものをすばやくリリースしていくか、というところに価値がある。

DevOpsはアジャイルの土台の上に成り立ち、アジャイルは文化の上に成り立つ。
しかし伝統的な「日本文化」の上にはアジャイルは適用しにくい。日本文化と DevOps & Agile の文化は相いれない。
ではどうするか。「日本文化」の上に「西洋文化」をインストールすれば良い。

西洋文化では生産性が圧倒的に違う…ように見える。その秘密は、彼らが日本人よりも優れているからというよりも、彼らが本質的に必要なものにだけ取り組むマインドセットを持っているからである。日本では「10」の労力が必要なものが、米国では「1」くらいの労力で行われていたりする。そんなマインドセットの上にアジャイル、DevOpsが成り立っている。

「Be Lazy!」
より少ない時間で成果を最大化する。いかに無駄なことがあるかを考えていく。いわゆるエッセンシャル思考。 チーム全員で西洋文化を共通認識として持っておく。1人でやっていてもつまはじきにあうのが落ち。 西洋文化を学んで実践すれば、アジャイルはうまくいくだろう、というお話。

私もアメリカにいたころ、大量のペニー(1セント硬貨)をドル札に交換しようとして銀行に持って行ったとき、窓口のおばちゃんに「何ドルあるのか自分で数えろ」と言われ、「なんて不親切なんだ」と思った経験がありすが、考えてみればそれは銀行業務の本質ではないのですよね。

「かゆいところに手が届く」が常態化すると、やがてサービスを受ける側はそれが「あって当然のもの」と認識するようになります。それが成熟したのが「日本文化」であって、そういった「おもてなし」な日本文化は仰るとおりアジャイルとの相性が良くないのかもしれませんが、顧客あってこそのビジネスという点を考えると、どちらが良いかは一概には言えないなと感じました。話は自分たちの組織だけでなく、顧客の「文化」まで及ぶ場合がある。そこが難しいところ。 80:20の法則(パレートの法則)は日本でもよく耳にするところですが、「20 or 100」の極端な二社択一ではなく、「まずは70から始めてみる」とか、状況にあった選択をする必要がありそうですね。

さいごに

アジャイル」というものを知識として持ってはいるものの、実務での経験がない自分としては、生のアジャイルの声が聴けたのは良い機会だったと思います。
また内容もさることながら、このイベントに参加できたということ自体が自分にとってプラスになったと思います。こういった「新たな視点」を吹き込んでくれるイベントは自分に足りないものを気づかせてくれて、自分と向き合う良い機会でもあります。最近は勉強会などにあまり参加できていなくて、人の話を聞く機会が取れていないので、こういった気付きを大事にしていきたいなと思いました。

JUnit 5のUser Guideを写経してみた その1

JUnit 5のUser Guideをひととおり読んで写経してみたので、何回かに分けてまとめてみます。

リポジトリこちらです。

JUnit 5の特徴

簡単にまとめると(まとまってない)、こんなカンジです。

  • ラムダ式を活用しているJava 8前提)
  • hamcrestは積んでいないアサーションAPIはお好きに)
  • アノテーションが全部変わった (!?)
  • 任意のアクセス修飾子が使える(privateは無理っす)
  • 拡張を容易にする仕組みが設けられた(RuleとかTestRunnerがお役御免に。メソッドインジェクションできるようになった)
  • etc

Travis CI + Gradle + JUnit 5でテストが実行されない(UP-TO-DATE判定される)など、気になる点はケッコーあるものの、現時点でもそれなりに使えるな、という印象を受けました。

ちょっとした工夫は必要ですがIDEからの実行もできますし、ユニットテストの概念そのものに大きな変更はないので、正式版で互換性がなくなるかもしれないリスクを許容できるなら、という条件付きですが、すぐに乗り換えても大きな問題はないかも?

テストのライフサイクル

class StandardTest {

    @BeforeAll
    static void initAll() {
        // JUnit 4の@BeforeClassに相当。
        // staticにするのを忘れると、このクラスに書かれたテストが1つも実行されないので注意
    }

    @BeforeEach
    void init() {
        // JUnit 4の@Beforeに相当。
    }

    @Test
    void succeedingTest() {
        // JUnit 4の@Testに相当。
        // パッと見JUnit 4と同じアノテーションのように見えますが、FQCNは違うので要注意。
    }

    @AfterEach
    void tearDown() {
        // JUnit 4の@Afterに相当。
    }

    @AfterAll
    static void tearDownAll() {
        // JUnit 4の@AfterClassに相当。
        // こちらもstaticにするのを忘れると(ry
    }
}

要点をまとめると、以下のような感じです。

  • privateを除く任意のアクセス修飾子を付けられる
  • ライフサイクルの考え方はJUnit 4と変わらない
  • アノテーションが一新された

@BeforeClass@BeforeAllに、
@Before@BeforeEach に、
@After@AfterEach に、
@AfterClass@AfterAll に、
それぞれ変更されています。

パッと見 @Test は変わっていないように見えますが、FQCNが違います。
org.junit.Test ではなく org.junit.gen5.api.Test です。

junit5リポジトリのprototype-1タグを覗いてみると、@TestInstanceというライフサイクルに影響しそうなアノテーションもありますが、ALPHA版には搭載されていません(個人的にはいらないかな)。

大きな注意点としては、@BeforeAll、@AfterAllにstaticを付け忘れると、そのクラスに書かれたテストが1つも実行されないこと。
失敗もスキップも中断もされず、完全にガン無視されます。

staticを付けた場合の実行結果

Test run finished after 203 ms
[        45 tests found     ]
[         3 tests skipped   ]
[        42 tests started   ]
[         2 tests aborted   ]
[        40 tests successful]
[         0 tests failed    ]

staticを付けなかった場合の実行結果

Test run finished after 203 ms
[        45 tests found     ]
[         2 tests skipped   ]
[        40 tests started   ]
[         2 tests aborted   ]
[        38 tests successful]
[         0 tests failed    ]

tests foundの数はどちらも同じ45ですが、test startedとtest successful(ついでにtest skipped)の数が足りないです。 tests failedの数だけ気にしていると、staticを付け忘れたことによってガン無視されたテストがあることに気付かないので要注意です。
ちなみにJUnit 4では@BeforeClass@AfterClassにstaticを付け忘れた場合、initializationErrorになるので何かおかしいことに気付くことができます。 (次バージョンで修正されてるといいな)

テストケースの名前

@DisplayName("(^o^)ノ ")
class SampleTest {

    @Test
    @DisplayName("文字列なので、識別子として使用できない文字も使用できます!!!")
    void test1() {
    }
    
    @Test
    @DisplayName("EclipseのJUnitビューやテストレポートにはこの名前で出力されます。")
    void test2() {
    }
}

ご覧のとおりです。

注意点としては、EclipseのQuickJUnitプラグインを使用している場合、@DisplayNameを付けているとinitializationErrorとなって実行できません。 [Run As] や [Debug As]、[F11]キーなどで、QuickJUnitを介さなければ実行できます。ここはQuickJUnitの改善に期待ですね。

アサーション

class SampleTest {

    @Test
    void 通常のアサーション() {
        assertEquals("expected", "actual");
        assertNotNull(new Object());
        assertTrue(true);
    }
    
    @Test
    void 例外のアサーション() {
      IOException thrown = expectThrows(IOException.class, () -> {
          throw new IOException("メッセージ");
      });
      
      assertEquals("メッセージ", thrown.getMessage());
    }
    
    @Test
    void グループアサーション() {
        assertAll(
            () -> assertNotNull(null),
            () -> assertNull(""),
            () -> assertEquals("a", "b"),
            () -> assertNotEquals("a", "a"),
            () -> assertTrue(false),
            () -> assertFalse(true)
        );
    }
}
通常のアサーション

JUnit 4.4から標準搭載されているhamcrestライブラリは、JUnit 5には同梱されません。
そのため往年の assertEquals()assertNotNull()assertTrue()などを使ってアサーションします。
お望みなら別途hamcrestを導入することで、hamcrestマッチャーを使い続けることもできます。

例外のアサーション

例外のアサーションとしては、expectThrows()を使います。
第一引数に期待する例外の型を、第二引数に例外を発生させる処理(4フェーズテストのExerciseフェーズ)をラムダ式で渡すことで、例外の有無とその型をアサートすることができます。
また戻り値として発生した例外オブジェクトが返されるので、続けてgetMessage()getCause()などをアサートすることが可能です。

JUnit 4のExpectedExceptionでは、例外を発生させる処理(4フェーズテストのExerciseフェーズ)よりも前に、期待結果(Verifyフェーズ)を書かなくてはいけなかったため、すごくモヤモヤしながらテストを書いていましたが、そんなモヤモヤとお別れできて私としては超嬉しいです!

グループアサーション

グループアサーションは、1つのテストケースメソッドの中に複数アサーションを書く場合に使用します。こいつを使うことで、いずれかのアサーションが失敗した場合でも、後続のアサーションが中断されず、すべて実行されるようになります。

上記のテストケースを実行すると、失敗したテスト結果が以下のようにまとめて表示されます。

expected: not <null>
expected: <null> but was: <>
expected: <a> but was: <b>
expected: not equal but was: <a>
<no message> in org.opentest4j.AssertionFailedError
<no message> in org.opentest4j.AssertionFailedError

スキップと中断

class SampleTest {

    @Disabled("スキップする理由")
    @Test
    void skippingTest() {
    }

    @Test
    void abortingTest1() {
        // その1
        assumingThat(
            System.getProperty("os.name").toLowerCase().contains("windows"),
            () -> {
                // ここにアサーション書く
            }
        );
    }
    
    @Test
    void abortingTest2() {
        assumeTrue(System.getProperty("os.name").toLowerCase().contains("windows"));
        // ここにアサーション書く
    }
}

@DisabledJUnit 4の@Ignoreに相当します。tests skippedにカウントされます。
assumingThat()assumeTrue()(ついでにassumeFalse())はJUnit 4のassumeThat()の代替です。第一引数に渡すboolean(またはBooleanSuplierの戻り値)が条件を満たす場合のみ、テストが実行されます。条件を満たさない場合はtests abortedにカウントされます。
@BeforeAllメソッドに記述することで、そのクラスに書かれたテストケースを丸ごと飛ばすこともできます。

構造化テスト

class OuterClass {
    @Nested
    static class Windowsの場合 {
    
        @BeforeAll
        static void beforeAll() {
            assumeTrue(System.getProperty("os.name").toLowerCase().contains("windows"));
        }
    
        @Test
        void test1() {
        }
    }
    
    @Nestted
    static class Windows以外の場合 {
    
        @BeforeAll
        static void beforeAll() {
            assumeFalse(System.getProperty("os.name").toLowerCase().contains("windows"));
        }
        
        @Test
        void test2() {
        }
    }
    
    @Test
    void 共通のテストケース() {
    }
}

テストの性質ごとにクラス分けしたい場合(初期化・終了処理を共通かしたい場合など)、構造化テストが使えます。

JUnit 4の@RunWith(Enclosed.class)に相当しますが、プラス、JUnit 5では以下の利点があります。

  • 外側のクラス(上記のOuterClass)にもテストケースを書けるようになった。
  • 内部クラスをstaticにする必要がなくなった。

注意が必要なのは、Javaの言語的制限で、非staticなインナークラスにはstaticなメンバーを持つことができないので、 インナークラスを非staticにする場合は@BeforeAll@AfterAllのライフサイクルメソッドは定義できませんコンパイルエラーになります)。

次回予告

とりあえず今日はこんなところで。
次回はメソッドインジェクションと、defaultメソッドの活用を書く予定です。

はじめに

自己紹介

こんにちは。willard379といいます。

都内のパッケージベンダーに勤めるJavaエンジニアです。

玉出身の35歳男性、昨年まで中小SIerに勤めていましたが今年転職しました。 なのでまだ有給休暇がなく、世間はGWまっただ中ですが私は明日も元気に出社です(ノД`)・゜・。

ブログをはじめる理由

経験も技術もたいしたことない

エンジニア歴はまだ10年に満たず、年齢から考えると経験はそんなに長くありません。
その間、エクセル仕事ばかりでコードをまともに書かない期間もそれなりに長く、純粋なデベロッパーとしての経験はたぶん20台中盤と同じくらいなもんでしょう。

下請け主体の中小SIerからパッケージベンダーに環境が変化したことで、求められるものが変わってきた、というのもあります。これまでは顧客オンサイトで作業することが多く、技術的に尖がっていることよりも「顧客にどう自分を気に入ってもらうか」の方に比重が置かれていたりしました。また前述のようにエクセル仕事ばかりでほぼコードを書かない(書く時間がない)こともありましたが、これからは「良いものを作る」ために好きなだけ尖がることができます。

○○おじさんとか呼ばれたくない

転職したことで最近ちょっと危機感、というか焦燥感みたいなものを感じるようにもなってきました。プログラマ35歳定年説なんて言葉もあるように、この年齢で定年退職せずにデベロッパーでい続けるというには、それなりのモノを持っていることが求められます。
自分はそんなもの持ち合わせていないので、メッキが剥がれるのも時間の問題です。このままでは「○○おじさん」とか言われてしまうんじゃないか、と考えると気が気じゃありません。

なのでこの機会に自分を鍛えなおし、もっとおもしろいことをやっていけたらな、と思ってブログを始めることにしました。決意表明みたいなもんです。

アウトプットが少ない

これまでの勉強法は、RSSで情報収集する、技術系のドキュメントを読んで写経する、といったインプット型のものが主体でした。これまでと同じ方法を続けていてもちょっと埒が開かないと思い始めていたところで、いろいろ模索するなかで、もっとアウトプットを増やす手段としてブログをはじめてみようと思いました。

モノを作ることは好きなので、これまでも人知れず愚にもつかないプログラムを作ったり(途中で飽きて放り出したり)してきました。ですが、ただ作ってGitHubにプッシュするだけでプロモーションもせず、ハマったことや感じたことをTwitterに垂れ流すことはあっても、単なる独り言に反応してくれる方なんていやしません。さすがにちょっと虚しいです。
これからは、やったことの記録、作ったものの紹介なんかもやっていこうと思いました。

運用方針

たぶん新しい技術をさわり始めたときなんかに書いていくになると思います。

とはいえ、技術ネタだけでは息がつまるので、趣味のこと、日常生活で感じたことなんかも書いていきたいです。

…実は過去に持っていたブログを一週間でほっぽり投げた前科持ちなんで、どこまで続けていけるかすごく心配ですが、まぁ、あまり肩肘肩らず、月1くらいのペースでノロノロと続けていきます。
どうぞよろしくお願いします。