読者です 読者をやめる 読者になる 読者になる

JUnitでHello World

お久しぶりですこんにちは。

最近は低調気味でなかなか体が思うように動かないたーせるです。

今日は、いつか書こうと思っていた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 WorldJUnitと相性のよいコードに直していきます。



Step 1. System.out#println()を別クラスに分離

はじめに、main()メソッドにべったり癒着しているSystem.out#println()機能を、MySysOutクラスに追い出します。

仕様

f:id:tercel_s:20150228191840p:plain

  • 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が設定されるように仕様を拡張しましょう。

f:id:tercel_s:20150228195316p:plain

ここでの検証ポイントは、あくまで「呼び出し元の引数から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#printlnMainクラスからちゃんと呼び出されることが検証できれば完成です。

f:id:tercel_s:20150228200007p:plain

仕様

  • 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もパスします。

入門書の最初に載っているサンプルとは似てもにつかないコードになってしまいました。中には「なんでこんなことをするんだ!」と思う方もいらっしゃるかと思いますが、まぁ、こういう思想もあるということでひとつ広い心で見ていただけたらと思います。



まとめ

  • 直感的に書けるコードは、必ずしも自動テストしやすいとは限らない
  • テスト優先でコードを書くと、クラスやメソッド同士が疎結合になる
  • この記事を書いてる途中ではてなブログが一時的にアクセス不能になった
  • 書きかけの原稿も一部飛んだ
  • かなしい
  • しにたい
  • ネガティヴ

以上、長々とお付合いいただきありがとうございました。

*1:JUnitが低機能というわけではなく、どちらかというとテスト対象の設計に難がある場合が多いような気がします。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.