PHP
CakePHP
TDD
PHPDay 21

PHPでTDD開発するまでに通った道のり

はじめに

これはPHP Advent Calendar 2017の21日目の記事です。
今年の下半期はPHP conference 2017の少し後くらいから、t_wadaさんよりテスト駆動開発の発売を契機に、テストに対する投稿やtweetをよく見る時期がありました。
私自身、現職に入るまでテストコードを書いてこなかったのでトレンドに刺激され、業務・趣味の両輪でTDDを身につけられるよう修行の身です。

ただ、実際にやり始めると現実には様々な壁が立ちはだかります。

この記事の内容は、テストコードを書き始めてからの自身の経験をステージ分けして、
どういうテストスキル・テストマインドをその時に持ったかをまとめたものです。

これからテストコードを書いていく人・そういう人を指導する立場にある人にとって有益な情報となれば幸いです。

 注釈

  • 本文中に現れるコードは、CakePHP2系をベースにしています。
  • 本文中に現れるコードは、業務での話をベースにしているため近しいサンプルコードという形で記載させていただいています。

目次

  • Stage-0: とにかくテストを書いてみる
  • Stage-1: テスト観点を網羅した小さいテストを書く
  • Stage-2: 既存の小さいコードにテストを書く
  • Stage-3: 小さいコードをテストファーストで実装する
  • Stage-4: 新規の大きいコードにテストを書く
  • Stage-5: 既存の大きいコードにテストを書く
  • Stage-6: 大きいコードを(半人前な)TDDで実装する
  • Stage-X: Next Stage

Stage-0: とにかくテストを書いてみる

SIerからweb業界に転身してきた私。この時点ではこんな感じ。

  • テストスキル
# テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
  • テストマインド
項目 5段階中 テストマインド
自信  そもそも書いたことないからちょっと心配 
積極性 ☆☆☆  Excel使わなくていいから普通にやりたい 

さて、実装タスクが目の前にあるので実装、「まだ使わないメソッドだしまぁテストはいっか(気楽)」という気持ちでプルリクを投げるとどこからともなくコメントが、、、

いや、使ってなくてもテストいるやろ!

「やばい、テスト書かないと抹殺される、、、(ガクブル)」
関西出身の私でも、東京で強めの関西弁をいただくと震えます。そんなわけで、入社直後の私が見よう見まねで書いたテストがこんな感じでした。

class QiitaSampleBehaviorTest extends CakeTestCase
{
    public function setUp()
    {
        parent::setUp();
    }
    public function tearDown()
    {
        parent::tearDown();
    }
    /**
     * test qiitaSample method
     */
    public function testQiitaSample()
    {
        $this->SampleTestModel = ClassRegistry::init('SampleTestModel');
        $data = array(
            'SampleTestModel' => array(
                'alpha' => 'abcde',
                'num' => 12345,
                'half_kana' => 'アイウエオ',
                'em_kana' => 'アイウエオ',
                'em_hiragana' => 'あいうえお',
                // 以下様々な文字列パターン
            )
        );
        // それぞれの文字種に対して返り値をチェックする。
        $this->SampleTestModel->set($data);
        $this->assertTrue($this->SampleTestModel->qiitaSample('alpha');
        ...
// 以下略

テスト対象メソッドは、独自Validationルールを提供するもので、文字列に対してチェック処理をします。
正常系でメソッドが正しく返り値を返すパターンを並べてassertをかけるテストを書いていました。

一回、書いてしまえばなんとなく要領がつかめ、ちょっとだけテストスキルが上がりました。

Stageクリア後

# テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
0 単一クラスの正常系のテストコードが書ける

小さいテストなら書けるようになりました。一歩前進ですね。テストマインドの方はどうでしょうか。

項目 5段階中 テストマインド
自信 ☆☆  まぁ普通になんとかなりそう 
積極性 ☆☆☆  テスト書いとくと実装に自信が持てるからいい感じ、でもまさかり怖い(震え声) 

実際、テストは「コードを書く」こと自体はそこまで難儀なものでは決してないことに気が付いて少し自信がついてます。
積極性に関してはテストの利点を知りつつ、「まさかりが飛んでこないように書いておこう」という状態です。

Stage-1: テスト観点を網羅した小さいテストを書く

「とにかくテストを書く」の段階では、テストを書いておいたほうが良いらしいから書いておくという心持ちで書いているので、最低限正常系くらいはという気持ちで書いていました。

次の実装でも同様に正常系のテスト込みの実装のプルリクを投げました。おっと、コメントがついてるぞ(緊張の面持ち)

このメソッドは、このケースでは例外を返しますよね?例外を返すこともテストしておいたほうがいいですよね。

冷静で鋭いツッコミおっしゃる通り、ちゃんと動作を保証するにはメソッドのIN/OUTで起きうるパターンを網羅したテストを書かねば、、、!
その時に書いたテストコードはだいたいこんな感じです。

    /**
     * test qiitaSample method
     * 正常系
     */
    public function testQiitaSampleRegular()
    {
    // 省略
    }

    /**
     * test qiitaSample method
     * 異常系
     * @expectedException InvalidArgumentException
     * @expectedExceptionMessage Fields contain wrong parameter.
     */
    public function testQiitaSampleInvalidLength()
    {
        $data = array(
            'SampleTestModel' => array(
                'alpha' => 'abcde',
            )
        );
        $this->SampleTestModel = ClassRegistry::init('SampleTestModel');
        $this->SampleTestModel->set($data);
        $this->SampleTestModel->qiitaSample('hogehoge');

    }

期待していない引数の場合は、InvalidArgumentExceptionを返す仕様をテストしています。
例外をキャッチすることもテストする術を覚えました。

Stageクリア後

Level テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
0 単一クラスの正常系のテストコードが書ける
1 単一クラスの正常系・異常系を網羅したテストコードが書ける

自分の書いたメソッドに対しては、ある程度ちゃんとしたテストが書けるようになりました。。テストマインドの方はどうでしょうか。

項目 5段階中 状態
自信 ☆☆☆  assert術はもう覚えたぞ、、、! 
積極性 ☆☆☆☆  自分の書いたものにはパターンを網羅したテストを書いて次の開発者に安心してもらおう 

Stage-2: 既存の小さいコードにテストを書く

Stage-1で自分で書いた小さなコードくらいであればパターンを網羅したテストコードがかけるようになりました。
少しテストコードを書く前の表情も緩み余裕が見え始めています。

そんなときにバグ修正のチケットがきました。
せっせと実装して修正完了、動作確認もOK、プルリクを投げ、、、おっとまたStage-0に戻るところでした。「前実装した人がテスト書いてないしいいでしょ・・・。」という誘惑をグッとこらえテストを書き始めます。

既存のコードに対するテストを書く際は、実装した人がどういう期待値を持って書いたかはコードにしかありません。(設計資料や過去の対応チケットのURLが分かっていれば別ですが。)
なので、コードを読んで既存の振る舞いをテストという形で定義するようなイメージになります。既存の振る舞いが分かれば後はすでに身につけたスキルでテストを書くことができます。

Stageクリア後

Level テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
0 単一クラスの正常系のテストコードが書ける
1 単一クラスの正常系・異常系を網羅したテストコードが書ける
2 既存の単一クラスの振る舞いの期待値をコードから読み取ることができる

現在、テストがない部分に対してもテストがかけるようになりました。活躍の場が広がっていきます。

項目 5段階中 状態
自信 ☆☆☆☆  テストコードはもう書ける。 
積極性 ☆☆☆☆☆  テストコードに自分のメソッドに対する期待値を込めることができる効果を知り、以前やる気が上がる 

「テストコードを書く」ということに関して効果を実感し始め、もうまさかりの恐怖なくとも自らテストをかけるようになっています。

Stage-4: 小さいコードをテストファーストで実装する

テストを書くことに自信がで始めそろそろ自分なりの創意工夫をしたくなってくる時期。このとき、TDDが話題に再浮上した真っ盛りということもありテストを書いて実装するという手法を試してみます。

  • テストを書く
    /**
     * confirm Validation rule is set.
     */
    public function testValidationRule()
    {
        $expected = array(
            'request_type' => array(
                'inList' => array(
                    'rule' => array('inList', array('hoge1', 'hoge2')),
                    'message' => 'hoge または hoge1を指定してください。',
                    'required' => true,
                )
            ),
        );
        $actual = $this->QiitaSample->validate;
        $this->assertSame($expected, $actual);
    }
  • テストを流してテスト失敗することを確認してから、実装開始。
    /**
     * Validation rules
     *
     * @var array
     */
    public $validate = array(
        'request_type' => array(
            'inList' => array(
                'rule' => array('inList', array('hoge1', 'hoge2')),
                'message' => 'hoge または hoge1を指定してください。',
                'required' => true,
            )
        ),
    );
  • テストが通ってOK!

テストで先に振る舞いを定義してそれに向かって実装・リファクタリングをしていくTDD(正確にはテストファーストという表現が正しいですね)のやり方を少し身に付けることができました。

Stageクリア後

Level テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
0 単一クラスの正常系のテストコードが書ける
1 単一クラスの正常系・異常系を網羅したテストコードが書ける
2 既存の単一クラスの振る舞いの期待値をコードから読み取ることができる
3 単一クラスについてテストを書いて実装するテストファーストを実践する

テストコードを書くという行為自体を開発手法を含めて実践し始めました。

項目 5段階中 状態
自信 ☆☆☆☆  テストから書くやり方も覚えたぞ! 
積極性 ☆☆☆☆☆  テストから書くと超いい感じ! 

ますますやる気がみなぎってますね。

Stage-4: 新規の大きいコードにテストを書く

小さいコードにテストを書く工程をクリアしました。ある程度細かい実装タスクが終了しどんと大きめの機能開発がやってきます。ある程度大きいと、複数モジュールを横断する実装になり呼び出し元のController側の処理になると結構な大きいものになってきますね。
まずは、実装してからテストを書きます。

class QiitaSampleTest extends CakeTestCase {

    public function setUp() {
        parent::setUp();
        // テスト対象オブジェクトを生成する
        $this->qiita = new QiitaSample();

        // LifecardAtobaraiConnectionオブジェクトのモックを生成する
        $this->qiita->con = $this->getMock('QiitaSample', array('method1'));
    }

    public function tearDown() {
        timecop_return();
        parent::tearDown();
    }

    /**
     * test charge method Success
     */
    public function testchargeSuccess() {
        Timecop::freeze(new DateTime("2017-11-15 16:17:56"));
        // QiitaSampleオブジェクトのモックを生成する
        // 期待するリクエスト・レスポンスを定義
        $expected_params = array(
            // 必要な引数
        );

        $expected_response = array(
            // 期待される返り値
        );
        // Mock作成時に期待したパラメータが生成されているかを検証する。
        $this->qiita->con->expects($this->once())
            ->method('method1')
            ->with($this->equalTo($expected_params))
            ->will($this->returnValue($expected_response));

        // メソッド実行に引数を用意する
        $param = array();
        // メソッド実行
        $actual_response = $this->qiita->method1(param);
        $this->assertEquals($expected_response, $actual_response);
    }
// 以下続く
}

今回は、外部連携が含まれるテストになり単一システム内で完結しない処理に対するテストコードを書いた際の抜粋版です。
少し大きい実装をちゃんとテストできるようにしようとなると、テストしやすいように疎結合な実装が必要になってきます。
当時は、テストコードを書くまではかなり密結合な実装をしてしまいテストが書けるようにリファクタリングしました。

自分が書いた大きめな実装に対してテストを書く事で以下の葛藤・気づきが生まれます。

  • テストを書くために疎結合にしないと保証するテストが書けない。DI(dependency injection: 依存性注入)にするなど疎結合にする設計を頭の中で持っておかないといけない。
  • どこまでモックにするべきなのか、モックにしすぎるとテストが何を保証しているのかわからなくなってしまう。
  • 全ての分岐に対してテストを書くと時間とテスト行数がえらいことになる、どこを落とし所にするべきか。
  • private methodはテストするべきか否か

このくらいの時期から、絶対にテストを書くことが全ての状況における最適解とは限らないのではないかといったもやもやが生まれ始めます。

Stageクリア後

Level テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
0 単一クラスの正常系のテストコードが書ける
1 単一クラスの正常系・異常系を網羅したテストコードが書ける
2 既存の単一クラスの振る舞いの期待値をコードから読み取ることができる
3 単一クラスについてテストを書いて実装するテストファーストを実践する
4 複数クラスを統合したユニットテストが書ける

ある程度大きなテストについてもテストを書く経験ができた状態です。テストマインドの方は、、、

項目 5段階中 状態
自信 ☆☆  どうテストするべきかについて想いを馳せる 
積極性 ☆☆☆  テストを書いたからといって完全に動作保証するには至らないのではないかという葛藤 

自信・積極性共に低下、何が最適解なのか始めたころには考えなかった思考が生まれます。

Stage-5: 既存の大きいコードにテストを書く

もやもやを抱えたまま、コードも見慣れてきてテストがない部分に対してテストを書いてリファクタリングしていきたいなんて想いが生まれます。
ある程度大きく1000行近くまであるメソッドだったりすると難易度劇上がりです。振る舞いを理解するのに時間がかかりかつ、その振る舞いをテストするコードを書くのも一苦労です。

<?php
App::uses('AppController', 'Controller');

class QiitaSampleTest extends ControllerTestCase
{
    public $fixtures = array(
        'app.qiita_sample1',
        'app.qiita_sample2',
        'app.qiita_sample3',
        'app.qiita_sample4',
        'app.qiita_sample5',
        'app.qiita_sample6',
        'app.qiita_sample7',
        // その他必要なテーブルがずらずら続く
    );

    public function setUp()
    {
        parent::setUp();
        // $_SERVERに影響受けたりするから動作する状態にかえる
        $_SERVER['VARIABLE1'] = 'localhost';
        $_SERVER['VARIABLE2'] = 'localhost';
    }


    /**
     * test QiitaSample
     */
    public function testQiitaSample()
    {
        // sessionの中身を定義
        $session = array(
            'session_variable'
        );

        CakeSession::start();
        CakeSession::write('session', $session);

        // POSTされるデータを定義
        $data = array ('post_data');


        // 色々Mockにしていく
        $mock = $this->generate('QiitaSample', array(
            'components' => array(
                'Component1' => array('method1' ,'method2')
            ),
            'models' => array(
                'Model1' => array('method1'),
            ),
        ));

        // Controllerのテストなのでリクエストを投げる
        $this->testAction($url, array(
            'method' => 'post',
            'data' => $data,
        ));

        // 色々な変数をチェック
        $this->assertSame(null, $this->vars['variable1']);
        $this->assertSame(false, $this->vars['variable2']);
        ...

        // 実行した後のセッションの状態を確認
        $expected = array('session');
        $actual = CakeSession::read('session');
        $this->assertSame($expected, $actual);

        // 色んなテーブルに保存されたデータの確認
        $this->assertSame($expected_table1_record, $actual_table1_record);
        $this->assertSame($expected_table2_record, $actual_table2_record);
        $this->assertSame($expected_table3_record, $actual_table3_record);
        ...

目の前のコードに先に広がる広大な宇宙、凄まじいコードリーディング力が必要になります。
それに加えて、要件・設計を正確に把握した上で振る舞いを見ていかないと、設計と合っていない実装だとしても振る舞いとして見てしまう危険性もあります。
私の未熟さゆえひたすらレッドバーを突きつけられメンタルとの戦い、Stage-1の自分が恥ずかしくなり死にたくなります。

Stageクリア後

Level テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
0 単一クラスの正常系のテストコードが書ける
1 単一クラスの正常系・異常系を網羅したテストコードが書ける
2 既存の単一クラスの振る舞いの期待値をコードから読み取ることができる
3 単一クラスについてテストを書いて実装するテストファーストを実践する
4 複数クラスを統合したユニットテストが書ける
5 既存の複数クラスを統合したユニットテストが書ける(?)

既存の大きなテストに対して、大量のレッドバーにボコボコにされながら、テストを書く経験ができた状態です。テストマインドの方は、、、

項目 5段階中 状態
自信  テストムッチャむずい... 
積極性 ☆☆  レッドバー見すぎて死にたい... 

ひたすら赤くなるテスト結果にボコボコにされてますね。

Stage-6: 大きいコードを(半人前な)TDDで実装する

既存の大きいコードに対してテストを書くことの大変さを目の当たりにした後、再び新規機能の実装に戻ってきました。
もう大きな実装をドカンとしてレッドバーを見続けるのは精神衛生上危険な状態です。小さいテストでグリーンバーを見続けたい私は、テスト駆動開発での実装を試みます。

実装中のツイートがこちら。

「小さくテストを書いて実装して、またテストを追加して実装して、テストを書いて」をぐるぐるしました。自分が今どこまで進んでいるかはコードに残っているToDoの数で一目瞭然なので進捗感も抜群です。

Stageクリア後

Level テストスキル
- Excelでそれっぽい「結合試験仕様書」がなんとなく作成できる
0 単一クラスの正常系のテストコードが書ける
1 単一クラスの正常系・異常系を網羅したテストコードが書ける
2 既存の単一クラスの振る舞いの期待値をコードから読み取ることができる
3 単一クラスについてテストを書いて実装するテストファーストを実践する
4 複数クラスを統合したユニットテストが書ける
5 既存の複数クラスを統合したユニットテストが書ける(?)
6 テストを書きながら(半人前な)TDDを実践する

タイトルに戻ってきました。

項目 5段階中 状態
自信 ☆☆  テストを書きながら実装する方法いい感じ 
積極性 ☆☆☆☆  グリーンバー最高! 

レッドゾーンに達していた精神状態はグリーンバーによって急激に回復しました。

Stage-X: Next Stage

「テストコードを書く」こと一つとっても、深みがあり突き詰めていけばどんどん新しい知見や発想の転換がありました。
そして、試行錯誤して末に初めてわかる利点・葛藤も途中でありました。
色々、「どうするべきか」については考えるものの、今は「書かないより書いた方がいい」という発想で、多少冗長になろうとテストを書いてます。
きっと、テスト文化がなかった環境から、テスト文化がある環境に移る方もまだまだいると思うので、その際にこう思ってるのかなぁとか想像する上での一助になれば幸いです。

以上