こんにちは。バックエンドエンジニアの河内です。
LaravelでDDDを採用しテストを書くさいに、DBを使わないリポジトリに差し替える方法について書きます。DDDについて詳細は省きますが、今回は以下のように処理が移るものとします。
Controller → UseCase → Repository
メリット・デメリット
なぜ、そんなことをするのでしょうか。メリットとしては、以下が考えられます。
メリット
- DB設計〜マイグレーションの工程を後に回せる
- テスト時のコスト(負荷、時間)を低減できる
一方で以下のようなデメリットと言えるようなものもあるかと思います。
デメリット
- そのリポジトリを作る必要がある
- 結局、DB使うテスト結果も気になる
実例
というわけで、簡単な実例で考えていきます。 なんらかのアイテムを登録するユースケースです。
プロダクトコード
コントローラ
コントローラです。説明にフォーカスするため、いろいろ削ぎ落としています。PHPDocもありません。アクションでレスポンスしてません。
フォームリクエストでは、受け取った値をユースケースに渡すため、getDataなるメソッドで処理が適切におこなわれたものとします。
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CreateItemRequest;
use App\Packages\AwesomeSystem\UseCase\CreateItem;
class ItemController extends Controller
{
public function __construct(CreateItem $createItem)
{
$this->createItem = $createItem;
}
public function store(CreateItemRequest $request)
{
$this->createItem->handle($request->getData());
}
}
ユースケースです。コンストラクタ引数のリポジトリがインターフェースです。handleメソッドで、受け取ったItemのエンティティをcreateメソッドへ渡しています。
<?php
namespace App\Packages\AwesomeSystem\UseCase;
use App\Packages\AwesomeSystem\Domain\Item;
use App\Packages\AwesomeSystem\Infrastructure\ItemRepositoryInterface;
class CreateItem
{
protected $itemRepository;
public function __construct(ItemRepositoryInterface $itemRepository)
{
$this->itemRepository = $itemRepository;
}
public function handle(Item $item)
{
$this->itemRepository->create($item);
}
}
DBを使うリポジトリです。Eloquentに依存しているので、ファイル名をEloquentで始めています。依存しているので、いきなり呼び出してcreateメソッドで保存処理を実行してしまいます。今回は、アイテムの名前だけ登録するものとします。エンティティItemと名前が重複するので今回はモデルのほうをEloquentItemとしました。
<?php
namespace App\Packages\AwesomeSystem\Infrastructure;
use App\Models\Item as EloquentItem;
use App\Packages\AwesomeSystem\Domain\Item;
class EloquentItemRepository implements ItemRepositoryInterface
{
public function create(Item $item)
{
EloquentItem::create(['name' => $item->getName()]);
}
public function first(): Item
{
return EloquentItem::first();
}
}
最低限、リポジトリに実装されるべきメソッドが羅列されています。
<?php
namespace App\Packages\AwesomeSystem\Infrastructure;
use App\Packages\AwesomeSystem\Domain\Item;
interface ItemRepositoryInterface
{
public function create(Item $item);
public function first(): Item;
}
サービスプロバイダ
ユースケースのコンストラクタ内で、ItemRepositoryInterfaceを指定したら、(プロダクトでは)EloquentItemRepositoryを呼び出してほしい。これは明示的にそう言ってあげないとLaravelは知るよしもありません。サービスプロバイダで結合します。
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Validator\CustomValidator;
class AppServiceProvider extends ServiceProvider
{
@return
public function register()
{
$this->app->bind(
\App\Packages\AwesomeSystem\Infrastructure\ItemRepositoryInterface::class,
\App\Packages\AwesomeSystem\Infrastructure\EloquentItemRepository::class
);
}
エンティティ
ちなみに、Itemエンティティは以下のようなイメージです。$nameは値オブジェクトでもよさそうですがこれも今回は省きます。
<?php
namespace App\Packages\AwesomeSystem\Domain;
class Item
{
protected $name;
public function __construct(string $name)
{
$this->name = $name;
}
public function getName(): string
{
return $this->name;
}
}
テストコード
さて、このユースケースのテストを書いてみます。タイトルのとおり、テストはDBを使わないリポジトリでおこないます。
<?php
namespace Tests\Unit\Awesome\UseCase;
use App\Packages\AwesomeSystem\Domain\Item;
use App\Packages\AwesomeSystem\Infrastructure\InMemoryItemRepository;
use App\Packages\AwesomeSystem\UseCase\CreateItem;
use Tests\TestCase;
class CreateItemTest extends TestCase
{
public function testアイテム新規登録()
{
$itemRepository = app()->make(InMemoryItemRepository::class);
app()->bind(CreateItem::class, function () use ($itemRepository) {
return new CreateItem($itemRepository);
});
$name = 'アイテムテスト';
(app()->make(CreateItem::class))->handle(new Item($name));
$item = $itemRepository->first();
$this->assertEquals($name, $item->getName());
}
}
①で、DBを使わないリポジトリを生成しています。②が、先述のサービスプロバイダでの結合と異なり、DBを使わない①との結合となっています。③でその結合でもってユースケースを生成し、アイテムエンティティを渡して保存しました。④でデータを1つ取り出します。初投入されたことが明らかなので、firstメソッドで取り出しました。⑤で、投入データと保存データが合致するか確かめます。これで、DBを使わずテストをおこなえました。
インメモリリポジトリについて触れておきます。内部では、プロパティ$dataを、データストアと見立て、createメソッドでは受け取ったアイテムエンティティをpushしているだけです。firstメソッドではコレクションの最初のエンティティを返すだけです。
<?php
namespace App\Packages\AwesomeSystem\Infrastructure;
use App\Packages\AwesomeSystem\Domain\Item;
class InMemoryItemRepository implements ItemRepositoryInterface
{
private $data;
public function __construct()
{
$this->data = collect();
}
public function create(Item $item)
{
$this->data->push($item);
}
public function first(): Item
{
return $this->data->first();
}
}
実例からメリット・デメリットを考察する
あらためて、メリットをふり返ります。
メリット
1. DB設計〜マイグレーションの工程を後に回せる
実例ではプロダクトコードから紹介しましたが、テストを書くのであればTDD…そのTDDの教えるところはテストファーストです。
だとすると、以下の順番でコードを書いていくことになろうかと思います。
- ユースケーステスト
- ユースケース
- リポジトリインターフェース
- (DBを使わない)リポジトリ
これだけあればユースケースのテストがかないます。これまでDBについて考えることはありませんでした。したがって、マイグレーションも登場しません。
ただし、エンティティ作成にあたってはドメインモデルを深く考える必要があります。それはDDDで本質的な作業です。あくまで、DBについての関心はひとまず置いておける、ということです。
2. テスト時のコスト(負荷、時間)を低減できる
DBテストでは、テーブルをTRUNCATE〜INSERTを繰り返す必要があり、テストケースが増えるとそのコストが無視できません。インメモリリポジトリではDBにまつわる問題をわきに置いて、ロジックのテストに集中できていることになります。
デメリット
1. そのリポジトリを作る必要がある
当然ですが、DBを使うリポジトリと別にDBを使わないインメモリリポジトリを作る必要があります。なければないでプロダクトコードを実装することもできます。テストケースの数によっては、インメモリリポジトリを作らないで済ませることもできそうですし、ケースバイケースです。
ただ、インターフェースを用意しインメモリリポジトリを作ってしまえば体感としては9割方完成はしていて、後はDBを使うリポジトリを用意しあてがうだけ…という感じです。各リポジトリを同じ工数かけて2つ作らねば…という感じではないのかな、と思います。
2. 結局、DB使うテスト結果も気になる
デメリットといいますか、プロダクトコードはDBを使うリポジトリで動くので、DB込でアプリケーションが本当に動くかの統合テストは別途必要になるかと思います。
まとめ
リポジトリの呼び出しのさいインターフェースに依存することで、プロダクトコードとテストコードで用いるリポジトリを使い分けることができました。インメモリリポジトリを使うことで、インフラ(=DB)の事情にとらわれず、ロジックのテストに集中できるかと思います。
最後に
Wizではエンジニアを募集しております。
興味のある方、ぜひご覧下さい。
careers.012grp.co.jp