ブラックボックステストとホワイトボックステスト

305件のシェア(そこそこ話題の記事)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

テスト分類のひとつにブラックボックステストホワイトボックステストがあります。

ブラックボックステストとは、テスト対象の内部を意識せずに外部仕様のみからテストケースを構築していく手法です。ユニットテストであれば、テスト対象となるメソッドの実装(コード)を意識せず、メソッドのAPI仕様からテストケースを作成することになります。

一方、ホワイトボックステストでは、テスト対象の内部を意識し、どのような構造であるかを踏まえたテストケースを構築します。ユニットテストであれば、テスト対象となるメソッドの実装(コード)を意識し、分岐や繰り返しなどを考慮しつつテストケースを作成することになります。

さて、ユニットテストはブラックテストでしょうか? それともブラックボックステストでしょうか?

「JUnit実践入門」では次のように記述しました。

本書で扱うユニットテストは、テスト対象の内部ロジックを考慮して行うため、どちらかといえばホワイトボックステストに分類されます。しかしながら、内部ロジックに依存しすぎたユニットテストは、変更に対して脆くなるため、テスト対象の外部仕様からテストデータを選択したほうがよいという側面もあります。このため、ユニットテストは、ホワイトボックステストとブラックボックステストを組み合わせて行います。(P.27)

Amazonでチェックする

書籍では分量の関係から詳細な解説ができませんでしたので、少し掘り下げて解説したいかと思います。

外部仕様からテストケースを作るワケ

ユニットテストを実践する理由はたくさんあります。 その中で、最も重要な理由は、安全なリファクタリングを行うことにあると言って良いでしょう。

リファクタリングとは「ソフトウェア(メソッドやクラス)の外部的振る舞いを変えずに内部構造を変更すること」です。 ユニットテストの対象となるクラスやメソッドは、何らかの責務を持つように設計されます。 そして、それらはどこか別のクラスやメソッドから利用されます。

どこか別のクラスやメソッドから利用されるということは、勝手に振る舞いが変わったならば大変なことになります。 このため、ユニットテストが浸透していない開発現場では、一度リリースしたコードには原則として変更を加えてはならない、一度リリースした後は外部ライブラリやJavaのバージョンを変更してはならない、といった悪習が残ることになります。 それらの悪習は、ユニットテストがないならば仕方ありません。 なぜならば、変更した時にどのような影響が出るか、全く解らないのです。

ユニットテストを実践することにより、これらの不安は大きく減ります。 少なくとも、クラスやメソッドに定義された仕様が変更されない限り、その動作が保証されるわけです。 そして、そのテストが成功している限り、内部の実装を自由に変更できることになります。

このため、ユニットテストでは、どれだけ外部仕様を網羅するように実装され、外部仕様を保証するかが重要なファクターとなります。

なお、どの程度まで保証したらば良いかは、そのクラスやメソッドがどの程度公開されるかに依存します。 外部ライブラリとして公開されるAPIなのか、プロジェクト内でのみ公開されるAPIなのかなどでも変わってくるでしょう。

FizzBuzzで外部仕様を考える

ユニットテストの主たる目的は安全なリファクタリングです。 したがって、外部仕様を元にブラックボックステストを行う事が理想と言えます。 しかし、現実としては内部ロジックを考慮して行うことが行うことが多いと言えます。 特にテスト駆動開発(TDD)を実践したり、TDD的なプログラミングを行っている場合は顕著になります。

ここで、みんな大好きFizzBuzzを例にしましょう。 FizzBuzzでは、整数が3の倍数の時にFizz、5の倍数の時にBuzz、3と5の公倍数の時にFizzBuzz、それ以外の時にその整数を表示していくプログラムです。 このプログラムを、ちょっと大袈裟に、設計するならば、整数と出力する文字列の変換部分が最も重要な処理となるということが解ります。 つまり、変換部分は次のようなメソッドとなります(実装はあえて省略)。

    /**
     * 整数をFizzBuzz文字列に変換する.
     * 整数が3の倍数の時にFizz、
     * 5の倍数の時にBuzz、
     * 3と5の公倍数の時にFizzBuzz、
     * それ以外の時にその整数を表す文字列を返す.
     * @param num 整数
     * @return 3の倍数の時にFizz、5の倍数の時にBuzz、
     *         3と5の公倍数の時にFizzBuzz、それ以外の時にその整数を表す文字列
     */
    public String toFizzBuzzStr(int num) {
        return null;
    }

このFizzBuzzシステムの最重要APIは、安全なリファクタリングのためにも、ユニットテストを書かなければなりません。 しかし、ここでユニットテストに慣れていないと、どこからどれだけテストすればいいの?という問題にぶちあたります。

テスト駆動開発とホワイトボックステスト

テスト駆動開発のこころ((c)@t-wada)は、「小さく、ひとつづつ」です。 外部仕様を満たすテストデータをひつつ選択し、それを満たすように実装・テストします。

まずは簡単に、適当な値でFizzでもBuzzでもFizzBuzzでもないテストケースを考えましょう。 そこで入力値として1を選択し、文字列1が取得できることが満たせればいいと考え、テストケースに落とし込みます。

    @Test
    public void toFizzBuzzStrは1の時1を返す() {
        FizzBuzz sut = new FizzBuzz();
        assertThat(sut.toFizzBuzzStr(1), is("1"));
    }

これのテストケースを満たすように、最小限の実装で実装するのがテスト駆動開発です。 プロダクションコードはこのようになります。

    public String toFizzBuzzStr(int num) {
        return "1";
    }

あえて、固定文字列1を返すように実装しているのは、仮実装(Fake it)という手法です。

次に、Fizzが返る入力値として3、Buzzが返る入力値として5、FizzBuzzが返る入力値として15と選択していき、仮実装も本来有るべき実装にすれば次のようになります。

public class FizzBuzz {
    /**
     * 整数をFizzBuzz文字列に変換する.
     * 整数が3の倍数の時にFizz、
     * 5の倍数の時にBuzz、
     * 3と5の公倍数の時にFizzBuzz、
     * それ以外の時にその整数を表す文字列を返す.
     * @param num 整数
     * @return 3の倍数の時にFizz、5の倍数の時にBuzz、
     *         3と5の公倍数の時にFizzBuzz、それ以外の時にその整数を表す文字列
     */
    public String toFizzBuzzStr(int num) {
        if (num % 15 == 0) return "FizzBuzz";
        if (num % 3 == 0) return "Fizz";
        if (num % 5 == 0) return "Buzz";
        return Integer.toString(num);
    }
}
public class FizzBuzzTest {
    @Test
    public void toFizzBuzzStrは1の時1を返す() {
        FizzBuzz sut = new FizzBuzz();
        assertThat(sut.toFizzBuzzStr(1), is("1"));
    }

    @Test
    public void toFizzBuzzStrは2の時2を返す() {
        FizzBuzz sut = new FizzBuzz();
        assertThat(sut.toFizzBuzzStr(2), is("2"));
    }

    @Test
    public void toFizzBuzzStrは3の時Fizzを返す() {
        FizzBuzz sut = new FizzBuzz();
        assertThat(sut.toFizzBuzzStr(3), is("Fizz"));
    }

    @Test
    public void toFizzBuzzStrは5の時Buzzを返す() {
        FizzBuzz sut = new FizzBuzz();
        assertThat(sut.toFizzBuzzStr(5), is("Buzz"));
    }
    
    @Test
    public void toFizzBuzzStrは15の時FizzBuzzを返す() {
        FizzBuzz sut = new FizzBuzz();
        assertThat(sut.toFizzBuzzStr(15), is("FizzBuzz"));
    }
}

Testing

内部ロジックを考慮するユニットテスト

FizzBuzzをステップバイステップで、ユニットテストを行っていくと、テストケースの追加と分岐(if文)の追加が連動していることが解ります。 言い換えれば、外部仕様に対する内部ロジックを追加し、それに対するテストケースを同時に作っているのです。

実は、テスト駆動開発の最も効果的な点はこの仕組みにあります。 たくさん処理(分岐や繰り返し)を実装した後にテストケースを考えようとしても、どうしても漏れが生じたり、そもそも面倒になってくるのです。 なので、1つ1つ倒しながら進んでいくことで、確実にユニットテストで守られたプロダクションコードを作ることができるわけです。 if文が追加されたら、それに対応するテストケースが必要、って考えれば当然ですよね。

これって、テストの分類から考えると、どちらかといえばホワイトボックステストです。 ブラックボックステストの観点からすれば、内部実装は他の会社に任せるなどして、テストケースだけを黙々として書くイメージでしょうか。 もちろん、テスト駆動開発を行っていたとしても外部仕様が増えたからテストケースを追加…と考える事もできますが、プログラマとしてはif文を追加したんだからテストも書かないと!と考える方が自然です。

さあ、リファクタリングの時間だ!

テスト駆動開発を行っていたにせよ、行っていなかったにせよユニットテストは充分に行われています。 現状では、パフォーマンスが悪いとクレームがついたため、判定回数を減らすように修正しようとなりました。 とりあえず、次のように修正すれば、最初のバージョンよりもより高速なFizzBuzzを提供できるでしょう!

    public String toFizzBuzzStr(int num) {
        if (num % 3 == 0) {
            if (num % 5 == 0) return "FizzBuzz";
            else return "Fizz";
        } else if (num % 5 == 0) { 
            return "Buzz";
        }
        return Integer.toString(num);
    }

可読性は悪くなりましたが、判定効率はあがったはずです。 ここでユニットテストを実行し、成功する事を確認出来れば、外部的な振る舞いに影響を与えていないことが解ります。

さて、ここで内部実装とテストコードの対応を確認してみます。 すると、初期バージョンでは、テストケースと内部実装がほぼ1対1に対応していたのですが、リファクタリング後のコードでどの分岐とどのテストケースが対応するかは解りにくくなりました。

もし、最初から複雑なロジックで書かれた内部実装があり、それに対するユニットテストを書くという状況であれば、ユニットテストはブラックボックステストになるでしょう。 しかし、テスト駆動開発を採用している時のように、実装とテストをほぼ同時に行い、リファクタリングはユニットテストのある状況で行うならば、はじめはホワイトボックステスト的となります。 その方が簡単で楽にテストを書くことができますね。

ホワイトボックステストは内部実装に大きく影響されるため、正しいのか正しくないのか良くわからないプログラムや、複雑すぎて理解ができないプログラムに行うと危険です。 しかし、シンプルにひとつづつ内部実装を増やしていくテスト駆動開発と組み合わせると非常に強力なテスト手法となります。 外部仕様と内部実装とテストコードがすべて対応している状況を作り、最後にリファクタリングを行うのがベストプラクティスでしょう。

一方、複雑すぎるスパゲティなシステムや、エンドトゥエンドのテストでは、内部実装を考慮するよりも外部仕様のみに着目し、ブラックボックステストとして、テストケースを組み立ててください。 これはコスト的な観点が一番大きいですが、効率よく、価値のあるテストを行うために必要となるでしょう。

ホワイトボックステストとブラックボックステストはそれぞれの特徴を生かして、上手に使いわけてください。

なお、弊社はホワイト企業です。

Have a nice testing!