背景
サーバサイド設計の際に、
厳格なルールを持ったコントロール層やサービス層などを用意しておくと、
基本的なCRUDのテストコードもある程度共通化できるんでないか、と思った。
あと、複数人でテストコード書く時に、ある程度自動的に規格化されるんでないか、と思った。
環境
- PHP 7.2.13
- laravel 5.5
- PHPUnit 6.5.13
サーバサイド設計
いわゆるリポジトリパターンを採用して、
ビジネスロジックはサービス層に集約した。
簡単に言うと、大体のリクエストはこんな感じで処理が走る
リクエスト => コントローラ => サービス => リポジトリー => モデル => DB
テスト設計
いわゆるTemplate Method
パターンを使います。
そしてスーパークラス(抽象クラス)には主に以下を定義しておきます。
- setUp(テスト前に実行する処理)
- 共通(にできそうな)テスト
- クラス毎に適時処理を切り分けるための抽象メソッド(データプロバイダー含む)
Before
例えばサービス内に「ジャンル」といったエンティティがあったとして、
それを全件取得する、および一件だけ取得する、といったことはよくあると思います。
で、それはこんな感じでサービス層でテストできるかと思います。
(何をどれくらいの粒度でテストするかはプロジェクトの指針にもよるので、
ここでは簡潔なテストのみにしておきます。)
<?php
namespace Tests\Unit\Services;
use App\Models\Genre;
use App\Services\GenreService;
use GenresSeeder;
use Illuminate\Database\Eloquent\Collection;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Class GenreServiceTest
*/
class GenreServiceTest extends TestCase
{
use RefreshDatabase;
/**
* @var
*/
protected $GenreService;
/**
* パラメータの設定とシーダーの投入
*/
public function setUp()
{
parent::setUp();
$this->GenreService = app()->make(
GenreService::class
);
$this->seed(GenresSeeder::class);
}
/**
* 全件検索
* @test
*/
public function all()
{
$res = $this->GenreService->all();
$this->assertInstanceOf(Collection::class, $res);
$this->assertGreaterThanOrEqual(1, $res->count());
foreach ($res as $item) {
$this->assertInstanceOf(Genre::class, $item);
}
}
/**
* findのデータプロバイダー
*
* @return array
*/
public function dataProvider_find(): array
{
return [
'id: 1' => [1],
'id: 2' => [2],
];
}
/**
* 一件検索
* @test
* @dataProvider dataProvider_find
*/
public function find(int $id)
{
$res = $this->GenreService->find($id);
$this->assertInstanceOf(Genre::class, $res);
}
}
で、似たようなエンティティが他にも大体あると思います。
ユーザー情報だったり、店舗情報だったり。
そして、似たような(もしくは製作者が違う場合バラバラな)テストがつらつらと同様に生まれるわけです。
AFTER
抽象化してみる
スーパークラス
<?php
namespace Tests\Unit\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Class ServiceTest
*/
abstract class ServiceTest extends TestCase
{
use RefreshDatabase;
/**
* @var \App\Services\ModelService
*/
protected $Service;
/**
* サービス名を返す
*
* @return string
*/
abstract protected function getServiceName(): string;
/**
* モデル名を返す
*
* @return string
*/
abstract protected function getModelName(): string;
/**
* シーダー名を返す
*
* @return string
*/
abstract protected function getSeederName(): string;
/**
* パラメータの設定とシーダーの投入
*/
public function setUp()
{
parent::setUp();
$this->Service = app()->make(
$this->getServiceName()
);
$this->seed($this->getSeederName());
}
/**
* 全件検索
* @test
*/
public function all()
{
$res = $this->Service->all();
$this->assertInstanceOf(Collection::class, $res);
$this->assertGreaterThanOrEqual(1, $res->count());
foreach ($res as $item) {
$this->assertInstanceOf($this->getModelName(), $item);
}
}
/**
* findのデータプロバイダー
*
* @return array
*/
abstract public function dataProvider_find(): array;
/**
* 一件検索
* @test
* @dataProvider dataProvider_find
* @param int $id
*/
public function find(int $id)
{
$res = $this->Service->find($id);
$this->assertInstanceOf($this->getModelName(), $res);
}
}
やってみたらデータプロバイダーも抽象化できました。
サブクラス
<?php
namespace Tests\Unit\Services;
use App\Models\Genre;
use GenresSeeder;
/**
* Class GenreServiceTest
*/
class GenreServiceTest extends ServiceTest
{
/**
* サービス名を返す
*
* @return string
*/
protected function getServiceName(): string
{
return GenreService::class;
}
/**
* モデル名を返す
*
* @return string
*/
protected function getModelName(): string
{
return Genre::class;
}
/**
* シーダー名を返す
*
* @return string
*/
protected function getSeederName(): string
{
return GenresSeeder::class;
}
/**
* findのデータプロバイダー
*
* @return array
*/
public function dataProvider_find(): array
{
return [
'id: 1' => [1],
'id: 2' => [2],
];
}
/**
* 各サービス独自のテスト
* @test
*/
public function generateGenre()
{
// 必要ならここの記述する
}
}
サブクラスのgetModelName
やらを各クラスに指定してやれば、
他のサービステストにも流用可能です。
データプロバイダーやら抽象メソッドをうまいこと作って、必要な項目は注入するようにしちゃいましょう。
よかったこと
- 工数減る
- 独自の処理がわかりやすい
- 全員が似たようなコードになる(はず)
よかったのかよくわからんこと
テストを共通化するのは(心理的に)どうなのこれ
テスト通ちゃってる感から来る、テストコードの漏れ。
エンティティによってはこのパターンも確認したい、とかが逆に漏れるかもしれん。
一つのファイルとしてはテストの流れがわかりにくい
今回でいうサブクラスだけを見た場合、どういうテストが走っているかはわからない。
必ずスーパークラスを見ないといけない
オーバーライドして、追加処理を実装する場合が気持ち悪い
例えば、
public function all()
{
$res = $this->Service->all();
$this->assertInstanceOf(Collection::class, $res);
$this->assertGreaterThanOrEqual(1, $res->count());
foreach ($res as $item) {
$this->assertInstanceOf($this->getModelName(), $item);
}
}
で全件取得をチェックした後に、さらにリレーションなど特定のチェックを続けてしたいこともあるかと思います。
その場合
public function all()
{
$res = $this->Service->all();
$this->assertInstanceOf(Collection::class, $res);
$this->assertGreaterThanOrEqual(1, $res->count());
foreach ($res as $item) {
$this->assertInstanceOf($this->getModelName(), $item);
}
return $res; // 追加
}
public function all()
{
$res = parent::all();
// サービス独自に行いたい何かしらの追加チェック
$this->assertSame($expect, array_keys($res->toArray()));
}
こうやってサブクラスでスーパークラスの結果を持って来ることができるんですが、
サブクラスは何もリターンしないので、オーバーライドの原則に反してしまいます。
(動きはしますが、タイプヒンティングまでするとこけます)
タイプヒンティングしない、もしくはreturnしておけば動きますが、returnしたものが使われないのは気持ち悪い
public function all(): Collection // タイプヒンティング
{
$res = parent::all(); // ここでCollection型のオブジェクトがスーパークラスから戻っているので
// 追加処理
$this->assertSame(self::$columns, array_keys($res->toArray()));
return $res; // サブクラスでも同等のものを返す必要があるが、・・・使われない
}
以下のように、パラメータ化すると返さなくて良いので、そのほうが良いかもしれません。
が、resultがテストごとに型が違う可能性があるので、結局使いづらい
protected $result;
public function all()
{
$this->result = $this->Service->all();
$this->assertInstanceOf(Collection::class, $this->result);
$this->assertGreaterThanOrEqual(1, $this->result->count());
foreach ($this->result as $item) {
$this->assertInstanceOf($this->getModelName(), $item);
}
}
public function all()
{
parent::all();
// 追加処理
$this->assertSame(self::$columns, array_keys($this->result->toArray()));
}
あとがき
コードは減りますが、流れが複雑になるので、
読みにくさと、規格化のトレードオフですね。
重複コードが減るので、拡張性も高いのですが、応用が効きづらいかもしれません。(今の所)
また、ルールに沿って規格化されてないと結局コードの書き方がバラバラで読みにくくなる可能性が高いので、プロジェクトにもよります。
また、テストしたいサーバサイドロジックをある程度整備しておかないと、
このパターンはうまくはまらないかと思われます。
運用に耐えれるかは今後の課題として、
こうやってコードを共通化するのは作業としては気持ちが良いものですね。
長くなりそうなので知見を得れば別に書きます。