RitoLabo

LaravelとPHPUnit-データアクセスをモック化してHTTPテスト&ユニットテストを効率化する-

  • 公開:
  • 更新:
  • カテゴリ: PHP Laravel
  • タグ: PHP,Laravel,Factory,PHPUnit,Mockery

PHPでWEBアプリケーション開発を行う場合の自動テストのツールとしてPHPUnitが有名ですが、 Laravelにはデフォルトで組込まれており、インストールしてすぐにでも使用する事が出来ます。

今回は、LaravelとPHPUnitを使ってテストを書きつつ、データアクセス部分のモック化を行いながらHTTPテストやユニットテストの効率化を行っていきます。

アジェンダ
  1. 開発環境
  2. HTTPテストとユニットテスト
  3. アプリケーションの構成
  4. HTTPテスト
    1. Factory定義
    2. コントローラのテスト
  5. ユニットテスト
    1. サービスクラスのテスト
    2. リポジトリのテスト
  6. テスト実行

開発環境

今回の開発環境は以下の通りです。

  • 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);

リポジトリへのデータアクセスをモック化しています。

  1. データアクセスに対するモックを作成
  2. リポジトリのgetList()メソッド要求に対して、BookListの疑似データを返す様に指定
  3. データアクセスに対してモック化を実行

これでデータアクセス部分のモック化が実行されます。

次に各テストケースですが、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へのアクセスが本当に必要なのかが見えてくると思うので、 「そのテストケースのあるべき姿」は常に追求していきたいですね。

サンプルコード