こんにちは、おのぽんです。
最近暑さが続き、僕もついにハンディファンを購入しました。
みなさまいかがお過ごしですか??
さて、今回は僕の在籍するプロジェクトでの「CodeIgniterのテストコードでattributeを活用してみたお話」を綴っていこうかと思います。
CodeIgniterは、現在僕の所属するプロジェクトで利用しているPHPのフレームワークで、attributeはPHP8.0より利用可能となった機能です。
今回はそんなattributeをテストコードの中で活用してみたお話となります!
(文章多めとなってしまいますが、ぜひお付き合いくださいmm)
現状
弊プロジェクトで書いているテストコードには、テストケース毎にテストデータを該当のテーブルにinsertすることがあります。
それらのデータはテストケース終了(=tearDown)時に対象のテーブルをtruncateしています。
課題
tearDown 時でtableのtruncateを明示しないといけず、忘れてしまうと全然違うテストケースのテスト実行に影響を及ぼしてしまう可能性があります。
(例えば、単体でテストを実行すると通るのに、全体で実行するとなぜか通らなくなるみたいな状況が起こり得てしまう状況です)
実現したかったこと
tableのtruncateを明示せずとも、tearDown時に勝手に綺麗になっていて欲しいと思いました。
例えば、LaravelのテストコードにはRefreshDatabaseのような機能があるのですが、CodeIgniterでは(というよりも弊プロジェクトでは)利用できない状況でした。
※ RefreshDatabase を使うと、テストケース開始( = setUp)時にtransactionを貼り、tearDown時にrollbackしてくれるので、truncateなどの明示をすることなくtableをクリーンな状況にしてくれます。便利。
弊社でもRefreshDatabaseのような状況を実現すべく早速実装に踏み込んでみました!
思い通りに行かなかった点
が、一筋縄ではいかない点がちらほら。。
ロジックによってmasterやslaveにアクセスしてしまう
transactionはcommitされなければ、新たにDBにconnectionを作ってもデータを見つけることはできません。
そのため、masterやslaveなどDBのconnectionが複数発生するロジックのテストケースでは実現できませんでした。
Controllerのテストだと同一のconnectionが利用できない
先ほどと事象は異なるものの、似たような状況が発生してしまい実現できませんでした。
擬似requestを送ってControllerの挙動を確認するようなテストを書いているのですが、擬似requestを送る前にテストデータを用意しようとすると、
- テストデータを用意した際のDBへのconnection
- 擬似requestによりアクセスを試みるDBへのconnection
が異なってしまい、用意したテストデータがcontroller側で見つからないという状況となってしまいました。
ここでattributeの出番
ここで本題です。たまにイレギュラーで思い通りにいかないケースもあるものの、少なくともmodelやlibraryのテストケースではほとんど正常にtransactionによるデータの消去を行うことができました。
なので、イレギュラーとしてtransactionを組めないものに関してはattributeを付与し、transaction処理を行わないようにする実装を行いました。
そもそもattributeとは
attributeとは、PHP8.0より利用可能となった機能で、Javaのannotationのようなものです。
https://www.php.net/manual/ja/language.attributes.overview.php
なぜattributeを選定したのか?
使ったことのない機能を触ってみたいという思いもありましたが、attributeを利用することで、不要なロジックを抑えることができると感じたためです。
コードを書く上で、本質的なことを伝える以外のことがたくさんかかれてしまっていると、「結局このコードは何をやっているのか」みたいなあたりが見えづらくなってしまいます。
テストコードのように、準備や後処理が必要となる場ではこれらの問題が出やすいので、今回のattributeのように「付与されてれば◯◯な状態になる」ということを簡単に明示できるのは可読性や運用に優れているように感じたため、選定しました。
今回の登場クラスとロジック
WithoutRollback クラス
attributeです。このattributeを付加したメソッドはロールバックをしない(=setUp時にtransactionを組まない)状態になるようロジックを組みます。
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class WithoutRollback { }
attributeとしての宣言なので、メソッドなどは特に持たせておりません。
TruncateTables クラス
こちらもattributeです。このattributeを付加したメソッドはtearDown時に指定されたtablesのtruncateを行います。
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class TruncateTables { function __construct($tables){ } }
こちらもattributeの宣言ではありますが、WithoutRollbackと異なる点として、コンストラクタの宣言をしております。
これは、TruncateTablesのattribute付加時に「どのtablesをtruncateするか」を一緒に宣言できるようにするためです。
AttributeUtil クラス
traitです。ExampleTestクラスで呼び出して、attributeが付加しているかなどのチェックを行うための実装を行っています。
trait AttributeUtil { /** * @param string $attributeClass * return array */ protected function getAttributes($attributeClass) { $cls = new ReflectionClass($this::class); $clsAttributes = $cls->getAttributes($attributeClass); $handleMethod = $cls->getMethod($this->getName()); $methodAttributes = $handleMethod->getAttributes($attributeClass); return array_merge($clsAttributes, $methodAttributes); } /** * @return bool */ protected function withoutRollback() { return (!empty($this->getAttributes(WithoutRollback::class))); } /** * @return array */ protected function getTruncateTables() { $attributes = $this->getAttributes(TruncateTables::class); if (count($attributes) == 0) return []; $tables = []; foreach($attributes as $attr) { $tables = array_merge($attr->getArguments(), $tables); } return $tables; } }
getAttributes($attributeClass)
今回のattribute取得を行うための、根幹となるメソッドです。
クラスやメソッドに付与されたattributeを取得することができます。
$attributeClass の文字列を付与することで、attributeの実態を含む配列が返ってきます。
ここで返り値を配列とすることで、後にTruncateTablesのattributeに付与されたtable名を取得できるようになります。
余談ではありますが、このメソッドはsetUpやtearDownで利用されることを想定して組んでますが、 $handleMethod で利用している $this->getName() ではテストケース名が返ってきます。(setUpやtearDownという名前は返ってこない)
そのため、テストケースのどこでgetAttributesメソッドを叩いても、期待したattributeを取得することができます。
withoutRollback()
WithoutRollback attributeが付与されているかどうかを確認するためのメソッドです。
getTruncateTables()
truncate対象のテーブル名を配列で取得できます。
TruncateTables attribute付与時に指定したtable名をここで全て取得できます。
ExampleTest クラス
setUpでtransactionを組み、tearDownでrollbackする実装の書かれたテストクラスです。
ブログでの説明上、主にこのクラス上で今回の実装をご説明いたします。
class ExampleTest extends TestCase { use AttributeUtil; public function setUp() : void { parent::setUp(); if ($this->withoutRollback()) return; // ---- ① $this->db->trans_begin(); } public function tearDown() : void { foreach($this->getTruncateTables() as $table) { // ---- ② $this->db->truncate($table); } $this->db->trans_rollback(); parent::tearDown(); } public function testFirst() // ---- ③ { // ... } #[WithoutRollback] #[TruncateTables('users', 'roles')] public function testSecond() // ---- ④ { // ... } }
下記に、今回のポイントを絞って記載します。
if ($this->withoutRollback()) return; // ---- ①
もし withoutRollback() がtrueの場合は、この時点でsetUpを終わらせます。
これにより、transactionを貼らない状況を作ることができます。
foreach($this->getTruncateTables() as $table) { // ---- ②
TruncateTables attributeの付与がある場合、table名を取得でき、tearDownのタイミングで各tableをtruncateします。
attributeが付与されていない場合は、 getTruncateTables() の中身が空配列なのでtruncateは実行されません。
public function testFirst() // ---- ③
testFirst() には特に何のattributeもついていないため、setUp時に trans_begin() が実行されます。
tearDown時でのtruncateメソッドは呼ばれず、 trans_rollback() が実行されたタイミングで、testFirst() ロジック内でinsertされたデータは全てflushされます。
public function testSecond() // ---- ④
testSecond() には WithoutRollbackとTruncateTables attributeが付与されています。
そのため、testSecond()が実行される前の①でのwithoutRollback()はtrueとなるため、transactionは貼られません。
tearDown時で利用したtableのデータのtruncateを行いたいので、TruncateTablesによりtablesを明示します。
今回の例では、users、rolesの2つのテーブルが、truncate対象のテーブルとなります。
WithoutRollback, TruncateTablesのattributeを分けている理由
今回、attributeの役割を
- WithoutRollback: rollbackをしたくない時に付与する
- TruncateTables: truncate対象のtableを明示する
のような形で役割を分けています。
もしここで、
#[WithoutRollback('users')]
のように書けば、役割を統一することもできるようになるかとも思いますし、実装を悩んだところはあります。
しかし、今回controllerのように
- 全てのテストケースでロールバックを対象外にしたい
- truncateするtableは各々変えたい
みたいな状況が起こりうる状況だったので、attributeの役割を分けてしまい、クラスにもattributeを付けられるようにすることで解決することを決めました。
下記に例を記載します。
#[WithoutRollback] class ExampleTest2 extends TestCase { // ... #[TruncateTables('users')] public function testFirst() { // ... } #[TruncateTables('roles')] public function testSecond() { // ... } }
例えば、ExampleTest2はclassそのものに WithoutRollback attributeが付与されています。
そのため、ExampleTest2の全テストケースでtransactionが貼られなくなります。
しかし、testFirstやtestSecondではそれぞれtearDown時でtruncateを明示しなければなりません。
今回の例では、testFirstではusersを、testSecondではrolesをtruncate対象とできております。
attributeの役割を分けることで、このようなattributeの付与の方法にアレンジを効かせられる状況を作りました。
いかがでしたでしょうか?
今回は、Codeigniterで書かれたテストケースにattributeを活用してみた事例を紹介させていただきました。
弊社では、引き続き一緒に働いていただけるエンジニアを募集しております!
attributeの活用事例の議論はもちろん、
- 目標達成や事業の成長を一緒に感じたい
- チーム貢献に尽力できて、またその成果も評価してほしい
- 自由に企画・提案しながら社内にアクションを起こしたい
という方がいらっしゃいましたら、是非一度弊社社員とお話させてください!
最後までお読みいただきありがとうございました!!