お久しぶりですこんにちは。
最近は低調気味でなかなか体が思うように動かないたーせるです。
今日は、いつか書こうと思っていたJUnitネタです。
はじめに
ここ数年で、開発の現場には JUnit をはじめとしたテスト自動化が急速に普及しました。
とはいえ、やはりまだまだプロジェクトによってかなり温度差がある印象です。
特に保守開発をやっている人たちからは、「JUnitを導入したけれど、使い方がよく分からない」「JUnitが低機能すぎて使い物にならない*1」という声が、ときどき聞こえてきます。
かくいう僕も、会社に入って初めてJUnitに触れたときは「なんだこれ……」と思ったものです。
たしかに従来の考え方で組まれたプログラム(レガシーコード)をJUnitでテストするのは大変で、考え方の転換が必要です。
そこで今日は、Javaプログラマなら誰もが一度は書いたことのある「Hello World」を題材に、テストしやすいコードに直していく過程をご紹介したいと思います。
みんな大好きHello World
まずは肩慣らしに、すべての出発点である Hello World を書いてみました。
Main.java
package ja.tercel_tech.hello; public class Main { public static void main(String... args) { System.out.println("Hello world"); } }
かんたんですね。
しかし、このHello, WorldをJUnitでテストするのは非常に大変です。
参考までに、「上記のmain()
メソッドを実行して"Hello world"
が出力されること」を検証するテストプログラムを以下にご紹介します。
MainTest.java (テストプログラム)
package ja.tercel_tech.test; import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.assertThat; import ja.tercel_tech.hello.Main; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import org.junit.After; import org.junit.Before; import org.junit.Test; public class MainTest { private PrintStream defaultPrintStream; private ByteArrayOutputStream byteArrayOutputStream; @Before public void setUp() { defaultPrintStream = System.out; byteArrayOutputStream = new ByteArrayOutputStream(); System.setOut(new PrintStream(new BufferedOutputStream(byteArrayOutputStream))); } @Test public void mainメソッドを実行() { // main()メソッドを実行 Main.main(new String[]{}); // 標準出力の内容を取得 System.out.flush(); final String actual = byteArrayOutputStream.toString(); // 期待値を設定 final String expected = "Hello world" + System.lineSeparator(); assertThat(actual, is(expected)); } @After public void tearDown() { System.setOut(defaultPrintStream); } }
いかがでしたでしょうか。
ここまでの問題点まとめ
Hello Worldをテストしようとしたら、テストコードが鬼畜だった。
鬼畜というのは言い過ぎですが、本体よりもテストコードの方が複雑な処理を行っており、本末転倒になっていることは確かです。
今回のテストコードがここまで大掛かりになった理由は、以下3点の性質のためです。
main()
は戻り値を持たないmain()
はクラスの内部状態を変更しない(副作用が無い)main()
は静的メソッドである
戻り値・副作用がないことの問題点
戻り値を持つメソッドは期待値との突合できるので比較的素直に検証可能です。
また、戻り値がなくともクラスの内部状態(フィールドなど)が書きかわるようなメソッドは、間接的に検証可能です。
しかし、戻り値を持たず、副作用も持たないメソッドは一般的に検証困難です。
JUnitは、基本的に「メソッドの戻り値が期待値と合致しているか」「メソッドの実行前後でクラスの内部状態が期待通りに変化しているか」を判断するためのものなので、これらの手がかりがなければ検証のしようがありません。
静的メソッドの問題点
この節は少し難しいので、オブジェクト指向をよく解っていない方は読み飛ばして頂いて結構です。
静的メソッドはモック化を困難にします。
まず、以下のようにメソッドの抽出リファクタリングを適用して、main()
メソッドのSystem
依存の箇所をprintln()
に切り離そうと考えました。
public class Main { public static void main(String... args) { Main.println("Hello world"); } public static void println(String x) { System.out.println(x); } }
もしも上記のMain#println()
が非静的メソッドだったとしたら、話は簡単です。
テストハーネスの中でMain
クラスを継承したサブクラスを作り、Main#println()
をオーバーライドしてテスト用の擬装メソッドを作るだけでよいからです。
// できたらいいなの話(※ 以下のコードはコンパイルエラーになるよ) class Main2 extends Main { private String _printedString; public String getPrintedString() { return _printedString; } @Override public void println(String x) { _printedString = x; } }
こうすれば、println()
の引数をgetPrintedString()
で取得できますので、めでたく期待値"Hello world"
と突合することができます。
しかしながら、静的メソッドは基本的に継承不可のため、この手が使えません。
では、どうすればよいでしょう。
次からは、実際にHello WorldをJUnitと相性のよいコードに直していきます。
Step 1. System.out#println()
を別クラスに分離
はじめに、main()
メソッドにべったり癒着しているSystem.out#println()
機能を、MySysOut
クラスに追い出します。
仕様
MySysOut#println()
には以下の引数を取ります:PrintStream
String
MySysOut#println()
は、PrintStream#println()
を呼びますPrintStream#println()
には、MySysOut#println()
の第2引数をそのまま渡します
引数にPrintStreamがある理由は、MySysOut#println()
をSystem.out
に依存させないためです。
まずはこれらの仕様をそのままテストコードに書き起こしてみましょう。
ここからは、本チャン実装を行う前にテストコードを書きます。プチTDDです。
テストコードでは、下記を検証することとします。
MySysOut#println()
の中でPrintStream#println()
が呼ばれていることMySysOut#println()
とPrintStream#println()
のString引数が等しいこと
MySysOutTest.java (テストコード)
package ja.tercel_tech.test; import static org.mockito.Mockito.*; import ja.tercel_tech.hello.MySysOut; import java.io.PrintStream; import org.junit.Test; public class MySysOutTest { @Test public void printStreamを指定してprintln実行() { // PrintStreamのMockオブジェクトを生成 PrintStream stream = mock(PrintStream.class); final String strArg = "Hello, world"; // println() を実行 MySysOut sut = new MySysOut(); sut.println(stream, strArg); // 引数 strArg を、そのまま PrintStream#println() に引き渡していることを検証する。 // もし、異なる文字列が渡されていたらテストは失敗して最悪死ぬ。 verify(stream).println(strArg); } }
次に、このテストをパスするような MySysOut クラスを追加します。
MySysOut.java
package ja.tercel_tech.hello; import java.io.PrintStream; public class MySysOut { public void println(PrintStream printStream, String str) { printStream.println(str); } }
Step 2. オーバーロードメソッドの作成
続いて、MySysOut#println()
の引数からPrintStream
オブジェクトを省略した場合は、自動的にSystem.out
が設定されるように仕様を拡張しましょう。
ここでの検証ポイントは、あくまで「呼び出し元の引数からPrintStream
オブジェクトが省略されている場合は、デフォルトでSystem.out
になること」だけです。
その先の出来事(画面に文字列が出るかどうか)には一切関心がないことに注意してください(「MySysOut#println()
にSystem.out
を与えれば、System.out#println()
が実行されること」は先ほど検証済みです)。
MySysOutTest.java(差分のみ)
@Test public void printStreamを指定せずprintln実行() { // テスト対象MySysOutのSpyオブジェクトを生成 MySysOut sut = spy(new MySysOut()); final String strArg = "Hello, world"; sut.println(strArg); // MySysOut#println()の第一引数にSystem.outが渡されていることを検証する // もしSystem.out以外が渡されていたらこのテストは失敗して最悪死ぬ。 verify(sut).println(System.out, strArg); }
続いて、このテストをパスするように再び MySysOut
の方を書き換えてやります。簡単ですね。
MySysOut.java
package ja.tercel_tech.hello; import java.io.PrintStream; public class MySysOut { public void println(String str) { println(System.out, str); } public void println(PrintStream printStream, String str) { printStream.println(str); } }
これで、System.out#println()
相当の機能を持つMySysOut
クラスが一応完成しました。
Step 3. Main#main()
の修正
最後に、MySysOut#println
がMain
クラスからちゃんと呼び出されることが検証できれば完成です。
仕様
Main#main()
からMySysOut#println()
が呼ばれることMySysOut#println()
の引数は"Hello world"
であること
これらの仕様を満たすようにテストを書きます。
MainTest.java
package ja.tercel_tech.test; import static org.mockito.Mockito.*; import ja.tercel_tech.hello.Main; import ja.tercel_tech.hello.MySysOut; import org.junit.Test; public class MainTest { @Test public void mainメソッドを実行() { MySysOut sysout = mock(MySysOut.class); // main()メソッドを実行 Main.setMySysOut(sysout); Main.main(new String[]{}); // main()の中で、MySysOut#println()が実行されることを検証 // このとき、引数が"Hello world"でなければエラーとなる final String expectArg = "Hello world"; verify(sysout).println(expectArg); } }
今回の注意点は、Main.java から参照しているMySysOut
オブジェクトが可換である必要があるということです。
そうしないと、テストコード側で用意したモックオブジェクトをMain
に咬ませてやることができず、きちんとMySysOut#println()
が実行されたことを検証することができません。
テストコードからも解るとおり、簡単のためsetter(setMySysOut()
)を用意することにしました。
Main.java
package ja.tercel_tech.hello; public class Main { private static MySysOut _sysout = new MySysOut(); public static void setMySysOut(MySysOut sysout) { if (sysout != null) { _sysout = sysout; } } public static void main(String... args) { String helloWorld = "Hello world"; _sysout.println(helloWorld); } }
これで完成です。めでたし。
Main.javaの実行結果は以下のとおりです。
Hello world
もちろんJUnitもパスします。
入門書の最初に載っているサンプルとは似てもにつかないコードになってしまいました。中には「なんでこんなことをするんだ!」と思う方もいらっしゃるかと思いますが、まぁ、こういう思想もあるということでひとつ広い心で見ていただけたらと思います。