TDDを体験する

前回準備した「PHPUnit」のサンプル。「オブジェクト倶楽部」で紹介されている「車窓からのTDD」(PDF)という記事を参考に、TDD(Test Driven Development / テスト駆動開発)とペアプログラミングの演習記事をPHPUnitでなぞってみた。残念ながら自宅で独りで書いたので、ペアプロのサンプルにはならないが。

作るのはスタッククラス。仕様は…

  • isEmpty()でスタックが空の場合、true。それ以外false を返す。
        boolean isEmpty()
  • size()でスタックのサイズを取得する。
        int size()
  • push()で引数の値をスタックの一番上に積む。
        void push(int value)
  • pop()でスタックの一番上の値を取り除く。
        void pop()
         スタックが空の場合、java.util.EmptyStackException が発生する
  • top()でスタックの一番上の値を取得する。
        int top()
         スタックが空の場合、java.util.EmptyStackException が発生する
  • (p.3)

…となっている。

2.長浜駅 ~Assert ファースト~

「コンパイルエラー」あたりはPHPだと実行時Fatal Errorが出る。コードは割愛してPHPUnitが動作するあたりから書いていく。

Fatal error: Class 'Stack' not found in \\path\to\source\testStack\StackTest.php on line 8
Fatal error: Call to undefined method Stack::isEmpty() in \\path\to\source\testStack\StackTest.php on line 9

まずはテストクラスを作成。BGが緑っぽい(#003300)コードがテストクラス。

<?php
// testStack/StackTest.php
require_once 'PHPUnit/Framework.php' ;
require_once '../stack/Stack.php' ;
class StackTest extends PHPUnit_Framework_TestCase
{
    // オブジェクトを生成、中身が空なのをテストする。
    public function testCreate()
    {
        $stack = new Stack() ;
        $this->assertTrue($stack->isEmpty()) ;
    }
}
?>

テスト対象のStackクラスを作成。BGが青っぽい(#000033)コードがStackクラス。

<?php
// stack/Stack.php
class Stack
{
    // 実装はしない。必ずテストが失敗する値を返す。
    public function isEmpty()
    {
        return false ;
    }
}
?>

3.長浜と敦賀の間 ~最初は赤で、次に緑、そしてリファクタリング!!~

それではテストを実行してみよう。

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.


F

Time: 0 seconds

There was 1 failure:

1) testCreate(StackTest)
Failed asserting that <boolean:false> is true.
\\path\to\source\testStack\StackTest.php:9

FAILURES!
Tests: 1, Failures: 1.

CUIなので「赤」とか「緑」とか出ないのがちょっと寂しいが、テストは予定通り失敗(赤)している。このエントリに出てくるエラー報告の行番号は若干ずれているので、実際は書きながら確認していただきたい。

次はテストを通すため「だけ」の実装を行う。これを「フェイク」という。

<?php
class Stack
{
    // テストを通す「だけ」の実装(フェイク)。
    public function isEmpty()
    {
        return true ;
    }
}
?>

テストの実行結果は…

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.


.

Time: 0 seconds


OK (1 test)

テストが通った(緑)。

次はisEmptyを実装したくなるが、そこをガマンして次のテストを書く。

<?php
require_once 'PHPUnit/Framework.php' ;
require_once '../stack/Stack.php' ;
class StackTest extends PHPUnit_Framework_TestCase
{
    // オブジェクトを生成、中身が空なのをテストする。
    public function testCreate()
    {
        $stack = new Stack() ;
        $this->assertTrue($stack->isEmpty()) ;
    }

    // 値をひとつpush、topで取り出してその値が値が帰ってくるのを確認。
    public function testPushAndTop()
    {
        $stack = new Stack() ;
        $stack->push(1) ;
        $this->assertEquals(1, $stack->top()) ;
    }
}
?>

stackオブジェクト(テスト対象)のインスタンス生成が重複している。この先も行うのは明白なので、ここでリファクタリングしておく。

<?php
require_once 'PHPUnit/Framework.php' ;
require_once '../stack/Stack.php' ;
class StackTest extends PHPUnit_Framework_TestCase
{
    // クラス変数に変更。
    protected $stack ;

    // すべてのテストメソッドの実行前に呼び出される。
    protected function setUp()
    {
        $this->stack = new Stack() ;
    }

    // オブジェクトを生成、中身が空なのをテストする。
    public function testCreate()
    {
        $this->assertTrue($this->stack->isEmpty()) ;
    }

    // 値をひとつpush、topで取り出してその値が値が帰ってくるのを確認。
    public function testPushAndTop()
    {
        $this->stack->push(1) ;
        $this->assertEquals(1, $this->stack->top()) ;
    }
}
?>

テストに失敗する実装を行う。

<?php
class Stack
{
    // fake
    public function isEmpty()
    {
        return true ;
    }

    // fake
    public function push($value)
    {
    }

    // テストに失敗する
    public function top()
    {
        return 0 ;
    }
}
?>

テストを実行。

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.


.F

Time: 0 seconds

There was 1 failure:

1) testPushAndTop(StackTest)
Failed asserting that <integer:0> matches expected value <integer:1>.
\\path\to\source\testStack\StackTest.php:16

FAILURES!
Tests: 2, Failures: 1.

予定通り赤を確認。緑になるフェイク実装を行う。

    // fake
    public function top()
    {
        return 1 ;
    }

テスト結果は…

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.


..

Time: 0 seconds


OK (2 tests)

緑。ここでやおらリファクタリングして、フェイク実装を本物に近づけていく。まず、pushされた値をtopで返せるように修正。

<?php
class Stack
{
    private $value ;            // とりあえずひとつ値を保存するように

    // fake
    public function isEmpty()
    {
        return true ;
    }

    public function push($value)
    {
        $this->value = $value ; // 引数で与えられた値を保存
    }

    public function top()
    {
        return $this->value ;   // 保存した値を返す
    }
}
?>

もちろんすぐテスト、緑を確認。結果はさっきと同じなので略。

次に、pushするとサイズが増えることを確認するテストを書く。

    // pushしたらサイズが1になるのを確認
    public function testPushAndSize()
    {
        $this->stack->push(1) ;
        $this->assertEquals(1, $this->stack->size()) ;
    }

同じようにテストに失敗するコードを書き、次にフェイクを書く。

    // 失敗するコード → のちfake
    public function size()
    {
//      return 0 ;              // 失敗(赤)
        return 1 ;              // fake(緑)
    }

4.敦賀駅 ~とらいあんぎゅれーしょん??~

pushしたらsizeは1、もう一度pushしたらsizeは2、というきわめて当たり前の動作をするかどうか、そのテストをまず書く。

    // pushしたらサイズが1になるのを確認
    public function testPushAndSize()
    {
        $this->stack->push(1) ;
        $this->assertEquals(1, $this->stack->size()) ;
        $this->stack->push(2) ;
        $this->assertEquals(2, $this->stack->size()) ;
    }

簡単な実装を手間隙かけてやってるなー、と感じるかもしれないが、この積み重ねが変更に強く信頼性の高いコードを書くための礎となるのだ…と、気持ちを強く持って書き進めたい。

「2回push したら、サイズが2になるはずですよね?そういうテストを追加し、先のテストと今回追加したテストを通るような一番シンプルなプログラムを考えるわけです。」「もう一回赤の状態にもどしてから、前に進む。コードを変更したり追加するときは、必ず赤にするのがTDD の掟。」

(p.7)

テストを実行してみる。

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.


..F

Time: 0 seconds

There was 1 failure:

1) testPushAndSize(StackTest)
Failed asserting that <integer:1> matches expected value <integer:2>.
\\path\to\source\testStack\StackTest.php:27

FAILURES!
Tests: 3, Failures: 1.

赤。緑になるように必要最小限のリファクタリングを行う。サイズを保持するようにして、pushされたら加算 / sizeで値を返すようにすればよさそうだ。

<?php
class Stack
{
    private $value ;            // とりあえずひとつ値を保存するように
    private $size = 0 ;         // stackのサイズ

    // fake
    public function isEmpty()
    {
        return true ;
    }

    public function push($value)
    {
        $this->value = $value ;
        $this->size ++ ;        // pushされたらサイズを加算
    }

    public function top()
    {
        return $this->value ;
    }

    /**
     *  現在のスタックサイズを取得
     *      @return     integer                     スタックの要素数
     */

    public function size()
    {
        return $this->size ;
    }
}
?>

サイズを管理できるようになったので、fakeのままだったisEmptyもリファクタリングする。

    /**
     *  スタックが空かどうかを返す
     *      @return     boolean                     true = スタックは空
     */

    public function isEmpty()
    {
        return $this->size == 0 ;
    }

テスト(赤)→実装(緑)→リファクタリング(緑)のループを「トライアンギュレーション」という。

「上手くいきましたね。この方法はトライアンギュレーション(Triangulation)と言って、テストケースをいくつか用意し、その全てのテストケースを通過するコードを書くことで,一般解を導きくという方法です。テスト1つだと,フェイクで済んでしまうでしょ,そこにテストを足して,フェイクでは通らないようにする.そこから,正しい実装を導くんです.三角測量のように,2点から測量してその交差点上に解を作ります.TDD 開発のパターンの一つです。」

(p.8)

5.北陸トンネルね!いっちょらい! ~Exception テスト~

「空のStackからpop / topすると例外を送出」という仕様をテストするコードを書く。送出する例外は 組み込み例外クラスから適当に選択した。

    // 空のスタックからpopしたら例外が送出されることを確認
    public function testEmptyPop()
    {
        try
        {
            $this->stack->pop() ;
            $this->fail() ;     // ここを通ったら失敗
        }
        catch (OutOfRangeException $e)
        {
            // 例外がthrowされればテスト成功 = なにもしない
        }
    }

    // 空のスタックからpopしたら例外が送出されることを確認
    public function testEmptyTop()
    {
        try
        {
            $this->stack->top() ;
            $this->fail("testEmptyPop failed.") ;     // ここを通ったら失敗
        }
        catch (OutOfRangeException $e)
        {
            // 例外がthrowされればテスト成功 = なにもしない
        }
    }

Stackクラスにはpopメソッドが実装されていないので、とりあえず赤が出る実装をしておく。

    public function pop()
    {
        // 何も返らない = 失敗
    }

テストを実行してみる。失敗の理由がわかりづらいときはfail("message")とすればよい。

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.

...FF

Time: 0 seconds

There were 2 failures:

1) testEmptyPop(StackTest)
\\path\to\source\testStack\StackTest.php:44

2) testEmptyTop(StackTest)
testEmptyPop failed.
\\path\to\source\testStack\StackTest.php:58

FAILURES!
Tests: 5, Failures: 2.

赤を確認したので、テストに通る(緑)→リファクタリング(緑)の順でコードを書く。

まずはフェイク。緑にするためだけの実装。

    public function pop()
    {
        // fake
        throw new OutOfRangeException() ;
    }

次にリファクタリング。スタックが空のときに呼び出されたら例外をthrowするように修正。もちろんテストは緑。

    public function pop()
    {
        // emptyかどうかチェック、emptyの場合は例外をthrowするように
        if ($this->isEmpty())
        {
            throw new OutOfRangeException() ;
        }
    }

同様にtopメソッドも緑になるようリファクタリング。

    public function top()
    {
        // emptyかどうかチェック、emptyの場合は例外をthrowするように
        if ($this->isEmpty())
        {
            throw new OutOfRangeException() ;
        }
        return $this->value ;
    }

同じ処理があるのでここもリファクタリングしてしまう。

    public function pop()
    {
        $this->emptyCheck() ;
    }

    public function top()
    {
        $this->emptyCheck() ;
        return $this->value ;
    }

    /**
     *  スタックが空のときに例外を送出する
     *      @throws     OutOfRangeException
     */

    private function emptyCheck()
    {
        if ($this->isEmpty())
        {
            throw new OutOfRangeException() ;
        }
    }

この場合のemptyCheckをExtractMethodという。

6.トンネルを抜けると、そこは?! ~TDD ラストスパート~

次は「popしたら一番上の要素を取り除く」テスト。pushしてpopしたら、サイズはゼロに戻るはず。

    public function testPushAndPop()
    {
        $this->stack->push(1) ;
        $this->stack->pop() ;
        $this->assertEquals(0, $this->stack->size()) ;
    }

テストを実行してみる。

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.

.....F

Time: 0 seconds

There was 1 failure:

1) testPushAndPop(StackTest)
Failed asserting that <integer:1> matches expected value <integer:0>.
\\path\to\source\testStack\StackTest.php:71

FAILURES!
Tests: 6, Failures: 1.

予定通り赤。popしたらsizeは減らさないといけない。

    public function pop()
    {
        $this->emptyCheck() ;
        $this->size -- ;
    }

実装したらテスト。

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.


......

Time: 0 seconds


OK (6 tests)

緑になったのでひと安心。

次は、スタックとして肝心な要素であるFILO(First In Last Out – 最初に入れたものが最後に出てくる)のテストを書いてみよう。

    public function testPushPushPopTop()
    {
        $this->stack->push(1) ;
        $this->stack->push(2) ; // この時点でstackは[2,1]

        // 後入れの[2]が取り出せることをテスト
        $this->assertEquals(2, $this->stack->top()) ;
        $this->stack->pop() ;   // 後入れ要素をpop -> 現在stackは[1]
        // [1]が取り出せることをテスト
        $this->assertEquals(1, $this->stack->top()) ;
    }

現在値はひとつしか保持してないから当然赤。

\\path\to\source\testStack>phpunit StackTest
PHPUnit 3.2.21 by Sebastian Bergmann.

......F

Time: 0 seconds

There was 1 failure:

1) testPushPushPopTop(StackTest)
Failed asserting that <integer:1> matches expected value <integer:2>.
\\path\to\source\testStack\StackTest.php:81

FAILURES!
Tests: 7, Failures: 1.

値は配列で保持し、現在一番上の要素とサイズが対応するので出し入れに利用する。

    private $value = array() ;  // stackの値を保持する配列

    /**
     *  スタックに値を積む
     *      @param      integer     $value          積む値
     */

    public function push($value)
    {
        $this->value[$this->size ++] = $value ;
    }

    /**
     *  スタックから先頭の値を取り出す
     *      @return     integer                     先頭の値
     *      @throws     BadFunctionCallException    スタックが空の場合
     */

    public function top()
    {
        $this->emptyCheck() ;
        return $this->value[$this->size - 1] ;
    }

もちろん書いたらすぐテスト。

\\path\to\source\testStack>phpunit StackTest


.......

Time: 0 seconds


OK (7 tests)

緑。

これで当初提示された仕様は満たしたので開発完了とする。

6.まとめ ~TDD の利点~

「6.」がふたつあるのは元ドキュメントの仕様である。

まぁ確かに各コードの量は倍増以上だし、納期やらなんやらに追いまくられる現場で「直接動作に必要ないコード」を書くのはなかなか大変だと思う。しかし、開発者の敵である「仕様変更」と戦うには重要だし、テストを書くことでロジックが整理され、結果キレイでバグの少ないコードが書ける、というメリットは想像以上に大きい。

TDD の効用:

  • すべてのコードがテストを通った状態で完成する。
  • 実装よりも使う側のインターフェイスを重視した設計になる。
  • テスト可能性を確保した設計になる。
  • 開発にリズムができて、楽しい!
(p.14)

ちゃんとユニットテストを書く価値がある仕事が増えることを祈りつつ、今後書くコードにはちゃんとテストも書こう…と決意しながら本エントリ終わり。今回書いたStack / TestStackの完成版を固めておくので、興味のある方は参照されたい。

1 comment

  1. DQN起業日記 1月 24, 2009 6:27 am  返信

    [PHP] limeでTDDを体験する

    PerlのTest::Moreに相当するものが、PHPではlime.phpという…

Leave a comment

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です