オブジェクト指向プログラミングにおける単体テストのしかた

この記事は TDD Advent Calendar 2012 の最終日の記事です。前日の記事は@biacさんの「[コラム] テストファーストとは何か?: TDD.NET」、初日の記事は@sue445さんの「Try Dream Development : 夢の開発を始めよう #TddAdventJp - くりにっき」です。

TDD Advent Calendarの記事なのですが、TDDというより単体テストの話です。単体テストのやり方について人それぞれやり方があるかと思いますが、自分が単体テストをするときの手順をまとめてみました。以下がその手順です。

  1. テストするメソッドを決める。
  2. メソッドの仕様を確認する。
  3. 事前条件をいくつかに場合分けする。
  4. 上で分けた場合ごとに、テストケース用のメソッドをつくる。
  5. テスト対象のクラスのインスタンスを生成し、場合分けしたひとつの事前条件を満たすコードを追加する。
  6. テストメソッドを呼び出すコードを追加する。
  7. 戻り値をチェックするコードを追加する。
  8. 状態をチェックするコードを追加する。
  9. テストをビルドして実行する。
  10. テストが通らなければ修正する。
  11. まだテストしていない場合わけがあるときは、4に戻る。
  12. まだテストしていないメソッドがあるときは、1に戻る。

ポイントは「状態をチェックする」です。副作用があるメソッドの場合には、状態のチェックは欠かせません。たまに、戻り値のチェックのみをしているテストを見かけますが、必ず状態のチェックをしましょう。

以下では、例題を使って各手順の詳細を説明します。例題は、整数型を要素とするリストです。例題のソースコードは、https://github.com/masateruk/blog/tree/master/20121225 にあります。

class Node {
public:
    Node(int n);  /* コンストラクタ */
    int get();  /* 要素を取得する */
    Node* next();  /* 次のノードを取得する */
};

class List {
public:
    int length();  /* 要素数を取得する */
    Node* get_head();  /* 先頭のノードを取得する */
    void add_head(Node* n);  /* 先頭にノードを追加する */
};

1. テストするメソッドを決める。
 まずは、add_headのテストをすると決めたとしましょう。テストする時間が十分にあればすべてのメソッドをテストしましょう。時間が十分にとれないようだったら、バグが潜んでいる可能性が高そうなメソッド(たとえば、複雑であるとか、新規に追加した)をテストしましょう。

2. メソッドの仕様を確認する。
 テストの目的のひとつは、実装が仕様を満足しているかどうかを確認する事です。テストするには、対象の仕様を確認する事から始めます。仕様は事前条件と事後条件からなります。add_headの事前条件は「引数nは、NULLでないNode型のオブジェクト(へのポインタ)である」。事後条件は「事後のリストs'は、事前のリストsの先頭にノードnを追加したもの」です。

3. 事前条件をいくつかに場合分けする。
 事前条件を場合分けして、その場合ごとにテストします。場合分けするヒントは事後条件にあります。事後条件が「Aのとき〇〇、Bのとき〇〇、それ以外のとき〇〇」となっている場合は、それぞれA, B, それ以外を場合として列挙します。事後条件の表現にもよりますが、事前の状態に関しての場合分けとメソッドのパラメータについての場合分けを行い、それぞれの組み合わせから事後条件で表現にある場合をつくるのが良いと思います。add_headの場合、事後条件には事前の状態およびパラメータに関する事前条件が特にないので、事後条件をもとにすると場合分け数はひとつになります。
 ブラックボックステストでは事後条件をもとに場合分けをするのが基本ですが、それに加えて実装に分岐がありそうなもので場合分けをすると有効なテストとなります。add_headでは、(i) 空リストの場合と (ii) 空リストでない場合に分けることにします。

4. 上で分けた場合ごとに、テストケース用のメソッドをつくる。
 場合ごとにテストケースを書きます。さっそくadd_headの (i) 空リストの場合のテストケースを書いてみましょう。私はひとつのテストケースに対してひとつのメソッドを割り当てます。

void test_add_head_when_list_is_empty()
{
}

5. テスト対象のクラスのインスタンスを生成し、場合分けしたひとつの事前条件を満たすコードを追加する。
 続けてテストメソッドの中身を書いていきます。Listオブジェクトは初期状態では空リストなので単にオブジェクトを生成するだけです。

void test_add_head_when_list_is_empty()
{
    List l;
}

6. テストメソッドを呼び出すコードを追加する。
 引数で渡すNodeオブジェクトを作ってadd_headを呼び出します。

void test_add_head_when_list_is_empty()
{
    List l;
    Node n(11);

    l.add_head(&n);
}

7. 戻り値をチェックするコードを追加する。
 add_headは戻り値を返さないので、ここでは何も追記しません。

8. 状態をチェックするコードを追加する。
 ここが重要なポイントです。事後条件によると、リストの先頭に要素が追加されているはずなので、それらをチェックします。

void test_add_head_when_list_is_empty()
{
    List l;
    Node n(11);

    l.add_head(&n);

    // 長さのチェック --- (a)
    TEST_ASSERT(l.length() == 1);
    // add_headで追加した要素が確かに取得できる --- (b)
    TEST_ASSERT(l.get_head()->get() == 11);
    // 11の要素しかふくまれないことを確認するassertion --- (c)
    TEST_ASSERT(l.lget_head()->next() == NULL);
}

 状態のチェックは利用できるメソッドをすべて使って行います。(c)のassertは、add_head()によって11以外の余計な要素が含まれていないを確認するためのものです。例えば、(a), (b) しかないと以下のような実装もテストが通ります。

class List {
public:
    int length();
    Node* get_head();
    void add_head(Node* n);

private:
    int m_length;
    Node* m_head;
};

List::List()
    : m_length(0), m_head(NULL)
{
}

int List::length()
{
    return m_length;
}

Node* List::get_head()
{
    return m_head;
}

void List::add_head(Node* n)
{
    m_length++;
    m_head = new Node(n->get()); // わざと余計な要素を追加
    m_head->m_next = n;
}

かなりわざとらしいですが、(c)がなければ上記のようなノードをひとつ余計に追加する実装でもテストが通ってしまいます。よって、状態のチェックは利用できるすべてのメソッドで確認することが重要です。

9. テストをビルドして実行する。
 テストメソッドを書いたらビルドして実行します。

10. テストが通らなければ修正する。
 テストが通らない場合は修正します。テスト駆動開発ではこの段階で実装を始めます。

11. まだテストしていない場合わけがあるときは、4に戻る。
 3で列挙した場合わけが残っている場合は、そのテストも同様の手順で書きます。(ii) 空リストでない場合のテストは以下のようになります。

void test_add_head_when_list_is_not_empty()
{
    List l;
    Node n1(11), n2(12), n3(13);

    l.add_head(&n1);
    l.add_head(&n2);

    l.add_head(&n3);

    TEST_ASSERT(l.length() == 3);
    TEST_ASSERT(l.get_head()->get() == 13);

    // 以下はadd_head後も先に入っていた12, 11が変わらないことを確認するassertion -- (d)
    TEST_ASSERT(l.get_head()->next()->get() == 12);
    TEST_ASSERT(l.get_head()->next()->next()->get() == 11);
    TEST_ASSERT(l.get_head()->next()->next()->next() == NULL);
}

ここでも(d)が重要な役割を果たします。もし(d)がなければ以下のような実装がテストを通ってしまいます。

void List::add_head(Node* n)
{
    m_length++;
    m_head = n;
}

テストしているメソッドにより変化を起こす状態をチェックするとともに、変化してはいけないものが変化していないことも漏れなく確認しましょう。

12. まだテストしていないメソッドがあるときは、1に戻る。
 同じ手順を他のメソッドについて行います。すべてのメソッドをテストしたら終了です。

すべてのメソッドをテストしたら終了です。最後によく聞かれる質問とその回答をふたつあげておきます。

Q1. 事前条件を満たさないテストはしますか?
 しません。なぜなら事前条件を満たさない呼び出しでは何も保証しないからです。よって、テストを書く呼び出し側からすれば、エラーを返ってくることを期待できません。アサートで落とされたりするかもしれません。さらに呼び出しから返ってこないかもしれません。以上のことから、メソッド呼び出し後の期待値を書けません。よってテストしません。

Q2. 情報取得のメソッドが不足していてチェックしたい情報がそろわないときはどうしますか?
 情報取得のメソッドが十分にそろっていないときは、副作用のあるメソッドを使って仕様である状態遷移に従って遷移できるかどうか確認します。

以上、オブジェクト指向プログラミングにおける単体テストの仕方を私なりのまとめてみました。これは契約による設計(Design by Contract : DbC)を学んで自分なりに考えたやり方です。契約による設計を基礎から学びたい方は下の本がおすすめです。

オブジェクト指向入門 第2版 原則・コンセプト (IT Architect’Archive クラシックモダン・コンピューティング)

オブジェクト指向入門 第2版 原則・コンセプト (IT Architect’Archive クラシックモダン・コンピューティング)

最終日の記事なので、TDD Advent Calendarを総括するような記事が期待されたかもしれませんが、普通にテストに関する記事にさせていただきました。最終日の記事を練っていた方もいたかと思いますが、快く担当させていただき感謝しています。ありがとうございます。

では、最後に。

メリークリスマス