EthnaのユニットテストをPHPUnitで書いてみる話

わたくし、Ethnaで組まれたレガシーなWEBアプリのプロジェクトにアサインされています。

未だに新機能が追加される現役バリバリのプロジェクトですが
さすがに年季の入ったコードも多いので、リファクタも進めています。

今後のメンテナンスを見越して、リファクタついでに
これまで全く書かれていなかったユニットテストも書きはじめました。が、
Ethnaのユニットテストについてほとんど情報が出てこない状態だったので
今更ながらまとめてみました。

# 開発環境

PHP 5.2
Ethna 2.3.7

# 読むと役立ちそうな人

Ethnaで組まれたアプリでこれからユニットテストを書こうとしている奇特な人。

# Simpletest?

EthnaにはSimpletestというテスティングフレームワークが組み込まれていて、
それを利用するのが一番楽そうなのですが、以下の理由で見送りました。

- テストファイルがプロジェクト内に散らばる
- プロジェクトがEthnaのお作法をちょっと外した使い方をしているのでそのままじゃ動かない
- PHPUnitの方が情報が多い

# PHPUnitのインストール

ということで、PHPUnitで進めることになりました。

レガシーな環境にPHPUnitのインストール手順を記事にされている方がいらっしゃいましたので、
ありがたく参考にさせていただきPHPUnitをインストールします。

https://qiita.com/dozo/items/d76c36e911059951f1b6

今回は、PHPUnit3.6をインストールしました。

PHPUnit3.6のドキュメント
https://phpunit.de/manual/3.6/ja/automating-tests.html

# 準備

とりあえず、下記３ファイルをプロジェクトディレクトリ直下に作成します。
（クラス名やファイル名はプロジェクトに合わせて適当に読み替えてください）

- PHPUnit設定ファイル
 - `phpunit.xml`
- Ethnaを起動するスクリプト
 - `_bootstrap.php`
- テスト用のヘルパーメソッドを追加した基底クラス
 - `SAMPLE_UnitTestCase.php`

```xml:phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
        bootstrap="/app_dir/_bootstrap.php"
        colors="true"
        convertErrorToExceptions="false"
        convertNoticesToExceptions="false"
        convertWarningToExceptions="false">
    <testsuites>
        <testsuite name="All Test Suite">
            <directory suffix="Test.php">/app_dir/tests/</directory>
        </testsuite>
    </testsuites>
</phpunit>
```

```php:_bootstrap.php
<?php
error_reporting(E_ALL);
putenv('DEVELOP=true');
// Ethnaのロード
require_once '/app_dir/app/APPID_Controller.php';
require '/app_dir/APPID_UnitTestCase.php';

// Ethnaのインスタンス化
new APPID_Controller();
```

```php:APPID_UnitTestCase.php
<?php
/**
 * このファイルを継承してテストクラスを書きます
 */
class APPID_UnitTestCase extends PHPUnit_Framework_TestCase
{
    protected $ctl;
    protected $classFactory;
    protected $backend;
    protected $session;

    public function setUp()
    {
        // オブジェクトの設定
        $this->ctl = Ethna_Controller::getInstance();
        $this->classFactory = $this->ctl->getClassFactory();

        $this->backend = $this->ctl->getBackend();
        $this->session = $this->backend->getSession();
    }

    public function createActionClass($actionName)
    {
        $actionClassName = $this->ctl->getActionClassName($actionName);
        return new $actionClassName($this->backend);
    }

    public function createActionForm($actionName)
    {
        $formName = $this->ctl->getActionFormName($actionName);
        return new $formName($this->ctl);
    }

    public function createViewClass($forwardName)
    {
        $viewClassName = $this->ctl->getViewClassName($forwardName);
        return new $view_class_name($this->backend, $forwardName, $this->ctl->_getForwardPath($forwardName));
    }
}
```

準備ができたのでテストを書いてきます。

# ActionClassをテストする

アクションクラスは大体こんな形になっていると思いますが、

```php:SomeActionClass.php
<?php
class APPID_Form_SomeActionClass extends Ethna_ActionForm {
    var $form = array();
}

class APPID_Action_SomeActionClass extends Ethna_ActionClass {

    function prepare() {
        // 何かしらの処理
        return null;
    }

    function perform() {
        // 何かしらの処理
        return 'someResult';
    }
}
```

こんな感じでテストをかきます。

```php:SomeActionClassTest.php
<?php

class SomeActionClassTest extends APPID_UnitTestCase
{
    private $ac;
    private $actionName;

    /**
     * テストメソッド開始前に毎回呼ばれる
     */
    public function setUp()
    {
        // 基底クラスのsetUpをコールして、テストの実行に必要なインスタンスを準備
        parent::setUp();

        $this->actionName = 'SomeActionClass';
        $this->ac = $this->createActionClass($this->actionName);
        $this->ac->af = $this->createActionForm($this->actionName);
    }

    public function testPrepareNormal()
    {
        // nullが返ること
        $this->assertSame(null, $this->ac->prepare());
    }

    public function testPerformNormal()
    {
        // someResultが返ること
        $this->assertSame('someResult', $this->ac->perform());
    }
}
```

# ViewClassをテストする

ビュークラスも同様にテストがかけます。

```php:someViewClass.php
<?php 
class APPID_View_someViewClass extends Ethna_ViewClass
{
    function preforward()
    {
        // 何かしらの処理
    }
}
```

```php:someViewClassTest.php
class someViewClassTest extends APPID_UnitTestCase
{
    private $vc;
    private $actionName;
    private $forwardName;

    public function setUp()
    {
        parent::setUp();

        $this->forwardName = 'someViewClass';
        $this->vc = $this->createViewClass($this->forwardName);
    }

    public function testPreforwardNormal()
    {
        $this->assertSome(null, $this->vc->preforward());
    }
}
```
ここまではなんの問題もないですね。

# モック・スタブ

テストを進めていくとマネージャーやオブジェクト、クラスの一部のメソッドをモック化したい場面が出てくると思います。

この場合は、PHPUnitで作成したモッククラスを注入します。
Ethna2.3.7＋PHP5.2の組み合わせはメンバ変数、メソッドの全てがパブリックなので
注入は特に難しく考えずに上書きしてやることで対応可能です。

### マネージャークラスのモック化

以下のように`backend`の`getManager`メソッドからマネージャークラスを返すコードがあったとします。

```php
class APPID_Action_someActionClass extends Ethna_ActionClass
{
    function prepare()
    {
        $manager = $this->backend->getManager('someManagerClass');
        return $manager->someMethod('abc');
    }
}
```

モックにしたいのは`someManagerClass`の`someMethod`ですが
マネージャークラスは`backend`がインスタンスを返しているので
`someManagerClass`をモック化しただけでは`$manager`に渡すことができません。

この場合は、`backend`の`getManager`メソッドをモック化して、
モック化した`someManagerClass`を返すようにします。
`backend`は、テスト対象クラスの`backend`メンバ変数をモックの`backend`で上書きしてやります。

PHPUnitで作るモック・スタブの詳細は公式ドキュメントを御覧ください。

```php
// マネージャークラスは予めrequireしてやる必要があります
require_once "app_dir/app/someManagerClassManager.php";

public function testManager()
{
        // マネージャークラスのモックを作成
        $manager = $this->getMockBuilder($this->ctl->getManagerClassName('someManagerClass'))
            ->disableOriginalConstructor()
            ->getMock();

        $manager->expects($this->once())
            ->method('someMethod')
            ->with('abc')
            ->will($this->returnValue(true));

        // backendのモックを作成
        $backend = $this->getMockBuilder(get_class($this->backend))
            ->disableOriginalConstructor()
            ->setMethods(array('getManager'))
            ->getMock();

        // $this->backend->getManager('someManagerClass')でモックを返すようにする
        $backend->expects($this->any())
            ->method('getManager')
            ->with('someManagerClass')
            ->will($this->returnValue($manager));

        // テスト対象のアクションクラスのbackendをモックで上書き
        $this->ac->backend = $backend;
        // trueが返ること
        $this->assertSome(true, $this->ac->prepare());
}
```

### sessionクラスのモック化

同様にsessionクラスもモック化したsessionを上書きしてやると
セッションを利用したコードもテストできます。

```php
class APPID_Action_someActionClass extends Ethna_ActionClass
{
    function prepare()
    {
        if ($this->session->get('account_id') === null) {
            return false;
        }
        return true;
    }
}
```

```php
public function testPrepare()
{
        // sessionクラスをモック化
        $session = $this->getMockBuilder(get_class($this->backend->session))
            ->disableOriginalConstructor()
            ->getMock();

        $session->expects($this->once())
            ->method('get')
            ->with('account_id')
            ->will($this->returnValue('hoge'));

        // テスト対象のアクションクラスのsessionをモックで上書き
        $this->ac->session = $session;

        // trueが返ること
        $this->assertSome(true, $this->ac->prepare());
}
```

### クラスの一部メソッドをモック化

この一部モック化ですが、
下のPHP標準関数や、メソッド内でnewされているクラスをモック化する場合によく使います。

```php
class APPID_Action_someActionClass extends Ethna_ActionClass
{
    function prepare()
    {
        $id = 'aaa';
        return $this->someMethod($id);
    }

    function someMethod($id) {
        if ($id !== null) {
            return true;
        }
        return false;
    }
}
```

```php
public function testPrepare()
{
        $this->actionName = 'SomeActionClass';
        // actionクラスを一部モック化
        $action = $this->getMockBuilder($this->ctl->getActionClassName($this->actionName))
            ->disableOriginalConstructor()
            ->setMethods(array('someMethod'))
            ->getMock();

        $action->expects($this->once())
            ->method('someMethod')
            ->will($this->returnValue(true));

        // trueが返ること
        $this->assertSome(true, $action->prepare());
}
```

### PHPの標準関数のモック化

PHPは便利関数が多いので、よく使われる標準関数ですが
このバージョンのPHPだとそのままではモックにできません。

```php
class APPID_Action_someActionClass extends Ethna_ActionClass
{
    function prepare()
    {
        $arr = array('a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5);
        // この標準関数json_encodeはこのままではモックにできない
        return json_encode($arr);
    }
}
```

この場合は、以下のようにクラスのメソッドで標準関数をラッピングして
そのメソッドをモックにすることで対応します。

```php
class APPID_Action_someActionClass extends Ethna_ActionClass
{
    function prepare()
    {
        $arr = array('a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5);
        // このようにクラスのメソッドでラッピングして、テスト時には$this->json_encodeをモックにする
        return $this->json_encode($arr);
    }

    function json_encode($value, $options = 0, $depth = 512)
    {
        return json_encode($value, $option, $depth);
    }
}
```

### メソッド内でnewしているクラス

```php
class APPID_Action_someActionClass extends Ethna_ActionClass
{
    function prepare()
    {
        // このHogeClassはこのままではモックにできない
        $hoge = new HogeCLass();
        return $hoge->otherMethod();
    }
}
```

こちらも以下のように、クラスメソッドでラッピングしてやります。

```php
class APPID_Action_someActionClass extends Ethna_ActionClass
{
    function prepare()
    {
        // テストのときはcreateHogeClassをモックにする
        $hoge = $this->createHogeClass();
        return $hoge->otherMethod();
    }

    function createHogeClass()
    {
        return new HogeClass();
    }
}
```

これらのパターンを組み合わせれば、
Ethnaのほとんどのケースでユニットテストがかけると思います。

# まとめ

これでユニットテストを書く環境が整いました。
あとは、テストを習慣づけることですがこれが一番難しいですね。