何かと理由(時間がない || メンテが面倒)をつけて避けがちなユニット試験。
未だ関わる開発現場で全くユニット試験が書かれていないことも多い。
とは言え、急な仕様変更に伴うデグレ確認や後々の保守開発を考えれば必須かなと。
PHPでユニット試験といえばPHPUnitが有名。
CakePHP3上でコントローラのユニット試験を動かす時にちょっと詰まりました。
その時のメモ書き程度に残しておきます。
動作環境
- PHP7.2
- CakePHP3.6.9
- MySQL5.7
環境構築
composerのパスを通していること.
// CakePHP3初期化(プロジェクトが無い人)
composer self-update && composer create-project --prefer-dist cakephp/app xxxx
// PHPUnitインストール
composer require --dev phpunit/phpunit:"^5.7|^6.0"
JSONレスポンスを返すAPIコントローラの単体試験
bakeコマンドで適当なコントローラ(今回はTest)を作って以下のように定義。
<?php
namespace App\Controller;
use App\Controller\AppController;
use Cake\ORM\TableRegistry;
class TestController extends AppController
{
public function indexget()
{
return $this->response->withStringBody(json_encode(['message' => 'Ok', 'data' => '']));
}
public function indexpost()
{
$entity = TableRegistry::get('TestData')->newEntity($this->request->getData(), ['validate' => 'add']);
if ($entity->getErrors()) {
$response = $this->response->withStatus(400);
return $response->withStringBody(json_encode(['message' => 'Bad Request', 'data' => $entity->getErrors()]));
}
return $this->response->withStringBody(json_encode(['message' => 'Ok', 'data' => '']));
}
}
ルーティングは以下の通り。
$routes->get('/test1', ['controller' => 'Test', 'action' => 'indexget']);
$routes->post('/test2', ['controller' => 'Test', 'action' => 'indexpost']);
バリデーション
<?php
namespace App\Model\Table;
use Cake\Validation\Validator;
use Cake\ORM\Table;
class TestDataTable extends Table
{
public function initialize(array $config)
{
parent::initialize($config);
}
public function validationAdd(Validator $validator)
{
$validator
->requirePresence("name", true, "キーが存在しません")
->notEmpty("name", "必須項目です");
return $validator;
}
}
Controllerクラスのユニット試験。
<?php
namespace App\Test\TestCase\Controller;
use Cake\TestSuite\IntegrationTestCase;
/**
* [実行コマンド(Win)]
* vendor\bin\phpunit tests\TestCase\Controller\TestControllerTest.php
*
* [実行コマンド(Linux)]
* vendor/bin/phpunit tests/TestCase/Controller/TestControllerTest.php
*/
class TestControllerTest extends IntegrationTestCase
{
public function setUp()
{
parent::setUp();
}
/**
* TestController.phpのindexgetメソッド
*/
public function testindexget()
{
$this->get('/test1');
$this->assertResponseCode(200);
$this->assertEquals('Ok', json_decode($this->_response->getBody(), true)['message']);
$this->assertEmpty(json_decode($this->_response->getBody(), true)['data']);
}
/**
* TestController.phpのindexpostメソッド
*/
public function testindexpost()
{
// この2つがないと403エラーになる
$this->cookie("csrfToken", "test-token");
$this->_request['headers'] = ['X-CSRF-Token' => 'test-token'];
$this->post('/test2', ["name" => "テスト太郎"]);
$this->assertResponseCode(200);
$this->assertEquals('Ok', json_decode($this->_response->getBody(), true)['message']);
$this->assertEmpty(json_decode($this->_response->getBody(), true)['data']);
}
}
継承元のIntegrationTestCaseで定義されている各HTTPメソッドに即したものを呼べば良いだけです。
ただCakePHP3ではセキュリティー対策としてデフォルトでcsrf制御ロジックが動作してます。
なのでpost、put、delete、patchを叩く時は工夫が必要。
CakePHP3でのCSRF対策
3.5まではCsrfComponentとしてコンポーネントで提供されてました。
なのでbeforeFilterでoffにすることが出来たようです。
以下のページに詳細が書かれています。
しかし3.6からはミドルウェアでの提供に変更。
ちなみにcsrfのミドルウェア(CsrfProtectionMiddleware)はsrc配下のApplication.phpで呼ばれてます。
// 一部抜粋
Application extends BaseApplication
{
public function middleware($middlewareQueue)
{
$middlewareQueue
->add(ErrorHandlerMiddleware::class)
->add(AssetMiddleware::class)
->add(new RoutingMiddleware($this, '_cake_routes_'))
// Add csrf middleware.
->add(new CsrfProtectionMiddleware([
'httpOnly' => true
]));
return $middlewareQueue;
}
}
で実際にミドルウェアの中身を見てみると...
class CsrfProtectionMiddleware
{
protected $_defaultConfig = [
'cookieName' => 'csrfToken',
'expiry' => 0,
'secure' => false,
'httpOnly' => false,
'field' => '_csrfToken',
];
protected $_config = [];
public function __construct(array $config = [])
{
$this->_config = $config + $this->_defaultConfig;
}
public function __invoke(ServerRequest $request, Response $response, $next)
{
$cookies = $request->getCookieParams();
$cookieData = Hash::get($cookies, $this->_config['cookieName']);
if (strlen($cookieData) > 0) {
$params = $request->getAttribute('params');
$params['_csrfToken'] = $cookieData;
$request = $request->withAttribute('params', $params);
}
$method = $request->getMethod();
if ($method === 'GET' && $cookieData === null) {
$token = $this->_createToken();
$request = $this->_addTokenToRequest($token, $request);
$response = $this->_addTokenCookie($token, $request, $response);
return $next($request, $response);
}
$request = $this->_validateAndUnsetTokenField($request);
return $next($request, $response);
}
protected function _validateAndUnsetTokenField(ServerRequest $request)
{
if (in_array($request->getMethod(), ['PUT', 'POST', 'DELETE', 'PATCH']) || $request->getData()) {
$this->_validateToken($request);
$body = $request->getParsedBody();
if (is_array($body)) {
unset($body[$this->_config['field']]);
$request = $request->withParsedBody($body);
}
}
return $request;
}
protected function _createToken()
{
return hash('sha512', Security::randomBytes(16), false);
}
protected function _addTokenToRequest($token, ServerRequest $request)
{
$params = $request->getAttribute('params');
$params['_csrfToken'] = $token;
return $request->withAttribute('params', $params);
}
protected function _addTokenCookie($token, ServerRequest $request, Response $response)
{
$expiry = new Time($this->_config['expiry']);
return $response->withCookie($this->_config['cookieName'], [
'value' => $token,
'expire' => $expiry->format('U'),
'path' => $request->getAttribute('webroot'),
'secure' => $this->_config['secure'],
'httpOnly' => $this->_config['httpOnly'],
]);
}
protected function _validateToken(ServerRequest $request)
{
$cookies = $request->getCookieParams();
$cookie = Hash::get($cookies, $this->_config['cookieName']);
$post = Hash::get($request->getParsedBody(), $this->_config['field']);
$header = $request->getHeaderLine('X-CSRF-Token');
if (!$cookie) {
throw new InvalidCsrfTokenException(__d('cake', 'Missing CSRF token cookie'));
}
if (!Security::constantEquals($post, $cookie) && !Security::constantEquals($header, $cookie)) {
throw new InvalidCsrfTokenException(__d('cake', 'CSRF token mismatch.'));
}
}
}
指定HTTPメソッドもしくはBodyにデータがある場合、_validateTokenメソッドが実行。
クッキーのトークン値とパラメータ(post値とheader値)のバリデーションが行われてます。
今回はリクエストオブジェクトのheadersにトークン値を設定し動作させてます。
ただpost値に含めても動作はしました。
public function testindexpost()
{
$this->cookie("csrfToken", "test-token");
$this->post('/test2', ["name" => "テスト太郎", "_csrfToken" => "test-token"]);
$this->assertResponseCode(200);
$this->assertEquals('Ok', json_decode($this->_response->getBody(), true)['message']);
$this->assertEmpty(json_decode($this->_response->getBody(), true)['data']);
}
postやputはこのやり方でも大丈夫ですが・・・
IntegrationTestCaseではdeleteはpost値を受け付けなくなってます。
// IntegrationTestCaseクラス
public function delete($url)
{
$this->_sendRequest($url, 'DELETE');
}
なので直で_sendRequestを呼び出すことになりますね。
$this->cookie("csrfToken", "test-token");
$this->_sendRequest('url', 'DELETE', $postData);
面倒なのでheaderに入れるのが一番楽かも。
バージョンが上がると仕様も結構変わってますね。
変な所で時間と体力を消耗されられるので、改めて公式ドキュメントを読むことが大切だと痛感。