こんにちは。
LocoPartnersでReluxのサーバーサイドを担当している下釜です。
この記事は「Relux Advent Calendar 2018」11日目の記事です。
#はじめに
今回はPHPUnitについて紹介していきたいと思います。
PHPUnitはみなさんご存知の通り、クラスや、メソッドといったプログラムの最小単位を対象としたテストのことです。
意外にUnitTestを書いている現場って少ないですよね?
そんなレガシーコードが増え続けると、デグレが課題化してきます。
新しい機能をいれたり、機能修正を行うと既存の機能でバグが出るといったことはサービスとしては非常に困りますし、
かといって毎回大量のリグレッションテストを行うのも工数がかかってしまいます。
属人化しているものほどデグレは起きやすく、実際自分もバグを出してしまったことがあるためUnitTestについて書こうと思いました。
今回はフレームワークにLaravelを用いて行うので、初期インストール時にPHPUnitも一緒についてきました。
↓こちらから誰でも簡単に始められるので試してみてください
https://laravel.com/docs/5.7/homestead
わかりやすい日本語ドキュメントは↓
https://readouble.com/laravel/5.7/ja/homestead.html
#実際にやってみる
では実際にPHPUnitを使ってみましょう。
今回はModelレイヤーとService(ビジネスロジック)レイヤーのテストを書いてみたいと思います。
###Modelレイヤー
/**
* 1件取得
* @param int $id
* @return Product
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function fetchById(int $id): Product
{
return $this->findOrFail($id);
}
上記のように商品テーブルからModelを取得するようなメソッドがあったとします。
このとき、このUnitに対して取りうるテストは
・正常な値が取れていること
・findOrFailを使っているためModelNotFoundExceptionが発生していること
になります。
実際に書くテストはこんな感じ↓
/**
* 指定したidの商品情報を取得するテスト
*/
public function testFetchById()
{
$productId = 1;
$categoryName = 'beauty';
$productModel = $this->app->make(Product::class);
//ここでデータベースにテストデータを投入する
$product = $productModel->fetchById($productId);
$this->assertInstanceOf(Product::class, $product);
$this->assertSame($productId, $product->id);
$this->assertSame($productName, $product->name);
}
まずは正常系。
今回seederを使ってテスト用にデータを入れていますが、seederについては割愛させていただきます。
実際にテーブルから取ってきた値と、期待する値が正しいかのテストをしています。
また、今回はプロダクションコードにて返り値にProductModelを期待しているため、
assertInstanceOfにて確認もしています。
assertSameは、型も値も同じかを見るassertionです。
assertEqualというものもありますが、こちらは値だけ同じかを確認します。
異常系はこういった形になります↓
/**
* 存在しない商品idを指定して取得するテスト
* @expectedException \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function testFetchByIdNoItem()
{
//存在しないID
$productId = 99;
$productModel = $this->app->make(Product::class);
//ここでデータベースにテストデータを投入する
$productModel->fetchById($productId);
}
あれ?assertion何もなくない?と思った方。そう、ないんです。
PHPUnitには便利なアノテーションが用意されていて、ここでは@expectedExceptionを使用しています。
ここに記載することで、期待した例外が発生したかもテストすることが出来ます。
その他便利なアノテーションはこちらから確認できます。
https://phpunit.readthedocs.io/ja/latest/annotations.html
では 実際に書いたテストコードを流してみましょう。
ドキュメントルート上で
./vendor/bin/phpunit
と叩いてみてください。これでtestが全て流れます。
今回の結果はこのような形です。
PHPUnit 7.4.4 by Sebastian Bergmann and contributors.
.. 2/ 2 (100%)
Time: 3.21 seconds, Memory: 16.00MB
OK (2 tests, 4 assertions)
はい!簡単にテストが出来ました!
今回のテストは2つ、assertは4つということですね。
###Serviceレイヤー
では次にService(ビジネスロジック)レイヤーのテストいってみましょう。
ここではMockについて説明したいと思います。
今回はUnitTestについて語っているので、そのメソッドが意図した動きをしているかだけを確認したいです。
例えば下のような商品を追加するメソッドがあったとします。
今回は説明を簡単にするために実際のビジネスロジックは入っていません。
/**
* ID指定で商品を取得する
* @param int $id
* @return Product
*/
public function detail(int $id): Product
{
return $this->product->fetchById($id);
}
内容は、先述したModelのfetchByIdを使うメソッドです。
Modelと同じようにテストを書こうとしたとき、あれ?と思う方がいらっしゃるかもしれません。
同じように値が取れるというテストを書こうとすると、Modelのメソッドを呼んでしまうことに気づきます。
これではせっかくのUnitTestが台無しです。
そこで登場するのがMockです。
この場合のテストをどう書くかと言うと下のようになります↓
//ProductFetchService
/**
* ID指定で商品を取得するテスト
*/
public function testDetail()
{
$expected = Mockery::mock(ProductModel::class);
$productMock = Mockery::mock(ProductModel::class)
->shouldReceive('fetchById')
->andReturn($expected)
->getMock();
$this->app->instance(ProductModel::class, $productMock);
$service = $this->app->make(ProductFetchService::class);
$this->assertInstanceOf(ProductModel::class, $service->detail(1));
}
今回のServiceレイヤーテストで期待することはmodelが返却されていることです。
Modelレイヤーで何が行われていようとSerciveレイヤーからは関係の無いことですよね?
なので、呼ぶModelをMockにしてしまいます。productModelのfetchByIdが呼ばれたら、$expectedを返却するというものです。
そのMockを呼ばれるインスタンスに注入します。
これでModelはMock化されました。
最後にService自身のdetailメソッドを呼んであげれば完成です。
もしここで別のLibraryを使ったりしても、同様にMock化すればこのService単体のテストが可能です。
#まとめ
いかがでしょうか。
テストを書くのって意外に大変じゃないですよね?
これを書くことで、今までビクビクしていたデグレに怯えない日々が来るというだけで自分は書く価値があると思います。
もちろんこれは各レイヤーでのテストのため、仕様にあっているかはテスト出来ていませんので、Contollerまで書き終わった際にfeatureテストで諸々書くのが良いかと思います。
一回書いてしまえば、もちろんメンテナンスは必要ですが、自分が新規に追加したコードと共にテストを流せば自動でリグレッションテストができ、他に影響が出るかも教えてくれるUnitTest。
ぜひ面倒臭がらずに書いて、デグレの恐怖とお別れしましょう!