こんにちは。ギークフィードの高橋敦史です。
今回はPHPUnit(ユニットテスト)を前提としたLaravelの設計テクニックについて書きます。
皆さんは結合テストで修正が発生したり、さらにはデグレが発生してから初めて本格的にユニットテストが必要になった経験はないでしょうか?
ところが開発の途中からテストコードを書き始めると、次のような問題に直面し、乗り越えるための工数やリスクが思いのほか増大してしまいます。
- テストコードを書くべき関数が分からない
- テストしたい関数ほど、テストコードを書けない
- テストケースが膨大になる
- テスト実行時にエラーが発生し、対処に追われる
今回はこのような問題に備えておくための、PHPUnitと親和性の高い設計テクニックをご紹介します。
(PHPUnit自体の話やテストコードの実装についてはまた別の機会に)
目次
パラメータはすべて引数で渡す
関数のインターフェース定義は、なるべくインプットに対してアウトプットが一意となるように設計します。
具体的には、アウトプットを生成するために必要なパラメータをすべて引数で渡すようにします。
引数以外の状態、例えば「実行時刻」、「static変数」、「グローバル変数」、「セッションの値」などでアウトプットが変わってしまう設計にしてしまうと、ユニットテストは引数以外のインプット準備の手間が必要になり、テスト自体の見通しも悪くなります。
なお、引数の種類が多くなりすぎて可読性が低下するような場合、DTOを使って受け渡すとスッキリします。
サービスコンテナを使う(依存性を下げる)
インスタンスの生成は、サービスコンテナを利用しましょう。
サービスコンテナを使うと直接的な依存を回避でき、テストでモッククラスに置き換えることができます。
逆に関数内でインスタンス生成していると強い依存関係となり、テスト親和性とコード再利用性が低下します。
<サービスコンテナを使った例>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class SampleController extends Controller { private $model; public function __construct(SampleModel $model) // ← SampleModelを継承したモッククラスに置換可能 { $this->model = $model; } public function index() { $this->model->find(); ... ... } } |
<サービスコンテナを使わない例>
1 2 3 4 5 6 7 8 9 10 |
class SampleController extends Controller { public function index() { $model = new SampleModel(); // ← SampleModel以外に置き換えられない $model->find(); ... ... } } |
型指定を使う
関数のインターフェース定義時は、引数と戻り値の型を指定しましょう。
型指定をしておくことで、異なる型の場合を考えずに済むので、テストケースの大幅削減につながります。
DTOで受け渡しする場合も、DTO側のgetter,setterで型定義しておきます。
型指定はIDEとも相性が良く、コーディング時のエラーチェックが効きやすくなり、結果的に実装品質が上がります。
定数定義はdefine()を避ける
定数を定義する場合、安易にdefine()を使うのは避けましょう。
PHPUnit実行時に定数の二重定義エラー Constant *** already defined に直面することがあります。
お薦めする順に実装方法を3つご紹介します。
定数定義-その1(configを使う)
Laravelのconfigを使う方法です。
app/config ディレクトリの下に定義しておくことで、config()関数で取得できるようになります。
1 2 3 4 |
<?php return [ 'DEFAULT_VALUE' => 10 ]; |
1 2 |
// どこからでもこのように呼び出せる $value = config('const.DEFAULT_VALUE'); |
定数定義-その2(定数クラスを使う)
config()では綴りが長くて不便だったり、IDEの補完が効かずにタイポを招くこともあります。
そういった場合はクラス定数を検討します。
例えば定数定義専用のクラスを作成しておくと、IDEの補完も届きやすくなります。
1 2 3 4 |
class Const { const DEFAULT_VALUE = 10; } |
さらにaliasを登録しておくと利用時のuse宣言も不要になり、かなり使いやすくなります。
1 2 |
// alias設定があればフルパス指定もuse宣言も不要 $value = Const::DEFAULT_VALUE; |
定数定義-その3(あくまでdefine)
あくまでdefine()を使う場合は、二重定義エラーを起こさないよう定義済み時の回避を仕込んでおきましょう。
1 |
if (!defined('DEFAULT_VALUE')) define('DEFAULT_VALUE', 10); |
テストしやすい構成(MVC)
クラスの配置をテストしやすい構成にしておきます。
ここではMVCを例にとり、階層ごとの比較をまとめます。
各レイヤーでの役割を明確に分けておくことで、PHPUnitの対象を絞ったり、テストの観点を特化することが出来るようになります。
階層 | パス例 | 役割 | ユニットテストの実施 |
---|---|---|---|
[M] Model |
app └Models |
DBアクセスのCRUD処理 | CRUD自体に対するテスト意義は薄い。 |
[V] View |
resources └views |
blade等のHTML定義 | 単体テスト不可。Controller(web)の戻り値としてはテスト可。 |
[C] Controller (web) |
app └Http └Controllers └Home |
webリクエストに対するコントローラ | テスト親和性は低い。生成されるHTMLに対するテストは可能だが、高度なテストができないため。 |
[C] Controller (api) |
app └Http └Controllers └Api |
APIリクエストに対するコントローラ | テスト親和性は高い。特にjsonやプリミティブ型の戻り値に対するテスト。 |
[C] Service |
app └Services |
コントローラから切り離したビジネスロジック | 最重要テスト対象。 |
ポイントは、ビジネスロジックを他の階層から切り離して集約しておくことです。
Modelの中に複雑な条件分岐を持つSelectメソッドを設けたり、Viewで表示以外のビジネスロジックに基づく分岐を実装しないよう留意しましょう。TraitやUtil系関数なども、各階層ごとに明確に分けておけばテスト対象として分かりやすく管理できます。
また要件次第ではありますが、javascript等のクライアント側で動くプログラムもユニットテスト対象外となるため、PHPUnitとしてはサーバサイドにロジックを寄せておく方が楽になります。
migration,seederを使う
migration、seederを使いましょう。
migrationを定義しておくと、後述する「環境の切り替え」と組み合わせることで、まっさらな状態のデータベースでテストを実施することができます。
seederを定義しておくと、例えばマスタの初期データなどシステムを稼働させるために必要なデータ一式を準備できます。
環境の切り替え
phpunit.xml の設定で、実行時に読み込む.envファイルを指定できます。
例えば下記のように testing など専用の環境を設定しておきましょう。
1 2 3 4 5 6 7 8 9 10 |
<?xml version="1.0" encoding="UTF-8"?> <phpunit backupGlobals="false" ...(中略)... <php> <env name="APP_ENV" value="testing"/> <env name="CACHE_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/> <env name="QUEUE_DRIVER" value="sync"/> </php> </phpunit> |
これに合わせて .env.testing も作成して、接続先DBを切り替えておきます。
1 2 3 4 5 6 |
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=testing_db DB_USERNAME=testing_user DB_PASSWORD=xxxxxxxxxx |
テスト用DBの準備は、migration実行のartisanコマンドにオプションを指定して実行します。
1 |
php artisan migrate --env=testing |
テストで生成したデータは、PHPUnit実行後に消すことも残すこともできます。
おまけ:Fakerを使ったダミーデータ生成
設計の話ではありませんが、環境の切り替えとseederを組み合わせ、さらにFakerを使ってダミーデータを生成することもおすすめです。パッと見それっぽいデータが生成でき、またデータ量の調整も容易なのでテスト時は重宝します。
事前にダミーデータをダンプ等のINSERT形式で用意しておいてseeder内で投入することも可能ですが、DB定義が変わった場合などメンテナンスに苦労しますので、Fakerでのダミー生成がお薦めです。
おわりに
さて、いかがでしたでしょうか。
Laravelっぽく実装しているうちに自然とユニットテスト親和性が高くなるあたり、よくできたフレームワークだなと改めて思います。
今回ご紹介した技は、設計時の仕込みとして参考にしていただければ幸いです。
- Simple AWS DeepRacer Reward Function Using Waypoints - 2023-12-19
- Restrict S3 Bucket Access from Specified Resource - 2023-12-16
- Expand Amazon EBS Volume on EC2 Instance without Downtime - 2023-09-28
- Monitor OpenSearch Status On EC2 with CloudWatch Alarm - 2023-07-02
- Tokyo’s Coworking Space Hidden Gem: AWS Startup Loft Tokyo - 2023-05-24