2016-02-19 追記
Laravel5.4がリリースされテスト周りの書き方が大きく変更されています。
この記事の内容だとLaravel5.4では動作させる事が出来ません。
時間が出来次第、この記事にLaravel5.4でも動作するように編集します。
(参考)
この記事で伝えたい事
- テストコードを書く事により得られるメリット
- テストコードを書く時上で必要な考え方
- REST APIでの具体的なテストコードの書き方
仕事でLaravel 5.3を触る機会があったので今回のサンプルではLaravel 5.3を利用します。
とはいえテストコードの基本的な考え方は他の言語やフレームワークでもほぼ同じですので、他の言語でも一部は参考に出来るかと思います。
サンプルとなるREST APIの仕様
APIには以下のURLでアクセスを行います。
-
GET /v1/accounts
- 登録されているアカウントのリストを取得する -
GET /v1/accounts/1
- アカウント#1を取得する -
POST /v1/accounts
- 新しいアカウントを作成する -
PUT /v1/accounts/1
- アカウントを#1更新する -
PATCH /v1/accounts/1
- アカウント#1を部分的に更新する -
DELETE /v1/accounts/1
- アカウント#1を削除する
今回はPOST /v1/accounts
のテストコードを作成する流れを説明します。
ソースコードはgithub にUPしてあります。
アカウント作成APIPOST /v1/accounts
の仕様は以下のようになっています。
パラメータ
パラメータ | データ型 | 最大桁数 | 必須 or 任意 |
---|---|---|---|
string | 128 | 必須 | |
password | string | 100 | 必須 |
email_verified | boolean | 1 | 任意 |
- email
- メールアドレスとして正しい形式である事
- password
- 半角英数字(小文字)が最低1種類それぞれ使われていて、8文字以上である事
- email_verified
- メールアドレスの検証を行ったかどうかを表すステータス ※詳しくはこちら のemail_verifiedを参照
- 1もしくはtrueを指定した時のみDBに1で登録される
$ curl -kv -X POST -d "email=keita@gmail.com" -d "password=Password12" https://dev.laravel-api.net/v1/accounts
正常終了時のレスポンス
- ResponseHeader
- HTTPステータスコード
- 201を設定
- Location
- 作成されたリソースを取得する為のURL
- X-Request-Id
- リクエスト毎に設定されるユニークな値(UUIDバージョン4形式)
- HTTPステータスコード
HTTP/1.1 201 Created
Server: nginx/1.10.2
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Location: https://dev.laravel-api.net/v1/accounts/1
X-Request-Id: fb9708d3-5b8a-4259-9611-3eea54fceaf0
Cache-Control: no-cache
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
Date: Sun, 27 Nov 2016 09:15:18 GMT
- ResponseBody
- データフォーマットはJSON形式
- _linksに対象リソースを表すURLを設定する
- _embeddedに対象リソースに関連するリソースを入れる
- sub
- アカウントの識別子を表す値 ※詳しくはこちら のsubを参照
- account_status
- アカウントの状態を表す値、初期状態はenabled(有効)、退会したアカウント、つまりDELETEされたアカウントはcanceledという状態に変更される。
- email
- 登録されたメールアドレス
- email_verified
- パラメータにemail_verifiedを1で設定していない限りは0が返却される
- password_hash
- 生成されたパスワードハッシュパスワードハッシュの生成はpassword_hash() を利用します。
{
"sub": 1,
"account_status": "enabled",
"_links": {
"self": {
"href": "/v1/accounts/1"
}
},
"_embedded": {
"email": "keita@gmail.com",
"email_verified": 0,
"password_hash": "$2y$10$bRNp1/KCP1G.UskIfizKc./bhCRBqzU/dOc/1G5Tezg7NJjX5TR26"
}
}
※これはサンプルなのでpassword_hashをレスポンスで返しているが商用のAPIでは絶対にやるべきではない。
異常終了時のレスポンス
以下の条件の場合はエラーでレスポンスを返却する。
- 既に登録済のemailをリクエストした時
- HTTPステータスコード409を返却
- エラーコード40000を返却
- パラメータ仕様と異なるデータ型でリクエストされてきた時(バリデーションエラー)
- HTTPステータスコード422を返却
- エラーコード422を返却
- バリデーションを通過しなかったパラメータのキー名を配列で返す
$ curl -kv -X POST -d "email=keita@gmail.com" -d "password=Password12" https://dev.laravel-api.net/v1/accounts
HTTP/1.1 409 Conflict
Server: nginx/1.10.2
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Request-Id: 7b754403-638e-4ac6-8722-44322ebefd52
Cache-Control: no-cache
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
Date: Sun, 27 Nov 2016 10:50:27 GMT
{
"code":40000,
"message":"email has already been registered."
}
curl -kv -X POST -d "email=gmail.com" -d "password=pass" https://dev.laravel-api.net/v1/accounts
HTTP/1.1 422 Unprocessable Entity
Server: nginx/1.10.2
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Request-Id: 3cefc92d-234b-478c-a273-c463a96d2ecb
Cache-Control: no-cache
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
Date: Sun, 27 Nov 2016 11:04:00 GMT
{
"code": 422,
"message": "Unprocessable Entity",
"errors": {
"email": [
"The email must be a valid email address."
],
"password": [
"validation.password"
]
}
}
データベースの構造
CREATE TABLE `accounts` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`status` TINYINT NOT NULL DEFAULT 0,
`lock_version` INT(11) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
CREATE TABLE `accounts_emails` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` INT(11) UNSIGNED NOT NULL,
`email` VARCHAR(255) NOT NULL,
`email_verified` BOOLEAN NOT NULL DEFAULT 0,
`lock_version` INT(11) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_accounts_emails_01` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
CREATE TABLE `accounts_passwords` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` INT(11) UNSIGNED NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`lock_version` INT(11) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_accounts_passwords_01` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
POST /v1/accounts
がリクエストされた際にこれら3つのテーブルにデータが登録されます。
APIのレスポンスと各カラムは下記のような関係になっています。
sub → accounts.id
email → accounts_emails.email
email_verified → accounts_passwords.password_hash
password → accounts_passwords.password_hash(ハッシュ化された値が登録される)
テストケースの作成
APIの仕様を理解したら次に考えるべきはテストケースです。
テストケースとは「この状態でこのパラメータを送信すれば、この処理が行われてこのレスポンスが期待される」といった内容です。
どのようなテストを行えば、このAPI POST /v1/accounts
が先程の仕様通りに作られていると担保出来るかを考える必要があります。
ここでは下記のようなテストケースを用意すれば仕様の担保が出来そうです。
正常系のテストケース
APIが正常終了する際のテストケースです。
- リクエストパラメータに登録されていないemailを指定してリクエストを送信する。(任意パラメータのemail_verifiedは指定しない)
- リクエストパラメータに登録されていないemailを指定してリクエストを送信する。(任意パラメータのemail_verifiedに1を指定する)
仕様通りのJSONデータが返却されている事を担保する為に以下の点を確認します。
- subに自動採番された数値型のIDが設定されている事
- account_statusに"enabled"が設定されている事
- _links.self.hrefに対象リソースを示すURL設定されている事
- _embedded.emailにリクエストしたemailが設定されている事
- _embedded.email_verifiedにリクエストした値が設定されている事(1を指定した時以外は0が設定されているハズ)
- password_hashに生成されたパスワードハッシュが設定されている事
- password_hashに設定された値がpassword_verify()でマッチする事
さらにHTTPのステータスコードが201になっている事とResponseHeaderにX-Request-Idが含まれている事を確認します。
異常系のテストケース
- 既に登録済のemailをリクエストした時にHTTPステータスコード409、ResponseBody.codeに40000が設定されている事を確認する
- バリデーションエラー、パラメータ(email,password,email_verified)にそれぞれ不正な値を入力し意図した通りにerrorsの中身が設定されている事を確認する
こんなところでしょうか。
バリデーションエラーに関してはどのようなパラメータを渡すか?厳密に考え出すと何度もテストを繰り返す必要があります。
テストコードを書く理由
私はテストコードを書く理由は大きく2つあると考えます。
- 検証コストの削減
テストケースを洗い出してみると分かる通り、一見単純そうに見えるAPIでもこれだけのテストケースが存在します。
これらのテストを手作業でやろうとすると、まずテスト仕様書的な物にテストケースを書き出して、仕様書に従いHTTPクライアント(curlコマンドやChrome等のRESTクライアントを使う等)でリクエストを送り、返却値を見て合っているか目視で確認、その後DBに接続しSELECT文で意図した形で登録されているか確認する。といった手順になるかと思います。
一度作って終わりのなら良いですがWebサービスは常に改善や新しい機能の追加に追われる傾向にあります。よって一度作ったら終わりという事はあまりなく常にコードは変更される可能性があります。
一度コードが変更されたら、変更内容にもよりますが、基本的に再度テストが必要です。
その度にこれらのテスト作業を行うのはあまりにも非効率です。
テストコードを書いてこれらの作業を自動化します。
これがテストを書く1つ目の理由「検証コストの削減」です。
- リファクタリングが比較的容易になる
この説明を行う前にコードの構成に軽く触れておきます。
今回利用しているサンプルコードはHTTPリクエストに応じて実行されるControllerをroutingで定義しています。
プログラムの実行順序としては以下のような流れになります。
- ドキュメントルートにリクエストが送信される
- routesに記載されているルーティングルールに従って、対象のControllerが呼ばれる。※今回のAPIの場合、\App\Http\Controllers\V1\ AccountsController::store()が呼ばれます。
- Controllerは対象のドメインロジックを\Domain\ServiceFacade::execute() を通じて呼び出します。
- 最終的に呼び出されるのは\Domain\Account\AccountService::create()が呼び出されます。
※ちなみにこのドメイン層(ビジネスロジックが存在する場所)部分は私が独自実装した部分でLaravelとはあまり関係ありません。
※設計的にドメイン層にある◯◯Serviceに実装されているメソッドは複数のControllerで呼ばないようにしています。
今回はAPI単位にテストクラスを作るので、\App\Http\Controllers\V1\ AccountsController::store()に対してPOSTリクエストを送信し、テストコードを書いていけば、そこから先で使われているその他のクラスに関してもテストが出来ます。
このようにAPIのIFに対してテストを書きておくと、内部実装にいかなる変更があったとしてもIFさえ変わらなければ、動作保証が容易になります。
最初から完璧な設計を行うのは無理があるので、ドメイン層(ビジネスロジック部分)はリファクタリングを繰り返しながら徐々に形を作っていくのが現実的です。
もしテストコードがなかったら、動作保証を行う為に再度テストのやり直しを膨大な工数を使って行うハメになります。
書いてみると分かるのですが、フロントエンドのUIテストと比較して、バックエンドのREST APIはテストが書きやすい部類ですので書かないのは勿体ないと個人的には思います。
テストコードで気をつけるべき点
以下は私がテストコードを書く時に特に気をつけている点です。
大きく2点。
- 冪等性が担保されている事
- 何度実行しても同じ結果が得られる事
- 通ったり通らなかったりするテストが存在しない
- テストケース同士が疎結合である事
- (例)テストA、テストBの順番で実行しないと通らないようなテストケースは作らない
テストコードの基本設計と粒度
テストケースで気をつけるべき点に記載した内容をカバーする為に以下の流れでテストを作成します。
- テストケース毎にテストに必要な事前データを準備する
- これをFixtureと呼びます
- APIの単位でテストクラスを作成しそこに必要なテストケースを実装していく
テストの作成にはLaravelが用意しているPHPUnitのラップクラスを利用します。
以下のクラスを作成します。
<?php
/**
* テスト基底クラス
*
* @author keita-nishimoto
* @since 2016-09-08
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
namespace Tests;
use App\Console\Kernel;
/**
* Class AbstractTestCase
*
* @category laravel-api-sample
* @package Tests
* @author keita-nishimoto
* @since 2016-09-08
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
abstract class AbstractTestCase extends \Illuminate\Foundation\Testing\TestCase
{
/**
* The base URL to use while testing the application.
*
* @var string
*/
protected $baseUrl = 'https://dev.laravel-api.net';
/**
* Creates the application.
*
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
/**
* Clean up the testing environment before the next test.
*
* @return void
*/
public function tearDown()
{
$this->beforeApplicationDestroyed(function () {
\DB::disconnect();
});
parent::tearDown();
}
}
これはLaravelが初期状態で用意しているTestCaseにtearDown()メソッドを加えただけの物になります。
tearDown()はテストケース終了時に必ず呼ばれるPHPUnitのメソッドです。
この中ではテスト用に作成したDBのコネクションを廃棄しています。
※これがないとテストが増えてきた時に一括実行等するとDBのmax connectionに達してしまいテストの実行が不可能になってしまいます。
テストに必要な事前データ(Fixture)ですが、これはLaravelのSeederクラスという仕組みがあるのでこれを利用します。
Seederクラスはざっくり言うと、事前にアプリケーションの実行に必要なマスタデータ的なデータを登録しておく為の仕組みです。
<?php
/**
* アカウント作成テストデータ投入
*
* @author keita-nishimoto
* @since 2016-11-07
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
namespace Tests\Domain\Service\Account;
use Factories\Account\ValueFactory;
use Illuminate\Database\Seeder;
/**
* Class CreateTestSeeder
*
* @category laravel-api-sample
* @package Tests\Domain\Service\Account
* @author keita-nishimoto
* @since 2016-11-07
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
class CreateTestSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
\DB::table('accounts')->truncate();
\DB::table('accounts_emails')->truncate();
\DB::table('accounts_passwords')->truncate();
$this->testFailEmailIsAlreadyRegistered();
}
/**
* 異常系テスト
* メールアドレスが既に登録されている
*/
private function testFailEmailIsAlreadyRegistered()
{
$sub = 1;
$idSequence = 1;
$email = 'account-create-test-duplicated@gmail.com';
$accountStatus = 0;
$accounts = [
'id' => $sub,
'status' => $accountStatus,
];
\DB::table('accounts')->insert($accounts);
$accountsEmails = [
'id' => $idSequence,
'account_id' => $sub,
'email' => $email,
];
\DB::table('accounts_emails')->insert($accountsEmails);
$passwordValue = ValueFactory::createPasswordValue(
[
'password' => '9password1',
]
);
$accountsPasswords = [
'account_id' => $sub,
'password_hash' => $passwordValue->getPasswordHash(),
];
\DB::table('accounts_passwords')->insert($accountsPasswords);
}
}
Seederクラスは実行時にrun()メソッドを呼び出します。
この中で関連する3つのテーブルに対してtruncateを実行しデータをクリアしています。
これは先程上げた「冪等性が担保されている事」を実現する為に、実行時にデータの中身を一度空にする事でテストの冪等性を担保する為の物になります。
testFailEmailIsAlreadyRegistered()メソッドは後ほど利用する「既に登録済のemailをリクエストした時」に利用する事前データです。
つまりrun()メソッドの中で行われている事は関連テーブルの必要なデータを全て削除し、テストケースに必要なデータを追加しています。
これで何度テストを実行しても必ずDBの状態はここに定義されている状態になるので冪等性が担保されるという事になります。
テスト用の環境変数とテスト用のDBを用意する
テスト実行時にSeederクラスを使ってDBを一度クリーンしているので、テスト用の環境変数とテスト用のDBを用意しておくのが無難です。
もし間違って本番環境でテストが実行されてしまったら必要なデータも全て削除されてしまう危険性がありますのでそのリスクを回避する為です。
ちなみにLaravelの場合は既に、phpunit.xml(テストクラスの設定ファイル)に環境変数が記載されています。<env name="APP_ENV" value="testing"/>
という記述がそれになります。
プロジェクトルートに.env.testingを用意します。
APP_ENV=local
APP_KEY=base64:JGTKoAh+vjAXSSsPCvD2M9mcZfEaCrdrAGhav3sNPMs=
APP_DEBUG=true
APP_LOG_LEVEL=debug
APP_LOG=daily
APP_URL=https://dev.laravel-api.net
DB_CONNECTION=mysql
DB_HOST=laravel-api-db.master-1
DB_PORT=3306
DB_DATABASE=laravel_api_test
DB_USERNAME=laravel_api_test
DB_PASSWORD=Laravel@RootPassword999@
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=smtp
MAIL_HOST=mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
PUSHER_KEY=
PUSHER_SECRET=
PUSHER_APP_ID=
このDB_DATABASEという部分をテスト用のDB名に切り替えます。(テスト用のDBとユーザーは別途作成しておいて下さい。)
私の場合はアプリケーションで利用するDB名に_testを付けて命名しました。
※もし心配であればDB_HOST等も本番環境とは異なる場所を指定しておけばさらに安心です。
作成したテスト用DBには本番環境で利用するテーブル定義を全て作成しておきます。
ちなみにMigrationファイル等もテスト実行時にちゃんと実行されるので問題はありません。
正常系のテストを実装していく
事前準備が終わったところでテストの実装を行っていきます。
<?php
/**
* アカウント作成テストクラス
*
* @author keita-nishimoto
* @since 2016-11-07
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
namespace Tests\Domain\Service\Account;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Foundation\Testing\WithoutMiddleware;
/**
* Class CreateTest
*
* @category laravel-api-sample
* @package Tests\Domain\Service\Account
* @author keita-nishimoto
* @since 2016-11-07
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
class CreateTest extends \Tests\AbstractTestCase
{
use WithoutMiddleware;
/**
* ユニットテストの初期化処理
* テストの実行毎に実行される
*/
public function setUp()
{
parent::setUp();
Artisan::call('db:seed', ['--class' => 'Tests\Domain\Service\Account\CreateTestSeeder']);
}
}
-
use WithoutMiddleware;
はLaravelのMiddlewareを無効化する為の記述です。 -
setUp()
で先程作成した\Tests\Domain\Service\Account\CreateTestSeederを呼び出しテストデータの初期化を行います。
正常系のテストケース1(リクエストパラメータに登録されていないemailを指定してリクエストを送信する。(任意パラメータのemail_verifiedは指定しない))を実装してみましょう。
普通にクラスメソッドとして実装していくのですが、PHPUnitにこれはテストケースだと認識させる為にいくつかルールがあります。
- メソッドはpublicで宣言する
- testから始まるメソッド名にする(メソッド名にtestを付けなくても
@test
というアノテーションを付ける事でもOKです。)
/**
* 正常系テスト
* 必須パラメータのみを設定
*/
public function testSuccessRequiredParams()
{
// リクエストで送信するパラメータを定義
$email = 'account-create-test-success-required-params@gmail.com';
$password = 'Password1';
// /v1/accountsに対してPOSTリクエストを送信、第2引数はパラメータを配列で渡します。
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => $password,
]
);
// APIの結果を一部利用したいのでjson_decode()でstdClassに変換します
$responseObject = json_decode(
$jsonResponse->response->content()
);
// APIの期待値を設定します。
$expectedSub = 2;
$accountStatusString = 'enabled';
$accountStatusInt = 0;
$expectedLinks = [
'self' => [
'href' => "/v1/accounts/$expectedSub",
]
];
$expectedEmbedded = [
'email' => $email,
'email_verified' => 0,
'password_hash' => $responseObject->_embedded->password_hash,
];
// 実際にJSONResponseの中に自分が期待したデータが入っているか確認します
$jsonResponse
->seeJson(['sub' => $expectedSub])
->seeJson(['account_status' => $accountStatusString])
->seeJson(['_links' => $expectedLinks])
->seeJson(['_embedded' => $expectedEmbedded])
->seeStatusCode(201)
->seeHeader('X-Request-Id')
->seeHeader(
'location',
"https://dev.laravel-api.net/v1/accounts/$expectedSub"
);
// パスワードハッシュが意図したロジックで実施されているか確認
$this->assertTrue(
password_verify(
$password,
$expectedEmbedded['password_hash']
)
);
// DBのテーブルに意図した形でデータが入っているか確認します
$idSequence = 2;
$this->seeInDatabase(
'accounts',
[
'id' => $expectedSub,
'status' => $accountStatusInt,
'lock_version' => 0,
]
);
$this->seeInDatabase(
'accounts_emails',
[
'id' => $idSequence,
'account_id' => $expectedSub,
'email' => $email,
'email_verified' => 0,
'lock_version' => 0,
]
);
$this->seeInDatabase(
'accounts_passwords',
[
'id' => $idSequence,
'account_id' => $expectedSub,
'lock_version' => 0,
]
);
}
テストコードが書けたらテストを実行します。
プロジェクトルートで以下のコマンドを実行します。
$ vendor/bin/phpunit tests/domain/service/account/CreateTest
ターミナルに以下の結果が表示されます。
PHPUnit 5.6.4 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 1.81 seconds, Memory: 14.00MB
OK (1 test, 16 assertions)
1個のテストケースが実行され、16個のアサートが成功した事を表しています。
アサート?
指定した変数がこちらの意図した結果かどうか調べる為のメソッドです。
先程のコードだと以下は全てアサーションメソッドになります。
-
seeJson(array $array)
# JSONデータのBodyに配列で指定したキーと値が存在するか? -
seeHeader(string $headerName, $value = null)
# JSONデータのHeaderに指定した値が含まれているか? -
assertTrue(bool $condition)
# 引数で渡した値がtrueを返しているかどうか? -
seeInDatabase(string $table, array $data)
# 指定したテーブルにデータが登録されているかどうか?($dataに渡された条件でWHERE句を作成しDBを確認する)
アサーションが意図した通りの結果だった場合(つまり意図した通りの結果だった場合)は先程の結果が表示されますが、もし失敗した場合は、以下のような表示になります。
PHPUnit 5.6.4 by Sebastian Bergmann and contributors.
F 1 / 1 (100%)
Time: 2.37 seconds, Memory: 14.00MB
There was 1 failure:
1) Tests\Domain\Service\Account\CreateTest::testSuccessRequiredParams
Unable to find JSON fragment
["sub":2]
within
[{"_embedded":{"email":"account-create-test-success-required-params@gmail.com","email_verified":0,"password_hash":"$2y$10$gkvpISSRsF8IYZN3OEGRO.LpnANu2v5qMV5RJWu3Q5IUqtuqzmXUG"},"_links":{"self":{"href":"\/v1\/accounts\/2"}},"account_status":"enabled","sub":3}].
Failed asserting that false is true.
/home/vagrant/laravel-api-sample/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php:396
/home/vagrant/laravel-api-sample/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php:326
/home/vagrant/laravel-api-sample/tests/domain/service/account/CreateTest.php:81
FAILURES!
Tests: 1, Assertions: 2, Failures: 1.
これはsubが2である事を期待したが、実際には3が返ってきているという意味。
アサートが失敗したらどこかが間違っているので根気良く修正していきます。
※ちなみにこの中で元々PHPUnitで標準で提供されているメソッドはassertTrue()のみで後はLaravelのテストクラスの拡張です。
アサーションメソッドは他にも種類がたくさんあるので、それぞれ下記のドキュメントを参照しましょう。
同じ要領で「リクエストパラメータに登録されていないemailを指定してリクエストを送信する。(任意パラメータのemail_verifiedに1を指定する)」のテストケースも実装します。
/**
* 正常系テスト
* email_verifiedを1で指定
*/
public function testSuccessEmailVerifiedTrue()
{
$email = 'account-create-test-success-email-verified-true@gmail.com';
$password = 'Password1';
$emailVerified = 1;
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => $password,
'email_verified' => $emailVerified
]
);
$responseObject = json_decode(
$jsonResponse->response->content()
);
$expectedSub = 2;
$accountStatusString = 'enabled';
$accountStatusInt = 0;
$expectedLinks = [
'self' => [
'href' => "/v1/accounts/$expectedSub",
]
];
$expectedEmbedded = [
'email' => $email,
'email_verified' => $emailVerified,
'password_hash' => $responseObject->_embedded->password_hash,
];
$jsonResponse
->seeJson(['sub' => $expectedSub])
->seeJson(['account_status' => $accountStatusString])
->seeJson(['_links' => $expectedLinks])
->seeJson(['_embedded' => $expectedEmbedded])
->seeStatusCode(201)
->seeHeader('X-Request-Id')
->seeHeader(
'location',
"https://dev.laravel-api.net/v1/accounts/$expectedSub"
);
$this->assertTrue(
password_verify(
$password,
$expectedEmbedded['password_hash']
)
);
$idSequence = 2;
$this->seeInDatabase(
'accounts',
[
'id' => $expectedSub,
'status' => $accountStatusInt,
'lock_version' => 0,
]
);
$this->seeInDatabase(
'accounts_emails',
[
'id' => $idSequence,
'account_id' => $expectedSub,
'email' => $email,
'email_verified' => $emailVerified,
'lock_version' => 0,
]
);
$this->seeInDatabase(
'accounts_passwords',
[
'id' => $idSequence,
'account_id' => $expectedSub,
'password_hash' => $expectedEmbedded['password_hash'],
'lock_version' => 0,
]
);
}
異常系のテストを実装していく
正常系のテストが通ったら次は異常系のテストを実装していきます。
まずは「登録済のメールアドレスをリクエストする」を実装して行きましょう。
先程作成したSeederクラスにtestFailEmailIsAlreadyRegistered()
というメソッドを実装しました。
ここで登録しているデータは正常にアカウント登録が完了した状態を再現しているので、ここで使われているメールアドレスをリクエストパラメータに指定します。
/**
* 異常系テスト
* メールアドレスが既に登録されている
*/
public function testFailEmailIsAlreadyRegistered()
{
$email = 'account-create-test-duplicated@gmail.com';
$password = 'Password1';
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => $password,
]
);
$errorCode = 40000;
$messageKey = 'error_messages' . '.' . $errorCode;
$errorMessage = \Config::get($messageKey);
$jsonResponse
->seeJson(['code' => $errorCode])
->seeJson(['message' => $errorMessage])
->seeStatusCode(409)
->seeHeader('X-Request-Id');
}
異常系のテストを実装していく(バリデーション)
続いてバリデーションのテストを実装していきます。
バリデーションテストは1回のリクエストで本当に意図した実装になっているかを確認するのが難しいです。
例えばパスワードのバリデーションルールは「半角英数字(小文字)が最低1種類それぞれ使われていて、8文字以上である事」です。
コード上では以下のようなロジックで判定しています。
/**
* パスワードのバリデーション
*
* @param $attribute
* @param $value
* @param $parameters
* @return bool
*/
public function validatePassword($attribute, $value, $parameters): bool
{
if (is_string($value) === false) {
return false;
}
if (is_null($value) === true) {
return false;
}
// 半角英数字をそれぞれ1種類以上含む8文字以上100文字以下
if (preg_match('/\A(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,100}+\z/i', $value) === 0) {
return false;
}
return true;
}
どこまでバリデーションのテストを厳密にするかは難しいところですが少なくとも以下ようなレベルではチェックしておきたいです。
- 半角のアルファベットのみで指定した場合にエラーになるか
- 数字のみで指定した場合にエラーになるか
- 7文字以下の半角英数字で指定した場合にエラーになるか
- 101文字以上のパスワードを指定した際にエラーになるか
これらを3つのパラメータ全てで試すとテストケースを書くのも結構大変です。
PHPUnitにはこのようなケースで利用出来るdataProviderという便利な仕組みがありますのでそれを利用しましょう。
先程とは別に以下のようなバリデーション専用のテストクラスを作成します。
専用のクラスを作成する理由は2つあります。
1つ目は、バリデーションのテストはDBへの事前データの登録が必要ないので、それを行わない事で少しでもテストの実行速度を早める為、2つ目はバリデーションのテストはパラメータの送信パターンが多いのでテストクラスが肥大化する傾向にあるからです。
<?php
/**
* アカウント作成バリデーションテストクラス
*
* @author keita-nishimoto
* @since 2016-11-17
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
namespace Tests\Domain\Service\Account;
use Illuminate\Foundation\Testing\WithoutMiddleware;
/**
* Class CreateValidationTest
*
* @category laravel-api-sample
* @package Tests\Domain\Service\Account
* @author keita-nishimoto
* @since 2016-11-17
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
class CreateValidationTest extends \Tests\AbstractTestCase
{
use WithoutMiddleware;
/**
* パスワードのバリデーションエラー
*
* @dataProvider passwordProvider
* @param $password
*/
public function testPassword($password)
{
$email = 'k-keita@example.com';
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => $password,
]
);
$errorCode = 422;
$messageKey = 'error_messages' . '.' . $errorCode;
$errorMessage = \Config::get($messageKey);
$jsonResponse
->seeJson(['code' => $errorCode])
->seeJson(['message' => $errorMessage])
->seeStatusCode(422)
->seeHeader('X-Request-Id');
$responseStdObject = json_decode(
$jsonResponse->response->content()
);
$this->assertObjectNotHasAttribute('email', $responseStdObject->errors);
$this->assertObjectHasAttribute('password', $responseStdObject->errors);
$this->assertObjectNotHasAttribute('email_verified', $responseStdObject->errors);
}
/**
* パスワードのデータプロバイダ
*
* @return array
*/
public function passwordProvider()
{
return [
'空文字' => [
'',
],
'マルチバイト文字' => [
'q1あいうえおK',
],
'NULL' => [
null,
],
'Array' => [
['a' => 'a'],
],
'JSON' => [
json_encode(
['りんご', 'ばなな', 'みかん']
)
],
'101文字以上のパスワード' => [
str_repeat('p1', 50) . 'A',
],
'数値のみのパスワード' => [
12345678
],
'小文字のみのパスワード' => [
'password',
],
'大文字のみのパスワード' => [
'PASSWORD'
],
'記号が含まれているパスワード' => [
'Passwd\n',
],
'8文字未満のパスワード' => [
'PassWd1',
],
];
}
テストメソッドに記載されている@dataProvider passwordProvider
という部分と引数で受け取っている$password
に注目して頂きたいです。
テスト実行時にdataProviderで指定されているpasswordProviderメソッドの配列の値が順番に渡ってきています。
passwordProviderに定義してあるデータ分だけループを回してテストを実行してくれるという仕組みです。
配列の中身は11個なのでこの場合、11回テストを実行しています。
このテストケースは以下の点が担保されています。
- Body.codeに422が設定されている事
- Body.messageにエラーメッセージが設定されている事
- HTTPステータスコードに422が設定されている事
- HTTPHeaderにX-Request-Idが設定されている事
- Body.errorsの中に"password"というキー名が存在する事
- Body.errorsの中に"email"というキー名が存在しない事
- Body.errorsの中に"email_verified"というキー名が存在しない事
後はパラメータの数だけ同じようなテストケースを実装していけばOKです。
passwordProviderで返しているテストデータは今後他のpasswordを受取るAPIのテストケースでも利用する可能性があるので、Provider用のパラメータを生成するクラスを別に定義して外出しします。
<?php
/**
* データプロバイダで利用するデータを作成するクラス
*
* `@dataProvider` を利用する際にこちらの利用する。
* 使い方としては各 `@dataProvider` から本クラスの静的メソッドを呼び出す。
* 本クラスで返却するのはバリデーションが通らない値に限定する事。
* 各テストクラス個別でしか利用しないようなデータは無理にこちらに共通化する必要はない。
*
* @author keita-nishimoto
* @since 2016-11-16
* @link https://github.com/keita-nishimoto/laravel-api-sample
* @link https://phpunit.de/manual/current/ja/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers
*/
namespace Tests;
/**
* Class ValidationProvider
*
* @category laravel-api-sample
* @package Tests
* @author keita-nishimoto
* @since 2016-11-16
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
class ValidationProviderCreator
{
/**
* メールアドレス(必須パラメータの場合)
*
* @return array
*/
public static function emailIsRequiredParams(): array
{
return [
'.から始まるメールアドレス' => [
'.keita-koga@example.com',
],
// 128文字以上
'長いメールアドレス' => [
'keita-koga-' . str_repeat('moo', 36) . '@gmail.com',
],
'空文字' => [
'',
],
'マルチバイト文字' => [
'q1あいうえお@gmail.com',
],
'NULL' => [
null,
],
'Array' => [
[1, 2, 3],
],
'JSON' => [
json_encode(
['りんご', 'ばなな', 'みかん']
)
],
'長い文字列' => [
str_repeat('S', 256),
],
'大きな数値' => [
4294967296,
],
'数値の0' => [
0
],
'記号が含まれた文字列' => [
'a./+*{}~¥¥¥<>?_*}{|~=---}a',
],
];
}
/**
* パスワード(必須パラメータの場合)
*
* @return array
*/
public static function passwordIsRequiredParams(): array
{
return [
'空文字' => [
'',
],
'マルチバイト文字' => [
'q1あいうえおK',
],
'NULL' => [
null,
],
'Array' => [
['a' => 'a'],
],
'JSON' => [
json_encode(
['りんご', 'ばなな', 'みかん']
)
],
'101文字以上のパスワード' => [
str_repeat('p1', 50) . 'A',
],
'数値のみのパスワード' => [
12345678
],
'小文字のみのパスワード' => [
'password',
],
'大文字のみのパスワード' => [
'PASSWORD'
],
'記号が含まれているパスワード' => [
'Passwd\n',
],
'8文字未満のパスワード' => [
'PassWd1',
],
];
}
/**
* email_verified(任意パラメータ)
*
* @return array
*/
public static function emailVerifiedIsOptionalParams(): array
{
return [
'NULL' => [
null,
],
'Array' => [
['a' => 'a'],
],
'JSON' => [
json_encode(
['りんご', 'ばなな', 'みかん']
)
],
'1以外の数値' => [
2,
],
'マイナス数値' => [
-1
],
];
}
}
最終的なバリデーション用のテストクラスは下記のような形になります。
先程から変更した点は以下の通りです。
- 全てのパラメータがバリデーションエラーになるケースを追加
- emailがバリデーションエラーになるケースを追加
- email_verifiedがバリデーションエラーになるケースを追加
- dataProviderの中身の配列を取得するコードを新規作成した別クラスから呼び出すように変更
<?php
/**
* アカウント作成バリデーションテストクラス
*
* @author keita-nishimoto
* @since 2016-11-17
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
namespace Tests\Domain\Service\Account;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Tests\ValidationProviderCreator;
/**
* Class CreateValidationTest
*
* @category laravel-api-sample
* @package Tests\Domain\Service\Account
* @author keita-nishimoto
* @since 2016-11-17
* @link https://github.com/keita-nishimoto/laravel-api-sample
*/
class CreateValidationTest extends \Tests\AbstractTestCase
{
use WithoutMiddleware;
/**
* 全パラメータのバリデーションエラー
*
* @dataProvider allParamsProvider
* @param $email
* @param $emailVerified
* @param $password
*/
public function testAllParams($email, $emailVerified, $password)
{
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => $password,
'email_verified' => $emailVerified,
]
);
$errorCode = 422;
$messageKey = 'error_messages' . '.' . $errorCode;
$errorMessage = \Config::get($messageKey);
$jsonResponse
->seeJson(['code' => $errorCode])
->seeJson(['message' => $errorMessage])
->seeStatusCode(422)
->seeHeader('X-Request-Id');
$responseStdObject = json_decode(
$jsonResponse->response->content()
);
$this->assertObjectHasAttribute('email', $responseStdObject->errors);
$this->assertObjectHasAttribute('email_verified', $responseStdObject->errors);
}
/**
* 全パラメータ用のデータプロバイダー
*
* @return array
*/
public function allParamsProvider()
{
return [
'マルチバイト文字' => [
'あいうえお',
'あいうえお',
'あいうえお',
],
'記号だけ' => [
'@@@',
'+++',
'---',
],
'JSON' => [
json_encode(
['りんご', 'ばなな', 'みかん']
),
json_encode(
['りんご', 'ばなな', 'みかん']
),
json_encode(
['りんご', 'ばなな', 'みかん']
),
],
'大きな数字' => [
-9999999999,
99999999999,
99999999999,
],
'大きな文字列' => [
str_repeat('a@', 65),
str_repeat('p1', 50),
str_repeat('11', 2),
],
];
}
/**
* メールアドレスのバリデーションエラー
*
* @dataProvider emailProvider
* @param $email
*/
public function testEmail($email)
{
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => 'Password123',
]
);
$errorCode = 422;
$messageKey = 'error_messages' . '.' . $errorCode;
$errorMessage = \Config::get($messageKey);
$jsonResponse
->seeJson(['code' => $errorCode])
->seeJson(['message' => $errorMessage])
->seeStatusCode(422)
->seeHeader('X-Request-Id');
$responseStdObject = json_decode(
$jsonResponse->response->content()
);
$this->assertObjectHasAttribute('email', $responseStdObject->errors);
$this->assertObjectNotHasAttribute('password', $responseStdObject->errors);
$this->assertObjectNotHasAttribute('email_verified', $responseStdObject->errors);
}
/**
* メールアドレスのデータプロバイダ
*
* @return array
*/
public function emailProvider()
{
return ValidationProviderCreator::emailIsRequiredParams();
}
/**
* パスワードのバリデーションエラー
*
* @dataProvider passwordProvider
* @param $password
*/
public function testPassword($password)
{
$email = 'k-keita@example.com';
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => $password,
]
);
$errorCode = 422;
$messageKey = 'error_messages' . '.' . $errorCode;
$errorMessage = \Config::get($messageKey);
$jsonResponse
->seeJson(['code' => $errorCode])
->seeJson(['message' => $errorMessage])
->seeStatusCode(422)
->seeHeader('X-Request-Id');
$responseStdObject = json_decode(
$jsonResponse->response->content()
);
$this->assertObjectNotHasAttribute('email', $responseStdObject->errors);
$this->assertObjectHasAttribute('password', $responseStdObject->errors);
$this->assertObjectNotHasAttribute('email_verified', $responseStdObject->errors);
}
/**
* パスワードのデータプロバイダ
*
* @return array
*/
public function passwordProvider()
{
return ValidationProviderCreator::passwordIsRequiredParams();
}
/**
* email_verifiedのバリデーションエラー
*
* @dataProvider emailVerifiedProvider
* @param $emailVerified
*/
public function testEmailVerified($emailVerified)
{
$email = 'k-keita@example.com';
$password = 'Password1';
$emailVerified = $emailVerified;
$jsonResponse = $this->post(
"/v1/accounts",
[
'email' => $email,
'password' => $password,
'email_verified' => $emailVerified,
]
);
$errorCode = 422;
$messageKey = 'error_messages' . '.' . $errorCode;
$errorMessage = \Config::get($messageKey);
$jsonResponse
->seeJson(['code' => $errorCode])
->seeJson(['message' => $errorMessage])
->seeStatusCode(422)
->seeHeader('X-Request-Id');
$responseStdObject = json_decode(
$jsonResponse->response->content()
);
$this->assertObjectNotHasAttribute('email', $responseStdObject->errors);
$this->assertObjectNotHasAttribute('password', $responseStdObject->errors);
$this->assertObjectHasAttribute('email_verified', $responseStdObject->errors);
}
/**
* email_verifiedのデータプロバイダ
*
* @return array
*/
public function emailVerifiedProvider()
{
return ValidationProviderCreator::emailVerifiedIsOptionalParams();
}
}
これで最初に作ったテストケース分のテストコードの実装が完了しました。
※厳密に言うと、X-Request-Idのデータ型のチェックが抜けていたりDBに登録されている日付データのアサート(現在日時が自動挿入されるハズ)が抜けています。
X-Request-Idはともかくとして、日時データのアサートは秒単位でズレてもテストが失敗したりと少し工夫が必要なので、機会があったら記事を書こうかと思います。
テストがちゃんと網羅出来ているか確認する
ここまでで最初に洗い出したテストケースの実装は完了していますが、このテストケースで本当に必要なテストパターンを網羅出来ているか不安です。
そこでコードカバレッジを出力してどの程度テスト出来ているかを確認してみましょう。
※コードカバレッジの出力にはXdebugのインストールが必要です。
サンプルコードを動作させているローカル開発環境 にはインストールされています。
カバレッジを出力する為にはphpunitをオプションパラメータ付きで実行します。
まずは最初に作った正常系のテストのみ実行してみましょう。
$ vendor/bin/phpunit --coverage-html=/usr/share/nginx/coverage --filter testSuccessRequiredParams tests/domain/service/account/CreateTest
--filter testSuccessRequiredParams
は実行するテストケースを絞る為のオプションです。
--coverage-html=/usr/share/nginx/coverage
がコードカバレッジを出力させる為のオプションです。
この場合はHTML形式で/usr/share/nginx/coverage 配下に出力させるという設定を行っている事になります。
出力したカバレッジをWebブラウザで確認します。
http://192.168.33.21 にアクセスすると以下のような画面が閲覧出来ます。
※サンプルコードをローカル開発環境で動作させていれば上記のURLで閲覧可能です。
先程実行したテストケースでコード全体の何%が網羅出来ているかを数値で表しています。
APIが使っているメインロジックである\Domain\Account\AccountServiceを見てみましょう。
コードの緑色の部分はテストで網羅できている箇所で赤色の部分がテストされていない箇所になります。作成したテストケースを全て実行して赤色の部分が残っている場合はその部分はテストケースで網羅出来ていない箇所という判断が出来ますので、足りないテストケースを洗い出す1つの指標になります。
ちなみに色が付いた部分にカーソルを当てると、どのテストメソッドがこの行を通過したか?という情報や何回この行を通過したか?が分かるようになっています。
コマンドの実行オプションが少々長いので面倒な場合はphpunit.xmlにカバレッジの出力設定を記載する事も可能です。
<logging>
<log
type="coverage-html"
target="/usr/share/nginx/coverage"
title="Base32"
charset="UTF-8"
yui="true"
highlight="true"
lowUpperBound="35"
highLowerBound="70"
/>
</logging>
ただしこの設定を行うと常時カバレッジが出力されるので、テストの実行時間がかなり遅くなります。
※約250程あるテストケースを実行するのに1分程度だったのですが、カバレッジを常時出力にしたら5分程かかるようになりました。
またデフォルトだとテストケース実行時に読み込まれたファイルは全てカバレッジの計測対象になってしまいます。
これはこれで良いのですが、自分で作成していないvendor配下のパッケージも含めて全て出力されるので、以下の設定を入れてカバレッジの計測対象をホワイトリスト化しておく事をオススメします。
私の場合は自分でコードを作成した部分のみをホワイトリストに入れています。
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">app</directory>
<directory suffix=".php">domain</directory>
<directory suffix=".php">exceptions</directory>
<directory suffix=".php">factories</directory>
<directory suffix=".php">infrastructures</directory>
<directory suffix=".php">repositories</directory>
</whitelist>
</filter>
コードカバレッジの注意点
カバレッジはテストが不十分かどうかを確認する事は出来ますが、テストが十分である事を保証している訳ではありません。
また100%に拘るのも費用体効果が良いとは言えません。
あくまでも参考程度に見ておくのが無難です。
その他テストコードを書く時に気をつける事
テストコードも商用コードの一部と考え、メンテナンス性を意識する事が大事だと考えます。
テストの実行は継続していく事が大事でメンテナンス性の低いテストコードを書くとテストを修正するのが嫌になってきます。そうするとそのうちメンテナンスされなくなっていくので、テストコードに投資した意味が失われてしまいます。
- テストケースの名前で何をテストしたいのか想像が出来る事
- 読みやすいコードを意識する、テストケースで重要なのは何を期待値としているかという点
- 複数のテストケースで同じデータを使いまわさない(テストデータも修正対象になる事があり得ます)
最後に
ここまででLaravel 5.3 + PHPUnitを利用したREST APIのテストの書き方に触れてきました。
他の言語でもテストフレームワークの違いや文法の違いはあるにせよ、基本的な考え方は変わりませんので他言語で開発している方でも応用出来るかと思います。
次にテスト系の投稿をする時はここでは触れられなかった、継続的インテグレーションの話等を書こうかと思います。(その頃には別の言語で別のテストフレームワークを使っているかもですが)
長文になってしまいましたが、最後まで読んで頂き、ありがとうございました。