LaravelとPHPUnit-データアクセスをモック化してHTTPテスト&ユニットテストを効率化する-
- 公開日
- 更新日
- カテゴリ:Laravel
- タグ:PHP,Laravel,Factory,PHPUnit,Mockery
PHP で WEB アプリケーション開発を行う場合の自動テストのツールとして PHPUnit が有名ですが、Laravel にはデフォルトで組込まれており、インストールしてすぐにでも使用する事が出来ます。
今回は、Laravel と PHPUnit を使ってテストを書きつつ、データアクセス部分のモック化を行いながら HTTP テストやユニットテストの効率化を行っていきます。
Contents
開発環境
今回の開発環境は以下の通りです。
- Laravel 5.8
- PHPUnit 7.5
- MySQL 8.0
- SQLite 3.7
今回はユニットテストと HTTP テストをメインに見ていきますが、その中で DB テスト部分にも触れるのでデータソースを2つ用意しています。
尚、記事中のパスの表記について、Laravel のプロジェクトルートを「 laravel/」としています。
HTTP テストとユニットテスト
ユニット(単体)テストは、1つの機能に対する自動テストの事を指します。例えば 1 つのメソッドだったり関数だったりに対して行うテストです。
対して HTTP テストは、HTTP リクエストから始まる各機能の繋がりやその結果(レスポンス)など、一連の流れをテストするものです。
Laravel では tests ディレクトリの中に Feature と Unit ディレクトリが存在しますが、HTTP テストは Feature ディレクトリへ、ユニットテストは Unit ディレクトリへ設置します。
ユニットテストと言われる function ベースのテストが自動テストの基本ですが、HTTP テストも取り入れる事によってもっと包括的なテストを実行する事ができ、よりアプリケーションの信頼度も高まります。
そんな中、ただテストを定義していけば良いかというとそうでもありません。 一定規模のアプリケーションになると、機能の数も増え、全てのテストを回すだけで結構な時間がかかってしまいます。
テストの時間を短くし、効率よく開発を進める為に「モック」という手法が自動テストではポイントになってきます。
その辺を踏まえながら今回は一連のテストを定義していきたいと思います。
アプリケーションの構成
今回、テストを行うアプリケーションのサンプルとして、以下の構成のものを使用します。
本情報の一覧を取得するアプリケーションの機能です。 1つのコントローラから1つのサービスクラスを通じてリポジトリからデータを取得する流れになっています。
HTTP テスト
本来は小さい単位のテストから作成していくのがセオリー(なので HTTP テストは通常ユニットテストの後で定義)ですが、今回のメインテーマはモックなので、敢えてメインディッシュである HTTP テストから定義します。
以下、コントローラのソースです。
- laravel/app/Http/Controllers/BookListController.php
-
<?php namespace App\Http\Controllers; use App\Services\BookService; class BookListController extends Controller { private $BookService; public function __construct(BookService $BookService) { $this->BookService = $BookService; } public function index() { // データ取得 $list = $this->BookService->getList(); return view('book_list', ['list' => $list]); } }
この index メソッドに対するテストを定義します。尚、エンドポイントはドメインルート( https://xxx.xxx.com/)です。
Factory 定義
まずは Factory を編集して、モック化に使用する疑似データを定義しておきます。
Factory は以下 artisan コマンドで生成できます。
# プロジェクトルートへ移動
cd /path/to/laravel
# Factory 生成
php artisan make:factory BookFactory
生成した BookFactory に以下を追加します。
- laravel/database/factories/BookFactory.php
-
$factory->define(Book::class, function () { return [ 'id' => 1, 'name' => '車輪の下で', 'author_id' => 1, 'author' => [ 'id' => 1, 'name' => 'ヘルマン・ヘッセ' ] ]; }, 'test_book_mock_data_1');
コントローラのテスト
ここから HTTP テストを定義していきます。以下の artisan コマンドを叩いてテストクラスを生成します。
# コントローラのテストクラス生成
php artisan make:test BookListControllerTest
- laravel/tests/Feature/BookListControllerTest.php
-
<?php namespace Tests\Feature; use Mockery; use App\Services\BookDataAccess; use Tests\TestCase; class BookListControllerTest extends TestCase { /** @var Mockery */ protected $repositoryMock; public function setUp(): void { parent::setUp(); // BookListの疑似データを作成 $books = []; $books[] = factory(\App\Book::class, 'test_book_mock_data_1')->make()->toArray(); // リポジトリのデータアクセスをモック $this->repositoryMock = Mockery::mock(BookDataAccess::class); $this->repositoryMock->shouldReceive('getList')->andReturn($books); $this->app->instance(BookDataAccess::class, $this->repositoryMock); } public function tearDown(): void { parent::tearDown(); Mockery::close(); } /** * @test * @group http * @group booklist * @group getlist */ public function getList_データ取得が正常に行えビューが表示されること() { $response = $this->get('/'); $response->assertStatus(200); } /** * @test * @group http * @group booklist * @group getlist */ public function getList_ビューへ渡すデータが意図した構造であること() { $response = $this->get('/'); $response->assertViewHas('list'); } }
コンストラクタである setUp メソッドで、データアクセス部分のモック化を行っています。
$books[] = factory(\App\Book::class, 'test_book_mock_data_1')->make()->toArray();
先に Factory で定義した疑似データを取得し、BookList の疑似データを作成しています。
// リポジトリのデータアクセスをモック
$this->repositoryMock = Mockery::mock(BookDataAccess::class);
$this->repositoryMock->shouldReceive('getList')->andReturn($books);
$this->app->instance(BookDataAccess::class, $this->repositoryMock);
リポジトリへのデータアクセスをモック化しています。
- データアクセスに対するモックを作成
- リポジトリの getList() メソッド要求に対して、BookList の疑似データを返す様に指定
- データアクセスに対してモック化を実行
これでデータアクセス部分のモック化が実行されます。
次に各テストケースですが、HTTP テストなので、リクエストを発生するところからがスタートになります。 今回定義したテストケースではそれぞれ、ドメインルートへのリクエストに対して以下のテストケースを定義しています。
- HTTP ステータスコード 200 のレスポンスを以て正常にビューの表示までが行えた事
- リクエストの処理後、ビューに渡されるデータに list プロパティが存在している事
つまり今回の例で言えば、HTTP テストを行う事によって、
「ドメインルートへのリクエストを BookList コントローラが受け取り、Book サービスクラスがリポジトリよりデータを取得しそれをビューへ渡す」
という一連の処理の流れをテストする事ができるわけです。
今回定義した2つのテストケースによって、きちんと意図した処理の道筋を通る事を確認する。 これが HTTP テストを行う意味です。なので、ストレージ部分はモック化し、そこの部分はリポジトリのユニットテスト( DB テスト)で行う事で、自動テストを効率化(時間短縮)する事ができます。
今回の例でもしデータアクセス部分をモック化しない場合はリポジトリからのデータアクセスが発生するので、テスト用の DB やデータを用意する必要があります。 どのみち前処理が必要になるので、それならばモック化した方が良い事しかありません。
ユニットテスト
ユニットテストでも必要な部分をモック化していきます。今回の例でいうとサービスクラスです。 逆に、リポジトリのユニットテストの場合はがっちりデータベースと通信して CRUD のテストを行います。
サービスクラスのテスト
テスト対象である BookService クラスは以下のようなソースになっています。
- laravel/app/services/BookService.php
-
<?php declare(strict_types=1); namespace App\Services; use App\Entities\BookList; class BookService implements BookDataAccess { /** @var BookDataAccess */ private $BookDataAccess; /** @var BookList */ private $BookList; public function __construct(BookDataAccess $BookDataAccess, BookList $BookList) { $this->BookDataAccess = $BookDataAccess; $this->BookList = $BookList; } public function getList(): BookList { $data = $this->BookDataAccess->getList(); $this->BookList->set($data); return $this->BookList; } }
定義されている getList() メソッドは、リポジトリから取得したデータをエンティティに渡し BookList オブジェクトを作成して返す処理になっています。 このメソッドに対するユニットテストを定義します。
以下の artisan コマンドを叩いてテストクラスを生成します。
# サービスクラスのテストクラス生成
php artisan make:test BookServiceTest --unit
- laravel/tests/Unit/BookServiceTest.php
-
<?php declare(strict_types=1); namespace Tests\Unit; use Mockery; use Tests\TestCase; use App\Services\BookDataAccess; use App\services\BookService; class BookServiceTest extends TestCase { /** @var Mockery */ protected $repositoryMock; /** @var BookService */ protected $BookService; public function setUp(): void { parent::setUp(); // BookListの疑似データを作成 $books = []; $books[] = factory(\App\Book::class, 'test_book_mock_data_1')->make()->toArray(); // リポジトリのデータアクセスをモック $this->repositoryMock = Mockery::mock(BookDataAccess::class); $this->repositoryMock->shouldReceive('getList')->andReturn($books); $this->app->instance(BookDataAccess::class, $this->repositoryMock); // BookServiceインスタンス生成 $this->BookService = app(BookService::class); } public function tearDown(): void { parent::tearDown(); Mockery::close(); } /** * @test * @group unit * @group service * @group BookService * @group getList */ public function getList_返り値がBookListオブジェクトであること(): void { $data = $this->BookService->getList(); $this->assertInstanceOf(\App\Entities\BookList::class, $data); } /** * @test * @group unit * @group service * @group BookService * @group getList */ public function getList_必要なプロパティを保持していること(): void { $data = $this->BookService->getList(); $this->assertObjectHasAttribute('id', $data->getIterator()[0]); $this->assertObjectHasAttribute('name', $data->getIterator()[0]); $this->assertObjectHasAttribute('author', $data->getIterator()[0]); } }
モック化の手順は先程と同じなので、バリエーションとしての紹介です。 このメソッドでは受け取ったデータで BookList オブジェクトを作成するという仕事があるので、その部分が意図通りに行えている事が最も テストしたい部分になります。なのでデータアクセス部分はモック化し、その処理が正しく行えているかだけをテストしている。という事になります。
リポジトリのテスト
リポジトリでは実際にデータの操作を行う部分なのでモック化はせず、実際に DB を操作してテストを行います。 今回は MySQL と SQLite でのデータソースを用意していたので、この2つのリポジトリに対するテストを定義します。
まずはテスト対象となるインターフェースと各リポジトリのソースです。
- laravel/app/Services/BookDataAccess.php
-
<?php declare(strict_types=1); namespace App\Services; interface BookDataAccess { public function getList(); }
- laravel/app/Repositories/BookMysqlRepository.php
-
<?php declare(strict_types=1); namespace App\Repositories; use App\Services\BookDataAccess; use App\Book; class BookMysqlRepository implements BookDataAccess { protected $Book; private $connection = 'mysql'; public function __construct(Book $Book) { $this->Book = $Book; } public function getList(): array { return $this->Book::on($this->connection)->with('author:id,name')->get()->toArray(); } }
- laravel/app/Repositories/BookSqliteRepository.php
-
<?php declare(strict_types=1); namespace App\Repositories; use App\Services\BookDataAccess; use App\Book; class BookSqliteRepository implements BookDataAccess { protected $Book; private $connection = 'sqlite'; public function __construct(Book $Book) { $this->Book = $Book; } public function getList(): array { return $this->Book::on($this->connection)->with('author:id,name')->get()->toArray(); } }
これらに対してテストを定義します。 以下の artisan コマンドを叩いてテストクラスを生成します。
php artisan make:test BookDataAccessTest --unit
php artisan make:test BookMysqlRepositoryTest --unit
php artisan make:test BookSqliteRepositoryTest --unit
それぞれ、テストを定義していきます。
- laravel/tests/Unit/BookDataAccessTest.php
-
<?php namespace Tests\Unit; use Tests\TestCase; abstract class BookDataAccessTest extends TestCase { /** * @test * @group unit * @group DataAccess * @group Book * @group getList */ public function getList_データ取得が正常に行われること() { factory(\App\Author::class, 'test_author_data_1')->create(); factory(\App\Book::class, 'test_book_data_1')->create(); $list = $this->Book->getList(); $this->assertCount(1, $list); } /** * @test * @group unit * @group DataAccess * @group Book * @group getList */ public function getList_必要なプロパティがセットされていること() { factory(\App\Author::class, 'test_author_data_1')->create(); factory(\App\Book::class, 'test_book_data_1')->create(); $list = $this->Book->getList(); $expected = [ 'id', 'name', 'author_id', 'author' ]; $this->assertSame($expected, array_keys($list[0])); $expected = [ 'id', 'name' ]; $this->assertSame($expected, array_keys($list[0]['author'])); } }
- laravel/tests/Unit/BookMysqlRepositoryTest.php
-
<?php namespace Tests\Unit; use App\Book; use App\Entities\BookList; use App\Services\BookDataAccess; use App\Repositories\BookMysqlRepository; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Artisan; class BookMysqlRepositoryTest extends BookDataAccessTest { use RefreshDatabase; protected $Book; public function setUp(): void { parent::setUp(); $this->app->bind(BookDataAccess::class, function($app) { return new BookMysqlRepository(new Book, new BookList); }); $this->Book = app(BookMysqlRepository::class); } public function tearDown(): void { Artisan::call('migrate:refresh'); parent::tearDown(); } }
- laravel/tests/Unit/BookSqliteRepositoryTest.php
-
<?php namespace Tests\Unit; use App\Book; use App\Entities\BookList; use App\Services\BookDataAccess; use App\Repositories\BookSqliteRepository; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Artisan; class BookSqliteRepositoryTest extends BookDataAccessTest { use RefreshDatabase; protected $Book; public function setUp(): void { parent::setUp(); // データベースをSQLiteへ変更 config(['database.default' => 'sqlite']); // マイグレーション実行 Artisan::call('migrate:refresh'); $this->app->bind(BookDataAccess::class, function($app) { return new BookSqliteRepository(new Book, new BookList); }); $this->Book = app(BookSqliteRepository::class); } public function tearDown(): void { parent::tearDown(); } }
ちなみにデータソースが2つありますが、それぞれのテスト DB を用意しています。
# .env
DB_DATABASE=laravel_db
DB_DATABASE_SQLITE=/vpath/to/laravel/database/database.sqlite
# .env.testing
DB_DATABASE=test_laravel_db
DB_DATABASE_SQLITE=/path/to/laravel/database/test_database.sqlite
テスト実行
すべてのテストを定義したので、実際に走らせてみます。
$ phpunit
==== Redirecting to composer installed version in vendor/phpunit ====
PHPUnit 7.5.13 by Sebastian Bergmann and contributors.
........ 11 / 11 (100%)
Time: 6.02 seconds, Memory: 26.00 MB
OK (11 tests, 19 assertions)
すべてのテストに合格した事を確認できました。
まとめ
以上で作業は終了です。 自動テストではデータベースの操作を伴う処理が一番時間がかかるので、実際にデータベースとやりとりを行う部分の機能のみテスト DB を使用し、 そうでない部分はモック化する事で結構な時間を削る事ができます。(規模によってリアルに分単位で違ってくる)
時間短縮も大事ですが、そもそもそのテストは何をテストしているのか?を考えると自ずと DB へのアクセスが本当に必要なのかが見えてくると思うので、 「そのテストケースのあるべき姿」は常に追求していきたいですね。