これだけは覚えたい、ユニットテストを書くための4つのデザイン

  • 499
    いいね
  • 0
    コメント

はじめに

去年、ブログの方に「ふつうのユニットテストのための7つのルール」という記事を書いたのですが、思ったより反響がありました。
あの記事で書いたのはあくまで原理・原則で、それを実現するためにはいくつかのテクニックが必要です。

特に、ああいうルールを作って「ユニットテストを書く事」を厳守するようにしても、
適切なテクニックを知らなければメンテが困難だったり、品質に寄与しなかったり、実行性能が悪いゴミが量産される可能性があります。

じゃあ、どうすれば良いかというと「最初からユニットテストが書きやすいように元のコードを設計する」ということです。
そう。まず身に付けるべきは「テストコードの書き方」では無く「テスト対象コード」すなわち「プロダクトコードの書き方」なのです。
また、ここで言ってる「最初から」は何も「テストファーストだ! TDDをせよ!」とまで徹底しなくてもレビューやPRのマージなど人に渡す前にちゃんとしておけ、ってレベルですね。

ただ、ちょっとした発想の転換なので、初級者は具体例を見た方が分かりやすいと思うので典型的な例をチートシートとして作ってみました。

設計チートシート

基本的な考え方は「ビジネスロジックとI/Oを分離する」「何度繰り返しても同じ値が返るようにする」となります。以下はその考え方をベースとした具体例です。

標準出力をするプログラムを作りたい

System.out.printlnLogger.infoのような出力と「出力する内容を組み立てる」部分を分けて、単純に文字列を返すだけのメソッドをテストします。

プロダクトコード例:

public class Example01Good {
    public static void main(String[] args) {
        String message = args[0];
        System.out.println(makeMessage(message));
    }

    static String makeMessage(String message) {
        return "Hello, " + message;
    }
}

テストコード例:

@Test
public void testMakeMessage() {
    assertThat(Example01Good.makeMessage("world"), is("Hello, world"));
}

乱数を扱うメソッドを作りたい

乱数は毎回値が変わってしまうので、ビジネスロジックと切り離して引数で値を渡します。
サイコロが偶数か奇数かを判定するプログラムだとこんな感じ。

プロダクトコード例:

public class Example02Good {
    public static void main(String[] args) {
        System.out.println(check(throwDice(Math.random())));
    }

    static String check(int num) {
        return (num % 2 == 0) ? "Win" : "Lose";
    }

    static int throwDice(double rand) {
        return (int) (rand * 6);
    }
}

テストコード例:

@Test
public void testCheck() {
    assertThat(Example02Good.check(1), is("Lose"));
    assertThat(Example02Good.check(2), is("Win"));
    assertThat(Example02Good.check(3), is("Lose"));
    assertThat(Example02Good.check(4), is("Win"));
    assertThat(Example02Good.check(5), is("Lose"));
    assertThat(Example02Good.check(6), is("Win"));
}

翌日の計算など日付を扱うメソッドを作りたい

乱数の時と同じように外部から与えれるようにします。
別解として例のように日付生成のFactoryを作ってテスト時はダミー(スタブ)ってのも良くやります。

プロダクトコード例:

class SystemDate {
    public LocalDate today() {
        return LocalDate.now();
    }
}

public class Example03Good {
    SystemDate systemDate = new SystemDate();

    public LocalDate tomorrow2() {
        return systemDate.today().plusDays(1);
    }
}

テストコード例:

@Test
public void testTomorrow2() {
    Example03Good target = new Example03Good();
    target.systemDate = new SystemDate() {
        @Override
        public LocalDate today() {
            return LocalDate.of(2017, 1, 16);
        }
    };
    assertThat(target.tomorrow2(), is(LocalDate.of(2017, 1, 17)));
}

ファイルの入出力を取り扱いたい

ファイル入出力も基本は文字列を取り扱うメソッドにすれば良いですが、
実データが非常に大きい場合や「行」として処理をしないといけないケースがあります。
その場合、ファイルの読み書きを実際に検証しても良いですが、記述コストも実行コストも高いので、
ロジック内からファイルの直接の取り扱いを分離し、Reader/WriterやInputStream/OutputStreamといった
インタフェースに対してプログラミングをし、テストではStringReader/StringWriter等を使うと簡単です。

プロダクトコード例:

public class Example04Good {
    public static void main(String[] args) throws Exception {
        System.out.println("hello");
        try (Reader reader = Files.newBufferedReader(Paths.get("intput.txt"));
                Writer writer = Files.newBufferedWriter(Paths.get("output.txt"));) {
            addLineNumber(reader, writer);
        }
    }

    static void addLineNumber(Reader reader, Writer writer) throws IOException {
        try (BufferedReader br = new BufferedReader(reader);
                PrintWriter pw = new PrintWriter(writer);) {
            int i = 1;
            for (String line = br.readLine(); line != null; line = br.readLine()) {
                pw.println(i + ": " + line);
                i += 1;
            }
        }
    }
}

テストコード例:

@Test
public void testAddLineNumber() throws Exception {
    Writer writer = new StringWriter();
    addLineNumber(new StringReader("a\nb\nc\n"), writer);
    writer.flush();
    String[] actuals = writer.toString().split(System.lineSeparator());

    assertThat(actuals.length, is(3));
    assertThat(actuals[0], is("1: a"));
    assertThat(actuals[1], is("2: b"));
    assertThat(actuals[2], is("3: c"));
}

解説というか基本となる考え方

具体例だけじゃなくて、なんでそう書くのか? ってところも説明していきます。

ロジックとI/Oの分離

繰り返しになりますが、基本はロジックとI/Oの分離になります。これがテストのためのコツです。
「コマンドライン引数から受け取った文字を加工して標準出力するプログラム」を例に見ていきましょう。

public class Example01Bad {
    public static void main(String[] args) {
        String message = args[0];
//         String message = "World"; // 動作確認用
        System.out.println("Hello, " + message);
    }
}

多くの人が最初に書くコードはこうでは無いでしょうか? コメントアウトしてあるコードが素敵です。
ではこのプログラムのユニットテストを書いてみます。

@Test
public void testMain() {
    String[] args = {"world"};
    Example01Bad.main(args);
}

こんな感じですかね。
なんとassertがありません! なので、このプログラムが正常かどうかはJUnitであるにも関わらず「人間が目で見て判断するしかない」です。

「こんなコード誰も書かないでしょwww」と思うかもしれませんが、もっと複雑なビジネスロジックで何度もこういった「動かすだけのテストコード」を見たことがあります。
※ 戻り値がbooleanだったりintだったりでPGが「正常終了か異常終了か」が判別できる程度のテストも同罪です。

まあ、これは論外なのですが、もうちょっと意識高い人はこう書きます。

/**
 * 意識高い系ダメテスト
 */
@Test
public void testMainWithSystemOut() {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    System.setOut(new PrintStream(out));

    String[] args = {"world"};
    Example01Bad.main(args);

    assertThat(out.toString(), is("Hello, world" + System.lineSeparator()));
}

標準出力をフックして結果を比較しています。
このテストはテストとしての要件を適切に満たしており、なにも間違っていませんが単純に無駄に複雑です。
なので、元のコードに手を加えることでもっとシンプルにします。

public class Example01Good {
    public static void main(String[] args) {
        String message = args[0];
        System.out.println(makeMessage(message));
    }

    static String makeMessage(String message) {
        return "Hello, " + message;
    }

「文字列を加工するロジック」をmakeMessageとして切り出しました。するとユニットテストはこうなります。

@Test
public void testMakeMessage() {
    assertThat(Example01Good.makeMessage("world"), is("Hello, world"));
}

とてもシンプルになりましたね? 良い感じです。

でも「画面に文字を表示する機能がテストできてない!」と感じる人もいるのではないでしょうか?
その通りです。ですが、そんなものはユニットテストで確認する必要は無いんです。

ユニットテストで最も意識を傾けないといけないのはロジックのテストです。
ファイル入出力や標準入出力、あるいはログ出力のような既に品質が十分に確認されたテストをする必要はありません。

そういったファイルIO等を自作していて、その品質が担保されてない場合は、
自作したファイルIOのテストでその品質は担保するべきなので、その機能を利用する個々処理で確認する必要はありません。
そういった組合せた場合のテストも大事ですがそれは結合テスト等の別フェーズで実施するものです。

なので、今回のように機能をなるべく小さく分解して、シンプルに戻り値を返すビジネスロジックを作ることを常に意識するのが大事です。
I/Oや後述するコントロールできない値の初期化をコントローラーレイヤーに可能な限り追い出し、そこのテストはビルド時に毎回走らせるようなUTからは外す、というのがお勧めです。
レガシーコードのメンテならダメな例で出したみたいに標準出力をフックするのも致し方ないことがありますが、自分で書くなら不要です。

値をコントロールできないものを直接初期化しない

代表的な例は日付や乱数あるいはネットワーク(WebAPI呼んでるとか)のようなコントロールできないものをテスト対象のメソッド内で初期化しないというのも重要です。

例えば、明日を求めるtomorrowメソッドを作ります。

public class Example03Bad {
    public LocalDate tomorrow() {
        return LocalDate.now().plusDays(1);
    }
}

当然ですがこのように直接tomorrowメソッドの中でLocalDateを初期化してしまうと毎日値が変わるので、自動テストは不可能です。

これはかなり重大な問題で、こういった依存を適切に排除してないから「ユニットテストの書き方が分からない」って人もそれなりにいます。

解決方法として一番単純なのは引数に日付を渡してビジネスロジックと切り離すことです。

public LocalDate tomorrow(LocalDate today) {
    return today.plusDays(1);
}

そうするとテストはこんな風に固定値で書けます。

@Test
public void testTomorrow() {
    Example03Good target = new Example03Good();
    assertThat(target.tomorrow(LocalDate.of(2017, 1, 16)), is(LocalDate.of(2017, 1, 17)));
}

基本はこれで十分ですが、数が多い時はFactoryMethodパターンとスタブを使った方が簡単なこともあります。
まずLocalDateを生成するSystemDateクラスを作り、その中でLocalDate.nowを使い、Example03Goodのフィールドにセットします。
ビジネスロジックとしてはSystemDateクラスを経由してLocalDateを取得するので、何が返ってくるかは実装に依存します。プロダクトコードならLocalDate.nowですね。

class SystemDate {
    public LocalDate today() {
        return LocalDate.now();
    }
}

public class Example03Good {
    SystemDate systemDate = new SystemDate();

    public LocalDate tomorrow2() {
        return systemDate.today().plusDays(1);
    }
}

テストコードはこんな風にsystemDateをスタブで上書きします。

@Test
public void testTomorrow2() {
    Example03Good target = new Example03Good();
    target.systemDate = new SystemDate() {
        @Override
        public LocalDate today() {
            return LocalDate.of(2017, 1, 16);
        }
    };
    assertThat(target.tomorrow2(), is(LocalDate.of(2017, 1, 17)));
}

ここでのコツはSystemDateのようなFactoryとなるフィールドはprivateでは無くpackageレベル以上にして置くことです。
そうすればDIコンテナとかMockフレームワークを使うことなく実装を切り替えることが出来ます。
もちろん、privateにしてコンストラクタで渡したり、setterで詰めるのも問題は無いですが、結局変更出来る事には変わりないので、だったら手軽なほうが良いなというだけです。

まとめ

さて、ざっと書いてみましたが如何でしょうか。ちょっとした設計の工夫でテストがずいぶん書きやすくなるのが分かってもらえたかと思います。

難しいことではないのですが、良く初級者に同じ指摘をするのと、こういう観点でサマリーしてある資料を知らなかったので書いてみました。
ちなみに「TDD(テスト駆動開発)が品質に寄与する」と言われるのも、自然とこういった書き方が強制されるからですね。

また、関数型言語を勉強するとこういった設計手法の極致みたいなところがあるので、参考になるところも多いかと思います。

それでは今年もHappy Hacking!