LoginSignup
362
394

More than 5 years have passed since last update.

PHPUnitの使い方まとめ2016

Last updated at Posted at 2016-01-27

前置き

地味にこの記事が読まれ続けているみたいなのですが、内容がよい加減に古くて心苦しいので、もうちょっと現代的な内容にマイグレーションしたものを投稿しようと思います。当時と違ってQiitaにもよい記事増えているのに今更感あるのですが、あの記事に辿りついてしまった人のため……という感じで書いておきます。

大前提

PHPUnitを使ったからといって、どんなソースコードもテストできる訳ではありません。テストをし易いようにクラスを設計している必要があります。また、そのように設計していてもUnitテストに入れることの出来ない箇所は出てきます。Unitテストに入れることの出来ない箇所は出来ないと割り切らなければなりません。むしろ、どれだけのコードをUnitテストに入れることが出来るか? というのが設計者の腕の見せどころになるでしょう。極論を言うと

「どんなクラスでも疎結合に実装していなければならない」

ということです。「オブジェクト指向原理主義的には密結合であるべき箇所」も疎結合に実装します。例えば、ライフサイクルを共にするような関係性ですね。これも「コンストラクタ渡しにする」など疎結合にするのが鉄則です。何事も「テストできないより、テストできるクラスの方が良いに決まってる」というマインドで臨んでください。

単体テストというもの

ここでいう単体テストは「メソッド単体のテスト」や「クラス単体のテスト」を指します。

  • オブジェクト
  • 設定値
  • 外部システム(DB, API, ファイル, コマンド)

そういったものの"密なる依存"を切り離して「そのクラスを設計し」、「そのクラスを実装し」、「そのクラスのテストを作る」ということになります。"密なる依存"とは例えば以下のようなことを指します。

  • オブジェクトを利用するメソッドが直接オブジェクトを new する(オブジェクトへの依存)
  • ロジックの中で設定ファイル(file_get_contents, json_decode)を直接参照する(ファイルシステムへの依存)
  • ロジックの中で直接ファイル(file_put_contents)を書き出す(ファイルシステムへの依存)
  • ロジックの中で直接DB(mysqli, PDO)を覗く(ネットワーク・Databaseへの依存)
  • ロジックの中で直接API(file_get_contents,curl)を覗く(ネットワーク・APIへの依存)
  • ロジックの中で直接コマンド(system)を叩く(OSへの依存)

これらはクラスに切り出し"密なる依存"を切り離して、実際に単体テストとして記述する時にはテスト用のモックで代用するということになります。

<?php

namespace UnitTest\Sample;

class Hoge {
  protected $settings       = null;
  protected $fileManager    = null;
  protected $dbManager      = null;
  protected $apiManager     = null;
  protected $commandManager = null;

  public function __construct ( 
    $settings ,
    $fileManager, 
    $dbManager,
    $apiManager,
    $commandManager) {

    $this->settings       = $settings;
    $this->fileManager    = $fileManager;
    $this->dbManager      = $dbManager;
    $this->apiManager     = $apiManager;
    $this->commandManager = $commandManager;
 }
}

new に関する問題

前述の中で「そのメソッド内で使いたいオブジェクトをnewする」という問題について説明しませんでした。これは実に難しい問題であるためです。

  • コンストラクタ渡し(メンバとして保持するべきものならば)
  • 引数渡し(そのメソッド内で使うべきものならば)
  • setter渡し(メンバとして保持するべきものならば)
  • DI

などの解決策があります。前バージョンでは「"生成の責務だけ外に追い出すための"ファクトリクラスを作っちゃえ」的な内容を書きました。確かに最後の最後の苦し紛れの場では有効なのですが、あまり、こういう記事内で積極的に勧める内容ではなかったなと思い省略します。そういったタイミングでは拙作のZaolikが使えるかも知れません。

PHPUnitのインストール

「俺はカツカツにチューニングしたいんだ!Composerは遅いから使いたくないんだ!」という人以外は、普通にComposerで良いと思います。しかし、そういう人でも単体テストの時くらいは、AutoLoad分のオーバーヘッドくらい大目に見る心の余裕が欲しいと思います。適当なディレクトリを作ってCompsoerをインストールしましょう。今回は、ここがワーキングディレクトリになります。

curl -sS https://getcomposer.org/installer | php

続いてcomposer.jsonを作成します。PHPUnitは、本番環境の動作に必要無いのでreuqire-devに指定しましょう。

{
    "name": "hogehoge/fugafuga",
    "autoload": {
        "psr-4": {
           "UnitTest\\Sample\\": "src/app"
        }
    },
    "require": {
    },
    "require-dev": {
        "phpunit/phpunit": "3.7.*",
        "phake/phake": "2.*"
    }
}

続いてphpunit.xmlを作ります。

<?xml version="1.0"?>
<phpunit
        bootstrap="./tests/bootstrap.php"
        colors="true"
        convertErrorsToExceptions="true"
        convertNoticesToExceptions="true"
        convertWarningsToExceptions="true"
        verbose="true"
        stopOnFailure="true"
        processIsolation="false"
        backupGlobals="false"
        syntaxCheck="true"
        >
    <testsuite name="sample tests">
        <directory>./tests</directory>
    </testsuite>
</phpunit>

さらに続いてパッケージをインストールします。

php composer.phar install
mkdir -p src/app
mkdir -p tests/app
echo '<?php require __DIR__ . "/../vendor/autoload.php";' > tests/bootstrap.php
vendor/bin/phpunit --help

ところで、require-devにいる「Phakeって何よ?」って話ですね。これはモッキングフレームワークです。PHPUnitにも強力なモッキングフレームワークは内蔵されているのですが、こいつが結構堅苦しいヤツでして、テストコードの殆どがモックの生成に費やされる羽目になることもあり、それだとテストコード見るのも嫌になっちゃうので、僕はPhakeを愛用しています。他にも色々とモッキングフレームワークが有りますので、慣れてきたら好みのモノを見付けてください。何はともあれ、ここでは一旦Phakeを使って説明します。サンプルらしいサンプルみたいなのだとイメージを掴み辛い類の話ですので、ここでは少し具体的なヤツを書いてみます。

対象のクラス src/app/DatabaseSession.php

<?php
namespace UnitTest\Sample;
class DatabaseSession 
{
    private $connection = null;
    public function __construct ($connection)
    {
        $this->connection = $connection;
    }

    public function save ($tableName, $object)
    {
        if (!$tableName || !is_scalar($tableName) || !$object) {
            throw new \InvalidArgumentException('$tableName or $object is empty or invalid type');
        }

        if (is_object($object) && $object instanceof \stdClass) {
            $object = (array)$object;
        }

        if (!is_array($object)) {
            throw new \InvalidArgumentException('$object must be array or \stdClass');
        }

        $values = array_values($object);
        $tableName   = '`' . str_replace('`', '\\`', $tableName) . '`';
        $columnBlock = implode(',', array_map(function($col) {return '`' . str_replace('`', '\\`',  $col) . '`';}, array_keys($object)));
        $valueBlock  = implode(',', array_map(function($val) {return '?'; }, $values));
        $valueMarker = implode('', array_map(function($val) {return strval(intval($val)) === strval($val) ? 'i' : 's'; }, $values));
        $stmt = $this->connection->prepare("REPLACE INTO $tableName ($columnBlock) VALUES ($valueBlock);");
        array_unshift($values, $valueMarker);
        $pointer = array();
        foreach ($values as $key => $val) {
           $pointer[$key] = &$val;
        }
        call_user_func_array(array($stmt, 'bind_param'), $pointer);
        $stmt->execute();
        $stmt->close();
    }
}

tests/app/DatabaseSessionTest.php

さて、一旦、引数の型のチェックのテストだけガーッと書いてみましょう。コメント中にも書いてありますが、PHPUnit_Framework_TestCaseを継承したクラスがテストクラスになります。そして「test」から始まるメソッドがテストです。テストは基本的に「$this」の「assert」から始まるメソッドを使った「アサーション」による値の検査が中心になります。ここでは、例題の都合上、assertInstanceOfしか基本の「アサーション」をしておらず引数チェックのための例外のassertionばかりになってしまっていますが、各自、基本のアーサションについては、公式のドキュメントを見ておいてください

<?php
namespace UnitTest\Sample;
/**
 * \PHPUnit_Framework_TestCaseを継承したクラスがテストケースになります
 */
class DatabaseSessionTest extends \PHPUnit_Framework_TestCase
{
    // テスト対象のオジェクト
    private $target = null;
    // プリペアードステートメントのモック
    private $statement = null;
    // \mysqliのモック
    private $connection = null;

    /**
     * これが各テストメソッドの前に実行されるメソッドになります。
     * ここで対象クラスのnewと依存オブジェクトの用意をしましょう。
     * このテストクラスを見た人が対象クラスの使い方が分かり易くなりますね
     */
    public function setUp()
    {
        // \mysqliのモックを作っています
        $this->connection = \Phake::mock('\mysqli');

        // \mysqli_stmtのモックを作っています
        $this->statement = \Phake::mock('\mysqli_stmt');

        // 対象クラスはリアルなオブジェクトです
        $this->target = new DatabaseSession($this->connection);
    }

    /**
     * testから始まるメソッド名がテストメソッドです。
     * assertは基本、左側がexpected(期待値), 右側がactual(実際の値)
     * assertInstanceOfは、actualがexpectedに指定した型に合っているかをチェックします。
     * 合っていれば成功です。
     */
    public function testInstance()
    {
        $this->assertInstanceOf('\UnitTest\Sample\DatabaseSession', $this->target);
    }

    /**
     * expectedExceptionは実行したら例外が投げられるかどうかのチェックをして、
     * 指定の型の例外が投げられたら成功というテストになります
     * @expectedException \InvalidArgumentException
     */
    public function testSaveTableNameNull()
    {
        $this->target->save(null, array('hoge' => 1));
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testSaveTableNameEmptyString()
    {
        $this->target->save('', array('hoge' => 1));
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testSaveTableNameArray()
    {
        $this->target->save(array(), array('hoge' => 1));
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testSaveTableNameObject()
    {
        $this->target->save((object)array('tableName' => 'tbl1'), array('hoge' => 1));
    }


    /**
     * @expectedException \InvalidArgumentException
     */
    public function testSaveObjectNull()
    {
        $this->target->save('hoge', null);
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testSaveObjectEmptyArray()
    {
        $this->target->save('hoge', array());
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testSaveObjectIsInvalidObject()
    {
        $this->target->save('hoge', new \DateTime());
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testSaveObjectIsScalar()
    {
        $this->target->save('hoge', 1);
    }
}

書いたら実行してみましょう。

> vendor/bin/phpunit

Testing started at 12:12 ...
#!/usr/bin/env php
PHPUnit 3.7.38 by Sebastian Bergmann.

Configuration read from /path/to/phpunit.xml

.........

Time: 199 ms, Memory: 3.75Mb

OK (9 tests, 9 assertions)

こんな感じになりましたかね?では、他にどんなテストが必要でしょうか?そうです。SQL文の組み立てや、stmtへのbind_paramも必要でしょう。ひとまず正常の型を引数で渡してメソッドを完走させるテストを書いてみましょう。個人的には正常の型を渡してメソッドが完走するテストは、setUpのすぐ下辺りに書くのが好みです。

    public function testSave()
    {
        // prepareメソッドに'REPLACE INTO `tbl1` (`col1`,`col2`) VALUES (?,?);'が渡されたら、$this->statementを返すスタブを設定します
        \Phake::when($this->connection)->prepare('REPLACE INTO `tbl1` (`col1`,`col2`) VALUES (?,?);')->thenReturn($this->statement);
        // そんで正しそうな引数で実行
        $this->target->save('tbl1', array('col1' => 'val1', 'col2' => 'val2'));
        // $this->statementのbind_paramが'ss', 'val1', 'val2'で呼ばれたことを検証。call_user_func_arrayでやっているので分かり辛いかも知れませんが、あそこです
        \Phake::verify($this->statement)->bind_param('ss', 'val1', 'val2');
        // executeが呼ばれたことを検証
        \Phake::verify($this->statement)->execute();
        // closeが呼ばれたことを検証
        \Phake::verify($this->statement)->close();
    }

さて、SQL文の組み立てや、bind_paramの確認がとれました。これで充分でしょうか?いいえ、全然足りていません。SQL文の組み立てのロジックについて全然足りていませんね。しかし、このメソッドの引数を組み替えながら、ここでテストするべきでしょうか?ちょっとテストし辛いですね。そもそも、このテストケースに、そこまで書くべきでしょうか?これはDatabaseへsaveすることを目的としたメソッドです。そもそも「SQL文の組み立て」という文字列操作に関心を持つことが正解でしょうか?例えば、これが「Query」というクラスに切り出されたらどうでしょう?テストし易いだけでなくDBごとのSQL方言を吸収する機構も、そこに持たせられるかも知れません。このように単体テストを書いていて「テストを書き辛いな……」と思ったら、それがクラスを分割するサインだと思ってください。各自、自分なりの正解を模索してみましょう。

FAQ

  • これ、めっちゃ大変だと思うんですけど? => 大変です。でも、毎度全部テストする方が大変です。
  • 時間かかるんじゃないの? => かかります。でも、毎度全部テストする方が時間かかります。
  • これだけじゃバグ発見しきれないのでは? => 勿論しきれません。でも、やらないより余程減らせるはずです
  • 既存のシステムに組み入れられない(TT) => 既存のシステムは手を入れないと、導入できないシステムの方が多いでしょう。レガシーコード改善ガイド読むとヒントが掴めるかも
  • テストは先に書くべき? => どっちでも良いと思います。僕としてはテストを書けているのなら書いたのが後だろうが先だろうがどっちでも良いです。
  • 例で使ったPHPUnit古くね? => コピってきたcomposer.jsonが古かったから……各自、良いバージョンを使ってください
  • カバレッジ100%目指さなくて良いですか? => 必要があればどうぞ。個人的には条件分岐やループが存在しない箇所は無理に書かなくて良いのでは?派です。
  • モックだらけになっちゃう(><) => 本物を使って問題無い場所は本物使いましょう。個人的には「モックじゃないとダメなオブジェクトが3個出てきたらリファクタを検討する」ようにしています
362
394
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
362
394