【Day 2】リファクタリングするなら自動テスト【じゃんけんアドカレ】

[じゃんけんアドベントカレンダー](https://qiita.com/advent-calendar/2020/janken) の 2 日目です。

- 初回 ... [【Day 1】とりあえず 1 クラスに全部書く【じゃんけんアドカレ】](https://qiita.com/os1ma/items/630e2d7fb5d744bbdf8a)

---

さて、[昨日のコード](https://qiita.com/os1ma/items/630e2d7fb5d744bbdf8a) を修正するにあたり、まずは JUnit で自動テストを書いていこうと思います。

現時点のコードは標準入力・標準出力に非常に強く依存しているうえ、private ではないメソッドのが main だけとなっており、非常にテストしにくいです。
正直ちょっと心が折れそうになっていますが、がんばって自動テストを書いていきます。

※ JUnit による自動テストだけだと物足りない記事になりそうだったので、JAR ファイルの実行をテストするシェルスクリプトを作成し、その記述ノウハウについても書いています

## JUnit で自動テスト

いろいろ調べたりしながら、JUnit での自動テストを書いてみました。
実際に書いたテストコードは [こちら](https://github.com/os1ma/JankenEnterpriseEdition/blob/e65219906ead784eeae05fb07c6283a5802a81f4/app/src/test/java/com/example/janken/AppTest.java) です。

※ 前回書いた App.java を修正せずにテストを記述するため、標準入力・標準出力含め全体をテストするコードを書いています。本来このようないわゆる E2E テストは最小限にした方が望ましいです

以下、特筆すべき点について何点か書いていきます。

### 標準入力・標準出力の自動テスト

標準入力・標準出力を使う自動テストを書くにあたり、Java で標準入力・標準出力をテストしている例を探したところ、いくつか見つかりました。

- [標準出力に結果を出すプログラムをJUnit 4.1でテストする方法](https://hyuki.hatenablog.com/entry/20060818/junit)
- [標準出力をJUnitでテストするには](https://gist.github.com/BladeanMericle/e1eec0806228f30c8ae3563a6947b182)
- [【Java】標準入力＆標準出力をJUnitでテストしたい](https://qiita.com/aky100200/items/f4f7d6279524774610fc)

今回書いたテストコードでは、これらの記事・コードを大いに参考にさせていただきました。

### パラメタライズドテスト

じゃんけんの全パターンをテストするため、パラメタライズドテストを使いました。

```java
    @ParameterizedTest
    @CsvSource({
            "0, 0, STONE, STONE, DRAW !!!, 2, 2",
            "0, 1, STONE, PAPER, Bob win !!!, 1, 0",
            "0, 2, STONE, SCISSORS, Alice win !!!, 0, 1",
            "1, 0, PAPER, STONE, Alice win !!!, 0, 1",
            "1, 1, PAPER, PAPER, DRAW !!!, 2, 2",
            "1, 2, PAPER, SCISSORS, Bob win !!!, 1, 0",
            "2, 0, SCISSORS, STONE, Bob win !!!, 1, 0",
            "2, 1, SCISSORS, PAPER, Alice win !!!, 0, 1",
            "2, 2, SCISSORS, SCISSORS, DRAW !!!, 2, 2"
    })
    void 正常な入力でじゃんけんが実行され結果が保存される(...
```

アノテーションを使って CSV 形式でデータを与えると、テストコードの引数が受け取ってくれるという便利なものです。

JUnit5 のドキュメントでは [こちら](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests) に書かれています。

### ファイルの中身のテスト

このプログラムでは、標準出力に結果を出す以外にも、ファイルにデータを保存する処理を実行しています。

テスト対象のコードを書き変えずに保存内容を確認するにはファイルを開いて見るしかないと考え、ファイルの特定の行を取得する関数を作ってテストで使うようにしました。

```java
    private static String readSpecifiedLineByFile(String path, long index) throws IOException {
        try (Stream<String> stream = Files.lines(Paths.get(path), StandardCharsets.UTF_8)) {
            return stream.limit(index)
                    .skip(index - 1)
                    .findFirst()
                    .orElseThrow(IllegalArgumentException::new);
        }
    }
```

### 対戦時刻のテスト

じゃんけんの対戦時刻を正しく保存しているかをどうテストしたものか考えましたが、テスト対象のコードを書き換えないと難しそうだったため、今回は見送りとしました。

申し訳程度に正規表現で形式が一致しているかテストしていますが、かなりよろしくない方法だと思います。

```java
        assertTrue(jankenCsvLine.matches(appendedJankenId + ",\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2}"),
```

ここは今後のリファクタリングの中で、テストができるコードに修正しようと思います。

テスト対象のコードを書き換えずにテストする良い方法をご存知の方がいらっしゃいましたら是非教えてください。[^1]

[^1]: Java で static メソッドをモックするライブラリ (PowerMock、JMockit) が使えないか調べましたが、使用しているテストライブラリ (JUnit5) に対応していませんでした

ちなみにですが、現在時刻が関わる自動テストについては、t-wada さんの「[現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ](https://t-wada.hatenablog.jp/entry/design-for-testability)」という記事が非常に参考になります。

## JAR のビルド・実行をシェルスクリプトで自動テスト

さて、JUnit でテストコードを書いただけだとあまり面白くない記事になってしまうので、追加で JAR ファイルのビルドと実行のテストをシェルスクリプトで自動化してみようと思います。

こういった自動化を行う際は Gradle のタスクを作成してもいいですが、今回は自分が慣れているシェルスクリプトにしました。

作成したシェルスクリプトは以下の通りです。

```bash
#!/bin/bash

set -o errexit
set -o nounset
set -o pipefail
set -o xtrace

readonly SCRIPT_DIR="$(cd "$(dirname "$0")"; pwd)"
readonly PROJECT_HOME="${SCRIPT_DIR}/.."

readonly JAR=${PROJECT_HOME}"/app/build/libs/app.jar"

# ビルド
"${PROJECT_HOME}/gradlew" \
  clean \
  build

# JAR の状態での実行をテスト
export DATA_DIR="${PROJECT_HOME}/data"
echo -e "0\n0" | java -jar "${JAR}"
```

自分は結構シェルスクリプトを書くことが多く、諸々工夫を入れていたりしますので、その内容を解説していきます。

なお、コーディング規約としては Google の [Shell Style Guide](https://google.github.io/styleguide/shellguide.html) を参考にしています。

### set で各種設定

自分がシェルスクリプトを書く際は、最初に

```bash
set -o errexit
set -o nounset
set -o pipefail
set -o xtrace
```

という Bash のオプションを入れます。

これらのオプションにより、

* エラー発生で終了
* 未定義の変数の使用で終了
* パイプラインの途中のエラーで終了
* 実行されたコマンドを表示

という挙動をするようになり、エラー発生時の原因調査がかなり楽になります。

なお、pipefail 以外の 3 つは set -eux のように簡略に書くこともできますが、どれがどのオプションなのか分かりにくいため、私は省略しないで書くようにしています。

### readonly で定数を定義

```bash
readonly JAR=${PROJECT_HOME}"/app/build/libs/app.jar"
```

変数への再代入は基本的にプログラムバグの原因になりやすいので、私はどんな言語でも基本的に変数への再代入はしません。
シェルスクリプトでは readonly をつけることで変数の書き換えを防ぐことができるので、readonly を付けています。
これにより、スクリプトのメンテナンス時などに誤って既存の変数と同じ名前の変数を定義してしまうことも防ぐことができます。

今回は使っていませんが、関数内でローカル変数を使う場合は local FOO=BAR のように local を使って宣言します。
local を付けないと、変数が関数の外部からも参照できてしまうためです。

### カレントディレクトリに依存しないスクリプトの記述

```bash
readonly SCRIPT_DIR="$(cd "$(dirname "$0")"; pwd)"
```

という記述により、シェルスクリプトが置かれているディレクトリを取得しています。

このようにシェルスクリプトが置かれているディレクトリを取得して、各種ファイルを絶対パスで指定することにより、どのディレクトリからシェルスクリプトが実行されても問題ないようにしています。

このような工夫なしにシェルスクリプトを書いてしまうと `./example.sh` と実行された場合と `./bin/example.sh` のように実行された場合で、カレントディレクトリの違いによる挙動の違いが生じることがあります。

### cd を可能な限り使わない

cd コマンドを多用するとスクリプトの途中で今どのディレクトリにいるのかを意識することになり、メンテナンス性が下がるため、シェルスクリプト内では可能な限り cd コマンドを使わないことにしています。

cd を使わないため、ファイルは `"${PROJECT_HOME}/gradlew"` のように、フルパスで指定しています。

gradlew を複数回実行するような場合は、`readonly GRADLEW="${PROJECT_HOME}/gradlew"` のようにして、ファイルを定数として定義する手法もよく見かけます。

### JAR 実行のテスト

標準入力には echo コマンドで出力し、パイプを使って java コマンドに受け渡しました。

```bash
echo -e "0\n0" | java -jar "${JAR}"
```

標準出力の内容については JUnit で書いた自動テストの方で保証しているので、ここでは記述しませんでした。

なお、echo ではなく printf を使ったほうがいい場合もあるようですが、今回はひとまず echo で書きました。

参考

- [CentOS7とOS X Mavericksのechoコマンドの違いについて](https://sota1235.hatenablog.com/entry/2015/08/07/235824)

### シェルスクリプトのテンプレート化

シェルスクリプトでのオプションの設定やスクリプトのディレクトリの取得などは、暗記してさらっと書けるものではないです。
そこで私はシェルスクリプトのテンプレートを用意しています。

以下のような関数を .bashrc に定義して、Gist 上に置いてある [自分のシェルスクリプトのテンプレート](https://gist.github.com/os1ma/349072921f3cccbbc790df1019525b1f) を取得しています。

```bash
$ declare -f shell_template
shell_template ()
{
    local gist_url='https://api.github.com/gists/349072921f3cccbbc790df1019525b1f ';
    curl -sS "${gist_url}" | jq -r '.files."shell-script-template.sh".content'
}
```

## 次回のテーマ

さて、なんとか自動テストを書くことができました。
しかし、自動テストは実行されなければ意味がありません。
テストが通らないコードがコミットされて放置されていれば、どんどんコードは腐っていってしまいます。

対策はもちろん CI、継続的インテグレーションです。
ということで、次回は GitHub Actions を使った CI を構築しようと思います。

それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
[じゃんけんアドベントカレンダー](https://qiita.com/advent-calendar/2020/janken) に興味を持ってくださった方は、是非購読お願いします。

## 次回の記事

[【Day 3】CI はプロジェクト初期に組むと幸せになれるはず【じゃんけんアドカレ】](https://qiita.com/os1ma/items/155e430c9d03dece20a5)

## 現時点のコード

現時点のコードは GitHub の [この時点のコミット](https://github.com/os1ma/JankenEnterpriseEdition/tree/e65219906ead784eeae05fb07c6283a5802a81f4) を参照ください。