2014年12月26日

Tips

PHPUnit コードカバレッジの向上事例を紹介します

  • このエントリーをはてなブックマークに追加

はじめに

こんにちは、ヤフーメール エンジニアの小沼俊治です。

開発者の皆さんの中には、CIにユニットテストの自動化を導入して、日々開発されるプロダクトの品質維持に務めている方々も多いのではないでしょうか?

私の担当サービスでもxUnitを使ってプロダクトコードにテストコードを作成して、CIツールのジョブで定期的にユニットテストを実行して品質維持に活用しています。

そして、それらテストコードがきちんと品質維持に貢献されていくためには、プロダクトコードのビジネスロジックをどれだけ網羅できているのか? と言う観点が重要になってきます。
その観点の達成状況を確認する指標の1つとして、ユニットテストを実行した時に行われるコードカバレッジ解析を利用する手があります。

これはテストコードが実装コードで実行したステップとその割合を示すものですが、闇雲にテストコードを数だけ用意したからといって単純にコードカバレッジが上がっていく訳ではありません。プロダクトコードの実装面からも、テストコードと親和性の高いプロダクトコードの作りが理解できているかどうかで、得られる結果に大きな差がでてきます。

また、やはり何事も高スコアを達成できると気持ちのいいものですし、引き続きユニットテスト環境を整備・維持していこうとするモチベーションにもつながると思っています。

code coverage

ただし、本題に入る前に誤解を招かないように1つ説明させていただくと、コードカバレッジが高ければ、それだけで機能を網羅した良いテストケースが用意できるわけではなく、TDD アンチパターンなどでも述べられているとおり、コードカバレッジの向上だけを追い求めるあまり、カプセル化を壊してしまうようなテストコードを生み出し、Red/Green/Refactor による小刻みで軽快な開発を阻害してしまうことは望んでいません。
皆さんが「こんなテストケースを書きたい」と思いついたときに、思い描いたテストコードを自由自在に書くことができるスキルの習得を目指す際の、成長度を確認する定量的な指標の1つを提案させていただいていると言うことで、ご理解いただきたいと思います。

それでは、PHP のプロダクトコードと PHPUnit にて、コードカバレッジの向上に役立つ実装事例を主に以下の観点で紹介させていただこうと思います。

  • テストダブルを組み込めるプロダクトコードを実装する
  • private, protected メソッドへユニットテストを実行する
  • 抽象クラスに実装されている具象メソッドへユニットテストを実行する

テストダブルを組み込めるプロダクトコードを実装する

プロダクトコードのビジネスロジックが、データベースや KVS への接続、または、ファイルアクセスなど、外部のコンポーネントやリソースに依存する実装は一般的にもあると思います。それらのプロダクトコードにユニットテストを実行するには、事前に外部のコンポーネント(および、リソース)と接続する設定を行い、テストケースに見合ったテストデータを外部のコンポーネントに用意しておく必要があるため、気軽にどんな環境でもユニットテストが実行できず、せっかく作ったテストコードも実行される機会が少なくなり、品質維持につながらなくなります。
また、ユニットテストの実行条件が外部のコンポーネントに依存してしまう割合が高いため、正常系、異常系のあらゆるケースを立て続けに実行することも非常に困難で、その分、テストケースの網羅性に支障をきたします。

ユニットテストを実行できる条件が環境に左右されず、また、気軽に実行してもらえるようにするには、外部のコンポーネントに接続するオブジェクトなど、ユニットテストの実行条件を上げてしまっている依存オブジェクトをテストダブルに置き換えられると、環境に依存する実行条件が無くなり、気軽にどんな環境でもユニットテストが実行できるので、品質維持に活用されるようになります。

ここでテストダブルと言う用語が出ましたが、もしかするとスタブやモックなどと言う方がなじみ深い方も多いかと思いますが、これら依存するオブジェクトの代役としての総称を指しています。テストダブルの定義については、「テストダブルで検索」していただくとより詳しく解説されているサイトがありますので、そちらをご参照ください。

では、依存するオブジェクトをテストダブルで置き換えるとはどう言うことかを具体的に説明していきましょう。

プロダクト実行時の依存オブジェクト構成

ここで示した図は、実行環境でプロダクトコードを実行した際の、あるクラス構成を表しています。
MyContoroller クラスが MyModel クラスを依存して、MyModel クラスは MyLibDb クラスと MyLibCalc クラスを依存してプログラムを実行します。MyLibDb はデータベースに接続する必要があるため、このクラスを実行するにはデータベースへの接続設定などの条件が整っている必要があります。

これらのプロダクトコードに対してユニットテストを実行する際は、どうすればよいでしょう。次の図で表してみました。

ユニットテスト実行のテストダブル置き換え

MyModel クラスのユニットテストを実行する際は、MyLibDb クラスのデータベースへの接続がユニットテストの実行条件を上げてしまうため、依存する MyLibDb クラスのテストダブルを用意して、MyLibDb クラスのインスタンスではなく、テストダブルのインスタンスに置き換えて実行します。こうする事で、データベースに接続する設定が整っていなくても MyModel クラスのユニットテストが実行できるようになります。
次に、MyController クラスのユニットテストを実行する際は、MyLibDb クラスを依存する MyModel クラスの実行条件が上がってしまっているので、依存する MyModel クラスのテストダブルを用意して、テストダブルのインスタンスに置き換えて実行します。
ちなみに、MyLibCalc クラスは MyModel クラスから安易に全てのテストケースを実行できるためテストダブルに置き換えていません。

この様に、実行する事が困難だったりする依存オブジェクトを、テストダブルに置き換えることが出来るようなプロダクトコードの実装になっていれば、気軽にどんな環境でもユニットテストが実行できるようになります。

また、依存するオブジェクトをテストダブルに置き換えて実行できると言うことは、確認したいテストケースに合った応答を返却する、さまざまなテストダブルを用意できるので、正常系、準正常系、異常系を含めた細かい条件でテストが実行できるので、コードカバレッジの向上につながってきます。

では、次にソースコードを用いてテストダブルに置き換える実装例を説明させていただきます。

外部コンポーネントに依存する実装を直接内包

まず始めに、例題としてKVS(memcached)に接続したプロダクトコード MyBizLogic.php があります。

<?php
namespace YahooJapan\Mail\demo201501\app\model;

/**
 * MyBizLogic.php
 */
class MyBizLogic
{
    public function vote($candidate, $coef = 1)
    {
        // 設定値を取得する
        if ($this->loadConfig($host, $port) === false) {
            return false;
        }
        // 投票数を色づける
        $value = $this->tingeGratuity($coef);

        // KVSに接続する
        $link = memcache_connect($host, $port);
        if ($link === false) {
            return false;
        }
        // KVSを更新する
        $result = memcache_increment($link, $candidate, $value);
        if ($value === false) {
            return false;
        }
        // KVSから切断する
        $stat = memcache_close($link);

        return $result;
    }

    private function loadConfig(&$host, &$port)
    {
        $host = 'localhost';
        $port = 11211;

        return true;
    }

    private function tingeGratuity($coef)
    {
        return 1 * $coef;
    }
}

例題のプロダクトコードではビジネスロジックと混在してKVSに接続する関数(例題では memcache_*() )が使われているため、ビジネスロジックの動作確認としてユニットテストを実行したい場合でも、

  • KVSに接続できる環境が整っている
  • ロジックが通過できるようなデータがKVSに用意されている

などの実行条件をクリアする必要があり、テストコードを作成した当初はそれら条件がそろっていても、時間の経過とともにデータの欠落が生じたり、プロダクトコードの維持と並行して永続的に条件を維持することも難しくなります。

また、CIツールによるビルド実行時にユニットテストの実行を組み込むには、環境依存の実行条件を排除しておく方が組込みやすくなります。
これらの理由により、外部のコンポーネントへ直接的に依存している実装をビジネスロジックから分離してみます。

外部コンポーネントへの直接的な依存を分離

外部への直接的な依存をラッピングしたクラス

まずは、外部のコンポーネントへ接続するためのステートメントのみをラッピングしたクラスとして MyMemcache.php を用意しました。

<?php
namespace YahooJapan\Mail\demo201501\app\lib;

/**
 * MyMemcache.php
 */
class MyMemcache
{
    // KVSに接続する
    public function connect($host, $port)
    {
        return memcache_connect($host, $port);
    }
    // KVSを更新する
    public function increment($link, $key, $value)
    {
        return memcache_increment($link, $key, $value);
    }
    // KVSから切断する
    public function close($link)
    {
        return memcache_close($link);
    }
}

ラッピングしたクラスはビジネスロジックと完全に切り離されているので、このクラスの役割としては、ビジネスロジックの事は全く考えずに、外部のコンポーネントと接続する機能の動作を保証することに専念できます。

外部へは間接的な依存にしたビジネスロジッククラス

続いて MyBizLogic.php から外部のコンポーネントへ接続する実装を、ラッピングクラスを経由した間接的な接続方法に改良します。

また、ラッピングクラスのインスタンス生成をコンストラクタのパラメータ化を用いて実装することにより、テストダブルが組み込みやすいプロダクトコードにします。
コンストラクターの引数に何も指定しないとラッピングクラス MyMemcache クラスのオブジェクトを生成してビジネスロジックが実行されます。一方、コンストラクターの引数でテストダブルのオブジェクトを投入するとテストダブルでビジネスロジックが実行されます。
後者がユニットテストにおける動作に該当し、外部のコンポーネントへ接続できた振りをしたテストダブルを投入すれば、実際に外部のコンポーネントに接続できていなくてもビジネスロジックのユニットテストが実行できるようになります。

この様に外部のコンポーネントに直接的につながっていなければ、外部のコンポーネントの環境に直接影響しないので、このクラスはビジネスロジックの動作を保証することに専念できるようになります。

ラッピングクラスとコンストラクタのパラメータ化を用いて実装し直した MyBizLogic.php になります。

<?php
namespace YahooJapan\Mail\demo201501\app\model;

use \YahooJapan\Mail\demo201501\app\lib\MyMemcache;

/**
 * MyBizLogic.php
 */
class MyBizLogic
{
    // ラッピングクラスを保持する
    private $memcache = null;

    // コンストラクタのパラメータ化でテストダブルが投入できる
    public function __construct($td_memcache = null)
    {
        if ($td_memcache === null) {
            $this->memcache = new MyMemcache();
        } else {
            $this->memcache = $td_memcache;
        }
    }

    public function vote($candidate, $coef = 1)
    {
        // 設定値を取得する
        if ($this->loadConfig($host, $port) === false) {
            return false;
        }
        // 投票数を色づける
        $value = $this->tingeGratuity($coef);

        // KVSに接続する(ラッピングクラス経由)
        $link = $this->memcache->connect($host, $port);
        if ($link === false) {
            return false;
        }
        // KVSを更新する(ラッピングクラス経由)
        $result = $this->memcache->increment($link, $candidate, $value);
        if ($value === false) {
            return false;
        }
        // KVSから切断する(ラッピングクラス経由)
        $stat = $this->memcache->close($link);
        return $result;
    }
    // (以降の実装は割愛)
}

MyBizLogic.php を利用するプロダクトコード MyController.php ではコンストラクタの引数にテストダブルを投入しないので、実際にKVSに接続してビジネスロジックが実行されます。

<?php
namespace YahooJapan\Mail\demo201501\app\controller;

use \YahooJapan\Mail\demo201501\app\model\MyBizLogic;

/**
 * MyController.php
 */
class MyController
{
    // プロダクトロジック
    public function execute()
    {
        $biz = new MyBizLocig();
        $result = $biz->vote('mr.obama');
    }
}

一方、テストコード MyBizLogicTest.php では、生成したモックオブジェクトをテストダブルとしてテスト対象のコンストラクタに投入することで、外部コンポーネントの接続状況などに左右されず、ビジネスロジックを単体でテストすることが可能になります。

<?php
namespace YahooJapan\Mail\demo201501\test\model;

use \YahooJapan\Mail\demo201501\app\model\MyBizLogic;

/**
 * MyBizLogicTest.php
 */
class MyBizLogicTest extends \PHPUnit_Framework_TestCase
{
    // テストロジック
    public function testCase1()
    {
        // 実施するユニットテストの条件に合致した Memcache.php のテストダブルオブジェクトを生成します
        $stub = $this->getMock('MyMemcache', array('connect', 'increment', 'close'));
        $stub
            ->expects($this->any())
            ->method('connect')
            ->will($this->returnValue(true));
        $stub
            ->expects($this->any())
            ->method('increment')
            ->will($this->returnValue(123));

        // ユニットテストではテストダブルオブジェクトを投入してテストを実施します
        $biz = new MyBizLogic($stub);
        $result = $biz->vote('mrs.clinton');

        $this->assertEquals(123, $result);
    }
}

ラッピングクラスにユニットテスト

では、ビジネスロジックから分離させた、外部コンポーネントへの接続をラッピングしたラッピングクラスにユニットテストを実行する場合、外部コンポーネントの接続ライブラリがインストールされている環境であればそのままユニットテストが実行できます。ラッピングクラスの実装も単に接続関数をコールしているだけなので、それらの関数がコールできるかどうか? が確認できれば最低限としてのユニットテストは達成されます。

一方、CIツールからユニットテストを実行する時のように、外部コンポーネントの接続ライブラリがインストールされていない環境で実行させる必要がある場合には、名前空間に実装した関数はグローバル関数より優先して実行されるというPHP言語仕様を活用することにより、同名の関数をスタブとして用意できるので、ユニットテストではそれらを利用することも可能になります。

ユニットテストを実行するために、外部コンポーネントに接続する関数を pretend.php にスタブとして実装しました。

<?php
namespace YahooJapan\Mail\demo201501\app\lib;

/**
 * pretend.php
 */
{
    // KVSに接続する(スタブ)
    function memcache_connect($host, $port)
    {
        return true;
    }
    // KVSに接続する(スタブ)
    function memcache_increment($link, $key, $value)
    {
        return $value + 1;
    }
    // KVSから切断する(スタブ)
    function memcache_close($link)
    {
        return true;
    }
}

ラッピングクラスのテストコードでは、この pretend.php を参照することでスタブ関数を利用してユニットテストを実行できます。

<?php
namespace YahooJapan\Mail\demo201501\test\model;

// スタブ関数を参照する
require_once(__DIR__ . '/pretend.php');

use \YahooJapan\Mail\demo201501\app\lib\MyMemcache;

class MyMemcacheTest extends \PHPUnit_Framework_TestCase
{
    // テストロジック
    public function testCase1()
    {
        $kvs = new MyMemcache();
        $result = $kvs->connect('localhost', 12345);

        $this->assertTrue($result);
    }
}

プロダクトコードにオブジェクト指向型APIを利用している場合

ここまではKVSへの接続を手続き型API(関数)で実装したプロダクトコードで説明してきましたが、オブジェクト指向型APIを使っていた場合には、外部のコンポーネントへ接続するためのラッピングクラスは用意することなく、テストダブルを組み込みやすいプロダクトコードが実装できます。

コンストラクターのパラメーター化で、引数にオブジェクト指向型APIのインスタンスを指定することで、コンストラクタ内で生成するか、コンストラクタ生成元から投入するかを選択できれば、プロダクトの実行環境、および、ユニットテストのいずれにおいても実行しやいクラスになります。

オブジェクト指向型APIとコンストラクタのパラメータ化を用いて実装した MyBizLogic.php は次の様になります。

<?php
namespace YahooJapan\Mail\demo201501\app\model;

/**
 * MyBizLogic.php
 */
class MyBizLogic
{
    // オブジェクト指向型APIのインスタンスを保持する
    private $memcache = null;

    // コンストラクタのパラメータ化でテストダブルが投入できる
    public function __construct($td_memcache = null)
    {
        if ($td_memcache === null) {
            $this->memcache = new \Memcache();
        } else {
            $this->memcache = $td_memcache;
        }
    }

    public function vote($candidate, $coef = 1)
    {
        // 設定値を取得する
        if ($this->loadConfig($host, $port) === false) {
            return false;
        }
        // 投票数を色づける
        $value = $this->tingeGratuity($coef);

        // KVSに接続する(オブジェクト指向型API)
        $link = $this->memcache->connect($host, $port);
        if ($link === false) {
            return false;
        }
        // KVSを更新する(オブジェクト指向型API)
        $result = $this->memcache->increment($candidate, $value);
        if ($value === false) {
            return false;
        }
        // KVSから切断する(オブジェクト指向型API)
        $stat = memcache_close($link);

        return $result;
    }
    // (以降の実装は割愛)
}

ユニットテスト実行時には、Memcacheクラスのインスタンスの代わりにMemcacheクラスを模したのモックオブジェクトを生成してコンストラクタで投入します。

private, protected メソッドへユニットテストを実行する

プロダクトコードに実装するクラスを設計する際、クラスの利用者に公開する機能を public で実装し、クラス内部で利用する機能を private や protected などのアクセス権を付与してメソッドを実装すると思います。

それらのメソッドに対して PHPUnit でユニットテストを実行する際、テストコードはプロダクトコード(クラス)を利用する立場になるのでプロダクトコードの public メソッドしかコールできません。しかし、private, protected のメソッドに対して直接ユニットテストを実行したいと思うことはあるはずです。そこで、それらメソッドのアクセス権を public にしてしまうと言うことは、本末転倒であります。

では、どうすれば private, protected メソッドに対してユニットテストを実行できるのか? をご紹介したいと思います。

privateメソッドが実装されたプロダクトコード

例題として public メソッドが1つと、private メソッドが2つ実装されたクラスのプロダクトコードとして Playing.php があります。

<?php
namespace YahooJapan\Mail\demo201501\app\lib;

/**
 * Playing.php
 */
class Playing
{
    public function goRockPaperScissors()
    {
        $value = $this->sampling();
        $label = $this->labeling($value);
        return $label;
    }

    private function sampling()
    {
        return mt_rand(0, 2);
    }

    private function labeling($value)
    {
        switch ($value)
        {
            case 0:
                return 'Rock';
            case 1:
                return 'Paper';
            default:
                return 'Scissors';
        }
    }
}

このプロダクトコードに対してユニットテストを実行する場合、public で定義されている Playing::goRockPaperScissors を実行すれば、その中で使われている Playing::sampling, Playing::labeling が呼ばれるので全てのメソッドをテストコードから実行することは可能です。

全てのコードカバレッジを達成するためには、Playing::labeling 内部で3つの条件分岐が実装されているので最低3回はテストを実行する必要があります。ただし、Playing::labeling の条件分岐は Playing::sampling で生成された乱数が元になっているので、3回テストを実行したからといって必ずしも全てのコードをカバレッジできるとは限りません。全てをカバレッジするには運次第になりますね。

Playing::labeling や Playing::sampling がテストコードから直接呼び出せれば確実性のあるユニットテストが実施可能だと、誰しもが考えることだと思います。

ReflectionMethod クラスを利用してユニットテストを実装

そこで PHP 5.3.2 以降に追加された ReflectionMethod::setAccessible メソッド利用すると private, protected なメソッドのアクセス権を変更できるので、それらのメソッドにユニットテストを直接実行することが可能になります。

  • 引数のない Playing::sampling メソッドをテストする場合は ReflactionMethod::invoke メソッドを利用します
  • 引数のある Playing::labeling メソッドをテストする場合には ReflectionMethod::invokeArgs メソッドを利用します

ReflectionMethodを利用したテストコードの実装例 PlayingTest.php を掲載します。

<?php
namespace YahooJapan\Mail\demo201501\test\lib;

use \YahooJapan\Mail\demo201501\app\lib\Playing;

/**
 * PlayingTest.php
 */
class PlayingTest extends PHPUnit_Framework_TestCase
{
    /**
     * 引数の無い Playing::sampling メソッドをテスト
     */
    public function testCase1()
    {
        // テスト対象のインスタンスを生成する
        $instance = new Playing();

        // private/protected メソッドのアクセスできるようにする
        $reflect = new \ReflectionMethod($instance, 'sampling');
        $reflect->setAccessible(true);

        // private/protected メソッドの実行
        $result = $reflect->invoke($instance);
    }
    /**
     * 引数のある Playing::labeling メソッドをテスト
     */
    public function testCase2()
    {
        // テスト対象のインスタンスを生成する
        $instance = new Playing();

        // private/protected メソッドのアクセスできるようにする
        $reflect = new \ReflectionMethod($instance, 'labeling');
        $reflect->setAccessible(true);

        // 引数がある private/protected メソッドの実行
        $result0 = $reflect->invokeArgs($instance, array(0));
        $result1 = $reflect->invokeArgs($instance, array(1));
        $result2 = $reflect->invokeArgs($instance, array(2));
        $result3 = $reflect->invokeArgs($instance, array(3));

        $this->assertEquals('Rock', $result0);
    }
}

ReflectionMethod クラスを利用することで、プロダクトコードのアクセス権に対する設計思想を変更することなく、ユニットテストが実行可能となりました。

抽象クラスに実装されている具象メソッドへユニットテストを実行する

抽象クラスはインスタンス化できないので、抽象クラスに実装されている具象メソッド(プログラムが実装されている通常のメソッド)にユニットテストを実行するには、別途、抽象クラスを継承した具象クラスを用意しなければ実行できないのでは? と思いますが、PHPUnit_Framework_TestCase::getMockForAbstractClass メソッドを使うと抽象メソッドをインスタンス化することが出来るため、わざわざ継承実装した具象クラスを用意することなく、抽象クラスのユニットテストが実行可能になります。

抽象クラスで実装されているプロダクトコード

例題として抽象クラスで実装されたプロダクトコード Person.php があります。

<?php
namespace YahooJapan\Mail\demo201501\app\lib;

/**
 * Person.php
 */     
abstruct Person
{
    private $age = 0;

    // 抽象メソッドの定義
    absttuct function greet();

    // 具象メソッドの実装
    public function grow()
    {
        $this->age++;
    }
    // 具象メソッドの実装
    public function howOld()
    {
        return $this->age;
    }
}

抽象クラスをインスタンスしてユニットテストを実行

testCase2()で実装されているように PHPUnit_Framework_TestCase::getMockForAbstractClass メソッドで抽象クラスのモックオブジェクトを生成できます。
そのモックオブジェクトに対して具象メソッドを実行できるので、ユニットテストが実行可能になります。

<?php
namespace YahooJapan\Mail\demo201501\test\lib;

/**
 * PersonTest.php
 */
class PersonTest extends PHPUnit_Framework_TestCase
{
    public function testCase1()
    {
        // 抽象クラスはそのままインスタンス生成するとエラーになる
//      $model = new \YahooJapan\Mail\demo201501\app\lib\Person();
    }

    public function testCase2()
    {
        // 抽象クラスをインスタンス化する
        $model = $this->getMockForAbstractClass('\YahooJapan\Mail\demo201501\app\lib\Person');
        $model->grow();
        $model->grow();
        $model->grow();
        $result = $model->howOld();

        $this->assertEquals(3, $result);
    }
}

ちなみに、PHPUnit_Framework_TestCase::getMockForAbstractClass メソッドで生成したモックオブジェクトから、ReflectionMethod クラスを生成する合わせ技を使えば、抽象クラスに実装されている private, protected メソッドにもユニットテストが実行できたりもします。

さいごに

今回は、まず始めにプロダクトコードがある事が前提で、後からテストコードを用意する流れで説明をしてきましたが、紹介した実装事例が理解できているとプロダクトコードを実装する前に、プロダクトコードのインターフェースからテストコードを実装することも可能になってきます。

これができると、テスト開発駆動(TDD)を導入することへの敷居も下がり、その結果、プロダクトコードが実装されると既にテストコードもペアで必ず存在することになるので、ユニットテストフェーズが意味を成す状態となり、理想的なCIライフサイクルに近づけることも可能だと考えています。

それでは、ご紹介させていただいた事例が、少しでも皆さんのクリエイティブな活動にお役に立てていただければ幸いです。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加