CodeIgniter3でci-phpunit-test 2.xを利用したUnitTestいろいろ①（curlをMock化したAPIのUnit Test）

# 概要

CodeIgniter3でのWebアプリケーション開発で、ci-phpunit-testを利用したテストコーディングを行いました。
試行錯誤しつつ、なんとなくこんな感じでテストすればいいのかな？というソースになったので、メモとして残します。

CodeIgniter3でci-phpunit-test 2.xの環境を用意する方法については、[CodeIgniter3 + PHPUnit 9.4.0 + ci-phpunit-test dev-2.x を利用した Unit Test環境の整備](https://qiita.com/You_name_is_YU/items/df51b52820af9e41f2ec)に載っています。
composerで標準的にセットアップされるものでは、バージョンが合わずに正常動作しないため、ちょっと大変でした。

ここでは、とりあえずcurlをMock化して、外部サービスのAPI実行を処理しているModelクラスのUnit Testについてまとめます。

# ソースの構成

```shell-session
.application
├── models
│   └── Gmo_api_model.php
├── libraries
│   └── Curl_request.php
├── tests
│   ├── models
│   │   └── Gmo_api_model_test.php
│   └── phpunit.xml
```

# 各ソースの内容とポイント

## phpunit.xmlの修正

必須の内容ではありませんが、phpunitを実行するときに、毎回サンプルのWelcomコントローラのテストが動作するのが邪魔だったので、`<exclude>./controllers/Welcome_test.php</exclude>`の定義をphpunit.xml に追加しています。

```xml:application/tests/phpunit.xml
<phpunit
	bootstrap="./Bootstrap.php"
	backupGlobals="true"
	colors="true">
	<testsuites>
		<testsuite name="CodeIgniter Application Test Suite">
			<directory suffix="test.php">./</directory>
			<exclude>./_ci_phpunit_test/</exclude>
			<exclude>./controllers/Welcome_test.php</exclude>
		</testsuite>
	</testsuites>
	<filter>
		<whitelist>
			<directory suffix=".php">../controllers</directory>
			<directory suffix=".php">../models</directory>
			<directory suffix=".php">../views</directory>
			<directory suffix=".php">../libraries</directory>
			<directory suffix=".php">../helpers</directory>
			<directory suffix=".php">../hooks</directory>
		</whitelist>
	</filter>
	<logging>
		<log type="coverage-html" target="build/coverage"/>
		<log type="coverage-clover" target="build/logs/clover.xml"/>
		<log type="junit" target="build/logs/junit.xml"/>
	</logging>
</phpunit>
```

## curl専用のクラス

curlを使ったAPI呼び出しを行う処理をラッピングする独自のCurl_requestクラス。
curlのfunctionをそのままModel内で実行されてしまうとMock化できないため、外出しにしています。

```php:application/libraries/Curl_request.php
<?php

defined('BASEPATH') OR exit('No direct script access allowed');

interface Http_request
{
    public function set_option($name, $value);
    public function execute();
    public function get_info();
    public function close();
}

/**
 * API呼び出しを行うcurlをラッピングする独自クラス。
 * PHPUnitでMock化するために作成。
 */
class Curl_request implements Http_request
{
    private $handle = null;

    public function __construct() {
    }

    public function init($url) {
        $this->handle = curl_init($url);
    }

    public function set_option($name, $value) {
        curl_setopt($this->handle, $name, $value);
    }

    public function execute() {
        return curl_exec($this->handle);
    }

    public function get_info() {
        return curl_getinfo($this->handle);
    }

    public function close() {
        curl_close($this->handle);
    }
}
```

## テスト対象となるモデルクラス

GMO-PGに用意されているプロトコルタイプのAPIを操作するためのモデルクラスです。

- **ポイント**
    - curl_setoptやcurl_execを直接実行せず、`libraries/Curl_request`を利用してAPIを操作している。
    - イメージ的にはDIのSetter Injectionのような感じで、インスタンス変数`$curl`がCurl_requestの参照変数となるように定義。
        - テストクラスからはこのインスタンス変数にMockオブジェクトをセットすることでUnit Testを行う。

```php:application/models/Gmo_api_model.php
<?php

/**
 * GMO-PGへのAPI操作を責務とするモデル
 */
class Gmo_api_model extends CI_Model {

    /** セッションに格納されたログインユーザ情報 */
    private $user = null;

    /** curl API実行用オブジェクト */
    public $curl = null;

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

        // curlでのAPI実行用ライブラリのロードとインスタンス変数へのセット
        $this->load->library('Curl_request');
        $this->curl = $this->curl_request;
    }

    /**
     * API操作に必要なユーザ情報をセットする
     */
    public function init($u) {
        $this->user = $u;
    }

    /**
     * GMO決済画面へのURLを生成して返却する
     */
    public function get_payment_url($flg_conf_member = false)
    {
        // 決済URL取得API
        $url = 'https://pt01.mul-pay.jp/payment/GetLinkplusUrlPayment.json';

        // GMOの決済で指定するオーダーIDは一意でなければいけない（注文ID先頭4桁を会員IDにした。）
        $orderId = str_pad($this->user['user_id'], 4, '0', STR_PAD_LEFT) . 'OR' . date('YmdHis');

        // json形式のパラメータを生成するための配列パラメータ定義
        $arr_param = array(
            'geturlparam'=> array(
                'ShopID'=> SHOP_ID,
                'ShopPass'=> SHOP_PASS,
                'TemplateNo'=> '1'
            ),
            'configid'=> 'test01',
            'transaction'=> array(
                'OrderID'=> $orderId,
                'Amount'=> 10000,
                'Tax'=> 1000,
                'PayMethods'=> ['credit']
            ),
            'credit'=> array(
                'JobCd'=> 'CAPTURE',
                'Method'=> '1'
            )
        );

        // GMO会員IDのパラメータを設定するか判別してパラメータに追加
        $arr_member_id = array(
            'credit'=> array(
                'MemberID'=> $this->user['user_id']  // GMO会員IDの指定
            )
        );

        if ($flg_conf_member) {
            if ($this->gmo_exists_member($this->user['user_id'])) {
                $arr_param = array_merge_recursive($arr_param, $arr_member_id);
            }
        } else {
            // $flg_conf_member=falseの場合は無受験に会員IDを指定する。
            $arr_param = array_merge_recursive($arr_param, $arr_member_id);
        }

        // 配列→json変換
        $param = json_encode($arr_param);
        return $this->get_gmo_linkurl($url, $param);
    }

    /**
     * GMO決済画面へのURLを生成して返却する
     */
    public function get_secure_payment_url($flg_conf_member = false)
    {
        // 決済URL取得API
        $url = 'https://pt01.mul-pay.jp/payment/GetLinkplusUrlPayment.json';

        // GMOの決済で指定するオーダーIDは一意でなければいけない（注文ID先頭4桁を会員IDにした。）
        $orderId = str_pad($this->user['user_id'], 4, '0', STR_PAD_LEFT) . 'OR' . date('YmdHis');

        // json形式のパラメータを生成するための配列パラメータ定義
        $arr_param = array(
            'geturlparam'=> array(
                'ShopID'=> SHOP_ID,
                'ShopPass'=> SHOP_PASS,
                'TemplateNo'=> '1'
            ),
            'configid'=> 'test01',
            'transaction'=> array(
                'OrderID'=> $orderId,
                'Amount'=> 10000,
                'Tax'=> 1000,
                'PayMethods'=> ['credit']
            ),
            'credit'=> array(
                'JobCd'=> 'CAPTURE',
                'Method'=> '1',
                'TdFlag'=> '2', // 3Dセキュア認証を契約に従って実施
                'Tds2Type'=> '1'
            )
        );
        // 3Dセキュア認証テスト用カード
        // 3DS1.0用　＝　https://faq.gmo-pg.com/service/detail.aspx?id=1681&a=102&isCrawler=1
        // 3DS2.0用　＝　https://faq.gmo-pg.com/service/detail.aspx?id=2379&a=102&isCrawler=1

        // GMO会員IDのパラメータを設定するか判別してパラメータに追加
        $arr_member_id = array(
            'credit'=> array(
                'MemberID'=> $this->user['user_id']  // GMO会員IDの指定
            )
        );

        if ($flg_conf_member) {
            if ($this->gmo_exists_member($this->user['user_id'])) {
                $arr_param = array_merge_recursive($arr_param, $arr_member_id);
            }
        } else {
            // $flg_conf_member=falseの場合は無受験に会員IDを指定する。
            $arr_param = array_merge_recursive($arr_param, $arr_member_id);
        }

        // 配列→json変換
        $param = json_encode($arr_param);
        return $this->get_gmo_linkurl($url, $param);
    }

    /**
     * GMOカード会員編集画面への遷移URLを生成して返却する
     */
    public function get_member_url()
    {
        // カード編集URL取得API
        $url = 'https://pt01.mul-pay.jp/payment/GetLinkplusUrlMember.json';

        // json形式のパラメータを生成するための配列パラメータ定義
        $arrayParam = array(
            'geturlparam'=> array(
                'ShopID'=> SHOP_ID,
                'ShopPass'=> SHOP_PASS,
                'TemplateNo'=> '1'
            ),
            'configid'=> 'test01',
            'member'=> array(
                'Cardeditno'=> 'CardEdit'.$this->user['user_id'],
                'MemberID'=> $this->user['user_id']  // GMO会員IDの指定
            )
        );

        // 配列→json変換
        $param = json_encode($arrayParam);
        return $this->get_gmo_linkurl($url, $param);
    }

    /**
     * 指定されたGMO会員IDがGMOサイトに存在するか確認し、
     * その結果をtrue / falseで返却する
     */
    public function gmo_exists_member($member_id) {
        $param = [
            'SiteID'           => SITE_ID,
            'SitePass'         => SITE_PASS,
            'MemberID'         => $member_id
        ];

        // リクエストコネクションの設定
        $this->curl->init('https://pt01.mul-pay.jp/payment/SearchMember.idPass');
        $this->curl->set_option(CURLOPT_POST, true);
        $this->curl->set_option(CURLOPT_RETURNTRANSFER, true);
        $this->curl->set_option(CURLOPT_CUSTOMREQUEST, 'POST');
        $this->curl->set_option(CURLOPT_POSTFIELDS, $param);

        // リクエスト送信
        $response = $this->curl->execute();
        $curlinfo = $this->curl->get_info();
        $this->curl->close();

        // 会員IDが見つかればtrue / 正しく見つからない「E01390002」場合はfalseを返却
        $http_stscd = $curlinfo['http_code'];
        parse_str( $response, $data );

        if($http_stscd != 200) {
            $gmo_errcd = '';
            if (array_key_exists('ErrCode', $data)) {
                $gmo_errcd = $data['ErrCode'];
            }
            $errmsg = <<< EOD
            $url . 'SearchMember.idPass API実行が失敗しました。 : '
            'HTTPステータスコード ： ' . $http_stscd
            'GMOエラーコード ： ' . $gmo_errcd
            EOD;
            throw new Exception($errmsg);
        }

        $err_info = '';
        if (array_key_exists('ErrInfo', $data)) {
            $err_info = $data['ErrInfo'];
            if ($err_info == 'E01390002'){
                return false;
            } else {
                throw new Exception('SearchMember.idPassのErrInfoで予期せぬリターン：' . $err_info);
            }
        }
        return true;
    }

    /**
     * プロトコルタイプのAPIを利用し、キー型のパラメータ指定方法によるAPI実行結果から、URL情報だけを抽出して返却する。
     * API実行に失敗した場合はExceptionをthrow
     */
    private function get_gmo_linkurl($url, $param) {

        // リクエストコネクションの設定
        $this->curl->init($url);
        $this->curl->set_option(CURLOPT_POST, true);
        $this->curl->set_option(CURLOPT_RETURNTRANSFER, true);
        $this->curl->set_option(CURLOPT_HTTPHEADER, array('Content-Type: application/json; charset=utf-8'));
        $this->curl->set_option(CURLOPT_CUSTOMREQUEST, 'POST');
        $this->curl->set_option(CURLOPT_POSTFIELDS, $param);

        // リクエスト送信
        $response = $this->curl->execute();
        $curlinfo = $this->curl->get_info();
        $this->curl->close();

        log_message('debug', 'kiteru?');
        log_message('debug', $response);

        $resJson = json_decode($response, true);

        // LinkUrlが取得できなければエラーとして扱う
        if (!array_key_exists('LinkUrl', $resJson)) {
            $http_stscd = $curlinfo['http_code'];
            
            $errmsg = <<< EOD
            $url . ' API実行が失敗しました。 : '
            'HTTPステータスコード ： ' . $http_stscd
            'GMO Error : ' . $response
            EOD;

            throw new Exception($errmsg);
        }

        // URL取得APIの実行結果からリンク情報を取得し返却
        return $resJson['LinkUrl'];
    }
}
```

## テストクラス

- **ポイント**
    - `getMockBuilder`と`setMethods`を使って`Curl_request`クラスをMock化
    - Mock化した`Curl_request`で`curl_exec`や`curl_getinfo`の実行結果をMock用の結果に差し替えてテストを実行

```php:application/tests/models/Gmo_api_model_test.php
<?php

/**
 * Gmo_api_modelクラスのテスト
 */
class Gmo_api_model_test extends TestCase
{
    // Curl_requestのモックオブジェクト
    private $curl_mock = null;

    /**
     * テスト初期処理（各テストメソッドの実行前処理）
     */
    public function setUp():void
    {
        $this->resetInstance();
        $this->CI->load->model('Gmo_api_model');
        $this->CI->load->library('Curl_request');
        $this->obj = $this->CI->Gmo_api_model;
    }

    /**
     * Curl_requestクラスのモックオブジェクトを初期化する。
     */
    private function init_curl_mock() {
        // Curl_request.phpで利用しているHttp_requestインターフェースのモックを作成してAPI実行結果をMock化
        $this->curl_mock = $this->getMockBuilder('Curl_request')
                                    ->setMethods(['init','set_option','execute','get_info','close'])
                                    ->getMock();
    }

    /**
     * @test
     */
    public function GMO決済リンクUrlが正常に取得できること(): void
    {
        // Mock化したAPIのcurl_exec実行結果を定義しreturnで利用
        $this->init_curl_mock();
        $ret_exec = array(
            'OrderID'=> 'sample-123456789',
            'LinkUrl'=> 'https://[ドメイン]/v2/plus/tshop11223344/checkout/0258d6e9232978d004bf776c26acb435c7bc9eca33b40798a714a9dde2dfe0c5',
            'ProcessDate'=> '20200727142656'
        );
        $this->curl_mock->method('execute')->willReturn(json_encode($ret_exec));
        // MockオブジェクトでCurl_requestへの参照を切り替え
        $this->obj->curl = $this->curl_mock;

        // Gmo_api_modelを初期化（ログインユーザ情報をセット）
        $user = array(
            'user_id'=> 1
        );
        $this->obj->init($user);

        $url = '';
        try {
            $url = $this->obj->get_payment_url();
        } catch(Exception $e) {
            $this->fail('決済URLの取得に失敗 : ' . $e->getMessage());
        } 
        // 決済URLの接続URLが取得できていればOK
        $this->assertGreaterThanOrEqual(0, strpos($url, 'https://stg.link.mul-pay.jp/v2/plus'));
        $this->assertGreaterThanOrEqual(10, strpos($url, 'checkout/'));
    }

    /**
     * @test
     */
    public function GMO決済リンクUrl取得失敗の詳細がExceptionで伝播されること(): void
    {
        // Mock化したAPIのcurl_exec実行結果を定義しreturnで利用
        $this->init_curl_mock();
        $ret_exec = array(
            array(
                'ErrCode'=> 'EZ1',
                'ErrInfo'=> 'EZ1004005'
            ),
            array(
                'ErrCode'=> 'EZ1',
                'ErrInfo'=> 'EZ1004001'
            )
        );
        $this->curl_mock->method('execute')->willReturn(json_encode($ret_exec));

        // Mock化したAPIのget_info実行結果を定義しreturnで利用
        $ret_info = array(
            'http_code'=> '400'
        );
        $this->curl_mock->method('get_info')->willReturn($ret_info);

        // MockオブジェクトでCurl_requestへの参照を切り替え
        $this->obj->curl = $this->curl_mock;

        // Gmo_api_modelを初期化（ログインユーザ情報をセット）
        $user = array(
            'user_id'=> 1
        );
        $this->obj->init($user);

        $url = '';
        try {
            $url = $this->obj->get_payment_url();
        } catch(Exception $ex) {
            $err_msg = $ex->getMessage();
            $this->assertMatchesRegularExpression('/API実行が失敗しました。/', $err_msg);
            $this->assertMatchesRegularExpression('/HTTPステータスコード/', $err_msg);
            $this->assertMatchesRegularExpression('/ErrCode/', $err_msg);
            $this->assertMatchesRegularExpression('/ErrInfo/', $err_msg);
            $this->assertMatchesRegularExpression('/400/', $err_msg);
            $this->assertMatchesRegularExpression('/EZ1/', $err_msg);
            $this->assertMatchesRegularExpression('/EZ1004005/', $err_msg);
        } 
    }

    /**
     * @test
     */
    public function GMOカード会員編集Urlが正常に取得できること(): void
    {
        // Mock化したAPIのcurl_exec実行結果を定義しreturnで利用
        $this->init_curl_mock();
        $ret_exec = array(
            'Cardeditno'=> 'CardEdit1',
            'LinkUrl'=> 'https://stg.link.mul-pay.jp/v2/plus/tshop99999999/member/7c1987623098alkje1617e7370c6899bb3df87e4dd52079e957c1acb42d5b44f5b67',
            'ProcessDate'=> '20201010143013',
            'WarnList'=> array(
                'warnCode'=> 'EZ4',
                'warnInfo'=> 'EZ4136014'
            )
        );
        $this->curl_mock->method('execute')->willReturn(json_encode($ret_exec));
        // MockオブジェクトでCurl_requestへの参照を切り替え
        $this->obj->curl = $this->curl_mock;

        // Gmo_api_modelを初期化（ログインユーザ情報をセット）
        $user = array(
            'user_id'=> 1
        );
        $this->obj->init($user);

        $url = '';
        try {
            $url = $this->obj->get_member_url();
        } catch(Exception $e) {
            $this->fail('GMOカード会員編集Urlの取得に失敗 : ' . $e->getMessage());
        } 
        // GMOカード会員編集Urlの接続URLが取得できていればOK
        $this->assertGreaterThanOrEqual(0, strpos($url, 'https://stg.link.mul-pay.jp/v2/plus'));
        $this->assertGreaterThanOrEqual(10, strpos($url, 'member/'));
    }

}
```

# phpunitの実行

以下のコマンドを実行することで、上記までで開発したUnit Testのコードが実行され結果が表示されます。

```shell-session
# cd /var/www/html/codeigniter3/application/tests/
# ../../vendor/bin/phpunit --testdox

PHPUnit 9.4.0 by Sebastian Bergmann and contributors.

Warning:       No code coverage driver available
Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

Gmo_api_model_test
 ✔ G m o決済リンク urlが正常に取得できること
 ✔ G m o決済リンク url取得失敗の詳細が exceptionで伝播されること
 ✔ G m oカード会員編集 urlが正常に取得できること

Time: 00:00.047, Memory: 8.00 MB
```

# 最後に

本当はSpring FrameworkのDI Containerみたいなのがいて、勝手にInjectionしてくれて、テストできるのがよかったんですが、そんな感じの情報が見つからなかったのでとりあえずこんな感じに作ってみました。