【CakePHP2環境】レガシーなシステムにテストを導入する
この記事は Lancers(ランサーズ) Advent Calendar 2022 の21日目の記事です。
はじめに
ランサーズ株式会社のプロダクト開発部に所属している種井です。
普段はエージェンシー開発チームで、エージェントサービス関連の開発業務を行なっており、主に顧客管理システムの開発・保守を担当しています。
本稿では、開発業務を行っている顧客管理システムにテストを導入していく上で実際に行ったこと、これから対応していくことについて振り返りも兼ねて記載していければと思います。
背景
上述した顧客管理システムは2016年のサービスリリースから現在に至るまでの長い期間、大きなリプレースや改修を行うことなくサービスを支えてきました。
しかし、ランサーズアドベントカレンダー17日目の記事で同じエージェンシー開発チームの@wakabayashiayakoさんが紹介しているように、2021年の下期ごろから開発体制が拡充されたことで短期間で大幅な機能追加・改善が行われるようになりました。
このような状況下で開発を行った結果、以下の課題感が浮き彫りになりました。
- 新規機能を開発する際に過去の設計と衝突して思うように機能開発が進まない
- 開発メンバー全員が直近1~2年の間でプロジェクトに参画したチームで、過去の開発内容に精通している人がいない - リリースの影響範囲が把握しづらく、想定外の箇所にバグを埋め込んでしまう(ソースコードを触るのが怖くなってしまう)
- リリース後のバグ対応にかけている工数が多い
新規機能を早く実装してリリースしたいという気持ちがある一方で、既存の設計との衝突や、過去実装に対する背景・知識を得るのが難しいなどの原因で、開発に対する心理的ハードルが高くなっていました。また、早く実装できたとしても、実装者の認識できていない箇所でバグが発生してしまうなど、エンジニアの開発体験としてあまり良いとは言えない状態でした。
これらの問題を解決するための対応策として、まず浮かんだのはテストケースを運用してみるということでした。
少し単純ですが、もし今のソースコードに対してテストケースがあれば、過去の実装の背景をより知ることができ、リリース前に追加した変更分がシステム全体に与える影響をより把握することができて、よりよい世界線があるのではないかと思いました。
それなら 今後開発する機能からでもテストを書いてみよう そう思うに至りました。
以降では、テストケースが今まで運用されてこなかったプロジェクトでテストの運用を開始するまでに行なったこと、これからやっていくことを記載していきます。
開発言語とフレームワークのバージョンは以下のようになっています。
- CakePHP 2
- PHP 7.3
問題点
さて著者の開発する顧客管理システムにはテストケースがこれまで一つもありませんでした。
あるのはbakeコマンドを使用した際に自動で生成されるテンプレートファイルのみでした。(このファイルすら全体の数%程度しかありませんでした。)
また、これまで開発チームでは基本的にテストはツールを使用せず、主に手動(人力)で確認していたので、テストケースを運用していくとしても、「今後の機能開発ではテストを必ず書くこと」など、いきなり非現実的な方針に転換することはできません。しかもテストケースを書く分、今までよりも開発工数が大きくなるので、開発者の負担にならないような形で始める必要がありました。
テストを本格的に導入する前にやったこと
テストケースを追加する目的・方針をチーム間で共有する
既に様々な機能が本番運用され、実際にサービスの土台として機能しているプロジェクトで今更1からテストケースを追加するのは、果たして本当に意味があることなのか、現状の開発スピードを落としてまでやることなのか等々、導入には数々の悩みポイントがありました。
テストケースの運用は個人ごとに書くか書かないかを選択できるようにすると、ある機能では十分にテストケースが書かれているのに別の機能では全く書かれていないなど、実装者の担当した機能ごとでテストケースの数・精度が不揃いになってしまい、折角の取り組みが中途半端になるリスクが高いので、開発チームの間で事前にテストケースを追加する目的・方針を決めました。
目的
目的に関しては以下の3項目を掲げています。
- リリース後のバグ対応にかかっている工数の削減
- ソースコードのドキュメント化
- リファクタリングが行いやすい環境の作成
リリース後のバグ対応にかかっている工数の削減
テストケースがないプロジェクトの場合、リリース前の検証は完全に手動のテストに頼ることになります。もちろん変更差分が比較的少ない場合は関連機能の周りを念入りに確認することで、本番環境にバグを埋め込むことは大体なくすことはできると思います。ただ変更差分が大きい機能の場合や、実装者と検証者の状況次第では、手動テストの効果は確実に保証されたものになりません。これまでも新しい機能をリリースした際に予測していなかった箇所でバグを埋め込んでしまい、緊急の対応を行うことは少なくありませんでした。これでは本来当てるべき新規機能の開発や改善タスクに割く時間がチームとして減ってしまう課題感があったので、この解決策としてテストケースの運用を行うことを決めました。
ソースコードのドキュメント化
テストケースはドキュメントとしても優秀で、それだけで資産価値のあるものです。
テスト対象のコードが提供する機能はどのようなユースケースを想定しているのか、どの機能と依存関係にあるのかなどテストケースを見ただけで得られる情報は有益なものです。
たとえ実装者が現場を離れても新しく参画した開発者が迷わないためにも、テストケースは充実させておきたいです。
リファクタリングが行いやすい環境の作成
既存のクラス設計の改善やちょっとしたコードのリファクタリングなど、システムとしての動作を変えずに内部品質の向上につながる改善活動を行なっていくには、テストケースで対象のコードを保護して変更前と変更後の実行結果を確認しながら安全に行うべきです。
また、リファクタリング箇所のレビューを担当する人の負担を軽減することにも繋がります。
方針
- テストの粒度
- テストカバレッジを計測して定期的にチーム間で振り返る
テストの粒度
テストの粒度は最低限Controller、Commandベースのテストを書くという方針になりました。
modelのテストなど小さい単位でテストが書いてある方が、それだけ堅牢にもなるのですが、まずは運用してみるということが一番大事なので、継続しやすいように開発者の負担になりすぎないようにしました。
テストカバレッジを計測して定期的にチーム間で振り返る
テストケースを追加していく上で重要な指標としてテストカバレッジを使用します。
テストカバレッジを計測することの目的は、テストケースを作成したことで、テスト対象のコードで構成されている機能でのバグ発生割合を把握しやすくすることだと考えています。
テストケースを追加するだけでは簡単にバグを減らすことはできません。バグが発生してしまったのはテストケースがそもそも存在しなかったからなのか、テストケースはあったが考慮漏れしていたパターンがあったのか、テストケースもあり、そのテストが全てのパターンを網羅していて、バグ検知が可能な状態になっているのかを把握するためにテストカバレッジを使用します。
テストカバレッジを計測したら定期的にチームで振り返りを行います。
これはまだ記事の執筆時点では行えていないのですが、例えば月次のバグ対応を行なったPull Requestを集計して、これとテストカバレッジを照らし合わせてテスト運用の効果を検証したいと考えています。
(運用後のレポートはまた後日、別のブログで記載したいと思います。)
テスト導入に向けた下準備
目的と方針が決まったらテスト運用に向けた準備を行いました。
全てのテストを実行できるようにする
CircleCiなどでコミットごとに全てのテストを実行したいので、専用のテストスイートを作成します。※カバレッジを出力するためにも必要です
実装内容は公式ドキュメントのCookBookに記載されています。
class AllTestsTest extends CakeTestSuite {
public static function suite() {
$suite = new CakeTestSuite('All tests');
$suite->addTestDirectoryRecursive(TESTS . 'Case');
return $suite;
}
}
テストカバレッジを計測できる状態にする
全てのテストが実行できるようになったら、テストカバレッジを計測できるようにします。
CakePHPではPHPUnitからテストカバレッジを計測することができます。
↓カバレッジを出力する際のコマンド例(cakeコマンドを使用)
$ Console/cake test app AllTests --configuration=任意のディレクトリ/phpunit.xml --coverage-html=任意のパス
--coverage-htmlオプションを使用してカバレッジのレポートを出力
FixtureのfactoryツールとしてFabricateを導入
全てのテストが実行できるようになり、カバレッジも計測できる状態になったら、
テストケースを作成するときに重要になるのが、テストデータの準備です。
CakePHPではFixtureを作成することでテストデータを生成することができますが、通常の方法だと以下の問題点があります。
- 一つのFixtureファイルに様々なテストデータを追記することになり、テストケースが増えるにつれて衝突してしまう(テストデータの管理が複雑になってしまう)
- テストコードのメンテナンス性が下がる
- テストケースとテストデータの依存関係が強くなることで、新しくテストケースを作成した際に過去のテストが壊れてしまう
これらの問題点をあらかじめ想定した上で、テストメソッドごとにデータをオーバーライドしていくことができるFixtureのfactoryを行なうライブラリの導入を検討しました。
候補としては以下の二つを考えていました。
cakephp-fixture-factories
cakephp-fixture-factoriesはCakePHP3からしか使用できず、著者の開発しているシステムでは導入することはできませんでした。
ただ、ライブラリのメンテナンスやサポートが手厚く(2022/12/21時点)、APIも直感的でかなり柔軟にテストレコードを生成することができる非常に魅力的なツールなので、CakePHP3以上の開発環境であれば第一候補としてこちらのライブラリを検討したいです。
Fabricate
結論、著者の開発するプロジェクトではFabricateを採用しました。
FabricateはCakePHP2で動作するV1とフレームワーク非依存のV2があります。
今回はFabricateのV1を使用しました。ゆくゆくはV2に移行して、環境に限りなく依存しない体制を作る予定です。
(移行するにはFabricateのV2とCakePHP2の間にアダプターを自作する必要があり、こちらの対応が間に合いませんでした。)
Fabricateの導入にはBASEさんの記事も参考にさせていただきました🙇♂️
↓Fabricate作成者のshizuhikoさんのブログ
CakePHPのデータジェネレータ Fabricate を作りました
Fabricateのversion2を作成しました
本稿でもざっくりとFabricateのAPIの使用例を記載します。
Fabricateの使い方-モデルごとに定義ファイルを作成する
Test/Fixture/
配下にディレクトリを作成するとFabricateの定義ファイルがまとまって管理しやすいです。
例:Test/Fixture/Fabricate/FabricateUserFixture.php
<?php
/**
* User Fixture
* @property User $User
*/
class FabricateUserFixture extends CakeTestFixture
{
public $import = 'User';
public function init()
{
parent::init();
$this->User = ClassRegistry::init('User');
Fabricate::define(['User', 'class' => 'User'], function ($data, $world) {
$now = new DateTimeImmutable();
return [
'id' => $world->sequence('user_id'),
'name' => 'test_user',
'created' => $now->format('Y-m-d H:i:s'),
'modified' => $now->format('Y-m-d H:i:s'),
];
});
}
}
Fabricate::define()
メソッドを使って、対象のモデル(テーブル)の初期値を設定します。
定義ファイルが作成できれば、あとは各テストケース内で呼び出すだけです。
Fabricateの使い方-テストメソッドの中でデータを生成する
例:Test/Case/Controller/UsersControllerTest.php
<?php
App::uses('UsersController', 'Controller');
App::uses('Fabricate', 'Fabricate.Lib');
/**
* UsersController Test Case
*
*/
class UsersControllerTest extends ControllerTestCase
{
/**
* Fixtures
*
* @var array
*/
public $fixtures = array(
'app.Fabricate/fabricate_user',
);
/**
* testIndex method
*
* @return void
*/
public function testIndex()
{
// Fabricate::build()メソッドの第二引数に連想配列でカラム名をkey、値をvalueに指定することで、
// 定義ファイルで作成したデフォルトのデータをオーバーライドできる。
$user_fabricate = Fabricate::build('User', ['name' => 'test_hoge']);
Fabricate::create('User', user_fabricate->data);
// act
$this->testAction('/Users/index');
// asert
// viewに渡す何かしらのデータをアサートする
$this->assertCount(1, $this->vars['']);
}
}
基本的にはFabricate::build()
でテストしたいメソッドに必要なモデルインスタンスを生成して、Fabricate::create()
でデータ投入する流れで実装しています。
これからやっていくこと
CircleCIでテストを自動実行
テストは導入出来ましたが、実行のトリガーが手動になってしまっては、かなり運用が辛いです。さらにテストの効果を最大限に享受することもできません。理想はコミット単位でテストが実行され、加えた変更がシステム全体に与える影響を確認することができる状態です。そのためにCircleCIとGithubを連携させてテストの自動実行を行います。
テストカバレッジの計測スコープを限定的にする
著者の開発するシステムでは今後テストコードを追加していくにしても、全体のソースコードに対するテストコードの割合はかなり小さくなってしまいます。また注力してカバレッジを上げたい箇所、特にユーザー影響の大きい金額を扱っている機能などは、全体のコードに対するテストコードの割合を計測するよりも、個別にセクションを分けて計測した方がチームとしても目標に対する現状の数値がより把握できるので、計測スコープを限定的にできるようにしていきたいです。
Fabricate Version2をCakePHP2環境に導入する
Fabricate Version1はCakePHP2環境でのみ動作するのですが、version2ではこのフレームワークへの依存性を排除する形でAPIが提供されています。導入するには各ORM用にアダプターと呼ばれるORMの差分を吸収するクラスを準備する必要があるのですが、CakePHP2のアダプターはまだ本稿の執筆時点では用意されていないので、自作する必要があります。
shizuhikoさんのブログにはCakePHP3環境での実装例が紹介されているので、CakePHP2用のアダプターを自作する予定です。
さいごに
ここまでで、チーム間でのテストケースを運用する目的・方針の認識合わせ、使用技術の下準備について記載しました。まだまだ本格的な実運用には至っていませんが、着々と準備を進めています。
もちろんチームとしてもサービスの状況に応じて、テストケースの運用だけに限られない最適な開発体制を整えていくことは今後も継続して模索していく予定ですが、今回の取り組みが状況を良い方向に動かすことができるように我慢強く検証していきます。
今後のこの取り組みのレポートはまたどこかのタイミングで記事にしてアウトプットしていきたいと思います。
明日は@sayakobさんです!
Discussion