2015.02.10
FuelPHPの単体テスト戦略
はじめに
こんにちは。次世代システム研究室のT.Tです。
最近新しいプロジェクトに配属されて、そのプロジェクトに関連するシステムで利用するサーバーサイドの標準フレームワークを作ることになりました。
今回は、その標準フレームワークで構築した単体テスト部分についてご紹介したいと思います。
システム要件
まず、標準フレームワークを導入する見込みのプロジェクトのシステム要件は概ね以下のようなものです。
- クライアントからHTTPでjsonをPOSTして、サーバーからjsonをレスポンスする
- 各クライアントからのリクエスト数は多くても毎分数回程度
- マスタデータとユーザーデータを扱う
- DBはマスタとスレイブの二台構成で、更新時だけマスタに接続する
- あまり厳密な処理はしない(語弊はありますが、リクエストやレスポンスの損失は若干許されて、無論ですがDB上の整合性は必要という感じ)
この要件に加えて、サービスのリードタイムは短くしたいということもあったので、開発言語としてはPHP、ベースとなるフレームワークとしてはFuelPHPを使うことにしました。
今までの単体テスト
FuelPHPは今までのプロジェクトでも何度か導入していて、その中でも単体テストは書いていましたが、DBに永続化したデータを利用していたり、dataProviderをテストコード内に書いていた部分があり、その影響で単体テストを書くのが難しくなってしまう側面がありました。
プロジェクトが進むにつれて、テストケースを跨いでDBに永続化するデータの整合性を取る必要が出て、データを投入するのに時間が掛かったり、テストケースを増やすとdataProviderが長くなりテストコードの可読性が下がるといったところです。
特に、仕様変更が入って既存のテストケースを変更する際にこの辺の影響が大きく、保守が大変で、どうにか変更した後も品質がどの程度担保できているか判然としない状況に陥りがちでした。
やりたいこと
ようやく本題になりますが、単体テストを以下のように書けるようにすることで、カバレッジと可読性、保守性を高めていきたいと考えています。
- テストコードはテストロジックのみで構成する
- テストケースで使用するテストデータは各テストケースに対応したファイルに保持する
- DBに永続化するテストは実施しない
各メソッドのテストをロジックとデータに分離することで、テストコードはロジックの実装だけに焦点を置き、各テストケースのカバレッジの確認や向上はデータファイルに集約することで可読性と保守性を高める狙いです。
また、他のテストケースとデータを共有しないことで、各テストケース仕様の変更にも対応しやすくすることで保守性を高めます。
配列構造のデータを扱うことが多いので、yamlからデータを読み込んでテストできるようにします。
ソースコードとしては以下のようなイメージです。
user.php
<?php // 親クラスでデータプロバイダとDBに永続化しない処理を実装 class Test_Model_User extends \Base_TestCase { /** * // yamlファイルからデータを取得する * @dataProvider additionProvider */ // データプロバイダからテストに必要なデータを取得 public function test_setAddress($user, $new_address, $expected) { // テストロジックだけテストコードに書く $user = \Model_User::forge($user); $user->setAddress($new_address); $this->assertEquals($expected, $user->address); } /** * // データプロバイダを使いまわせる * @dataProvider additionProvider */ public function test_setPhoneNumber($user, $new_phone_number, $expected) { $user = \Model_User::forge($user); $user->setPhoneNumber($new_phone_number); $this->assertEquals($expected, $user->phone_number); } }
setAddress.yml
--- - # set name - # user address: 'old address' new address # address new address # expected
setPhoneNumber.yml
--- - # set phone number - # user phone_number: '080-0000-0000' 090-0000-0000 # new_address 090-0000-0000 # expected
単体テストの位置づけ
プロジェクト全体で実施するテストはおおまかに以下のような感じで切り分けてリリース品質を目指し、単体テストではサーバーサイドのクラスメソッド単体のテストだけを担います。()内はサーバー側では直接関与しないものです。
- 単体テスト
- サーバーサイドのクラスメソッド単体のテスト <– ここまで通ればコミット可
- 結合テスト
- コントローラのテスト
- DB永続化が必要なテスト <– ここまで通ればクライアント側に提供化
- システムテスト
- 機能テスト
- (クライアントからのテスト)
- 非機能テスト
- ボリュームテスト
- ストレステスト
- (ユーザビリティテスト) <– ここまで通ればリリース可
- 機能テスト
dataProviderの実装
データプロバイダは各テストケースの共通の親クラスで実装して、テストメソッドごとにyamlファイルを配置できるようにディレクトリを切って、各テストケースに適したファイルからデータを取得します。
先ほどの例だと、test_setAddressではDATAPATH/model/user/setAddress.ymlから、test_setPhoneNumberではDATAPAth/model/user/setPhoneNumber.ymlからデータを取得します。
DATAPATHはphpunitのbootstrapで、APPPATH以下のテストデータ用のディレクトリを指定しています。
testcase.php
<?php abstract class Base_TestCase { const PREFIX_TEST = 'test_'; public function setUp() { parent::setUp(); // 一つのテストケースごとにトランザクションを張ってロールバックする \DB::start_transaction(); } public function tearDown() { // 一つのテストケースごとにトランザクションを張ってロールバックする \DB::rollback_transaction(); parent::tearDown(); } private function extractEssentialName($name) { return substr($name, strlen(self::PREFIX_TEST)); } public function additionProvider($methodName) { // テストメソッドごとにyamlファイルを配置できるようにディレクトリを切る $path = implode("/", array_map('strtolower', explode("_", $this->extractEssentialName(get_class($this))))); return new YamlIterator($path."/".$this->extractEssentialName($methodName).".yml"); } }
yamliterator.php
class YamlIterator implements Iterator { private $position = 0; private $array; public function __construct($path) { $this->array = Format::forge(file_get_contents(DATAPATH.$path), 'yaml')->to_array(); } public function rewind() { $this->position = 0; } public function valid() { return isset($this->array[$this->position]); } public function key() { return $this->position; } public function current() { return $this->array[$this->position]; } public function next() { $this->position++; } }
時刻指定のテスト
以上でyamlファイルからデータを取得して単体テストを実行できるようになりましたが、テストケースによっては、現在時刻や1時間前等のように動的な時刻を使ってテストしたい(テストが簡単になる)場合があります。
そのようなデータをyamlに記述できるようにデータプロバイダを拡張して実際には使用しています。
文字列で取得
--- - // 2015-01-01 01:00:00に実行すると2015-01-01 00:00:00の形式で単体テストで参照される ${now -1 hour}
タイムスタンプで取得
--- - // 2015-01-01 01:00:00に実行すると1420038000の形式で単体テストで参照される ${timestamp -1 hour} yamliterator.php
<?php class YamlIterator implements Iterator { const TIME_PATTERN = '#\$\{(now|timestamp)(.*)\}#'; private $position = 0; private $array; public function __construct($path) { $handle = fopen(DATAPATH.$path, 'r'); $replaced = array(); if ($handle) { while (($line = fgets($handle)) !== false) { preg_match(self::TIME_PATTERN, $line, $m); if (!empty($m[0])) { $replaced[] = self::parse($line, $m[1], !empty($m[2]) ? $m[2] : null); } else { $replaced[] = $line; } } } fclose($handle); $this->array = Format::forge(implode('', $replaced), 'yaml')->to_array(); } private static function parse($line, $type, $offset) { $time = !isset($offset) ? date('Y-m-d H:i:s') : date('Y-m-d H:i:s', strtotime($offset)); if ($type == 'timestamp') { $time = (new DateTime($time))->getTimestamp(); } return preg_replace(self::TIME_PATTERN, $time, $line); } public function rewind() { $this->position = 0; } public function valid() { return isset($this->array[$this->position]); } public function key() { return $this->position; } public function current() { return $this->array[$this->position]; } public function next() { $this->position++; } }
まとめ
このような方針で実際にプロジェクトで使っていますが、一つ一つテストデータ用のファイルを作る等の手間はありますが、当初の狙い通りテストコードとデータが分離されて可読性の高いテストコードが実装でき、DBへの永続化を避けることで個々のテストメソッドに集中してテストデータを用意でき、保守性も高まったと思っています。
改善したい点としては、マスタデータであっても各テストケースのyamlに個々にデータを持たせているので、このデータに修正が必要になると割とコストが掛かってしまう点です。
この点については、マスタデータは単体テスト実行前にDBに永続化しておいて、そこからデータを取るようにすることで避けられると思います。
もう一つの問題として時刻指定のテストをする際に、テスト実行時刻が秒の切り替わりを跨ぐとテストが失敗してしまうことがあります。この点についても今後改善したいと思います。
参考リンク
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD