本記事は、サムザップ Advent Calendar 2020 #1 の12/13の記事です。
はじめに
タイトルがお堅いのですが内容は、
- 開発初期のプロジェクトなので、せっかくだから、ガッツリテストを書きましょう!
- テスト方針、規約を決めて、テストの品質を保ちましょう!
- サンプルコード
- 補足(Privateメソッドのテスト、モックの利用、Facatoryって素敵)
上記になります。
テストは、Laravel デフォルトの PHPUnit を使用します。
環境
- Laravel
$ php artisan --version
Laravel Framework 6.19.1
- php
$ php -v
PHP 7.4.11 (cli) (built: Oct 1 2020 19:44:09) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
with Zend OPcache v7.4.11, Copyright (c), by Zend Technologies
with Xdebug v2.9.8, Copyright (c) 2002-2020, by Derick Rethans
- PHPUnit
$ phpunit --version
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
テスト方針
テスト方針は、ざっくりですが、以下の2点としました。
- テストの実行時間を考慮して、テスト種別でモック(※1)を利用したテストとデータベース等にアクセスするテストと分割する。
- テストケースはメソッドごとに記述する。
※1 モックとは、
モックとは主にコード内のロジックを検証するために必要な「1. テスト条件を設定」するための手段です。
テスト対象が別クラスに依存している場合、テスト条件はそれらのインスタンスの振る舞いによって決まることが多いでしょう。モックは依存クラスの振る舞いをテスト時に、開発者が定義したものに「すり替える」ことを指します。
手軽なLaravelテストコード (3種類のモック手法) からの引用
テスト種別
Integration
Action(Controller)のテスト。データベースアクセスを伴う、APIの疎通。
HTTPステータスコード、APIのレスポンスの検証を行う。
Feature
Action(Controller)のテスト。データベースアクセスは行わず、モックを利用したテスト。
APIのリクエストのパラメータの検証。
Unit
Repository、Service(Facede)、Domain/Service(ビジネスロジック)のテスト。
Repositoryのテストはデータベースのアクセスを行う。それ以外はモックを利用したテスト。
メソッドの戻り値の検証。
※ 原則、全てのテスト種別で、正常系、異常系のテストを記述する。
サンプルコード
QiitaSample テーブルにアクセスする Repository の Unit テストのサンプルです。
QiitaSample Table
mysql> desc qiita_samples;
+------------+---------------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+------------+---------------------+------+-----+---------+-------+
| id | bigint(20) unsigned | NO | PRI | NULL | |
| user_id | bigint(20) unsigned | NO | UNI | NULL | |
| name | varchar(255) | NO | | NULL | |
| value | varchar(255) | NO | | NULL | |
| created_at | timestamp | YES | | NULL | |
| updated_at | timestamp | YES | | NULL | |
| deleted_at | timestamp | YES | | NULL | |
+------------+---------------------+------+-----+---------+-------+
7 rows in set (0.02 sec)
Eloquent Model
<?php
declare(strict_types = 1);
namespace App\Domain\Eloquent\User;
use App\Domain\Eloquent\BaseModel as Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class QiitaSample extends Model
{
use SoftDeletes;
public static $rules = [
'user_id' => 'required',
'name' => 'required',
'value' => 'required',
];
protected $fillable = [
'id',
'user_id',
'name',
'value',
];
protected $connection = 'qiita_sample';
protected $guarded = [];
protected $casts = [
'user_id' => 'integer',
'name' => 'string',
'value' => 'string',
];
}
Entity Model
<?php
declare(strict_types = 1);
namespace App\Domain\Entities\User;
use App\Domain\Entities\Base as Entity;
class QiitaSample extends Entity
{
protected $fillable = [
'id',
'user_id',
'name',
'value',
];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'name' => 'integer',
'value' => 'integer',
];
protected $dates = [];
protected $guarded = [];
protected $guarded_for_update = [
'id',
'user_id',
];
public function getId(): int
{
return $this->getAttribute('id');
}
public function getUserId(): int
{
return $this->getAttribute('user_id');
}
public function getName(): string
{
return $this->getAttribute('name');
}
public function getValue(): string
{
return $this->getAttribute('value');
}
}
Repository Interface
<?php
declare(strict_types = 1);
namespace App\Domain\RepositoryInterfaces\User;
interface QiitaSampleRepositoryInterface
{
/**
* @param array $user_id
*/
public function findByUserId(int $user_id);
/**
* @param array $user_ids
*/
public function getByUserIds(array $user_ids);
}
Repository
Repository の取得系のメソッドは基本的に Entity 、 Collection (要素は Entitiy )を戻り値としています。
<?php
declare(strict_types = 1);
namespace App\Infrastructures\Repositories\User;
use App\Domain\Eloquent\User\QiitaSample as QiitaSampleModel;
use App\Domain\Entities\User\QiitaSample as QiitaSampleEntity;
use App\Domain\RepositoryInterfaces\User\QiitaSampleRepositoryInterface;
use Illuminate\Support\Collection;
class QiitaSampleRepository implements QiitaSampleRepositoryInterface
{
/**
* @param int $user_id
*/
public function findByUserId(int $user_id): ?QiitaSampleEntity
{
$qiita_sample = QiitaSampleModel::where('user_id', $user_id)->first();
return QiitaSampleEntity::getInstance($qiita_sample);
}
/**
* @param array $user_ids
*/
public function getByUserIds(array $user_ids): Collection
{
$qiita_samples = QiitaSampleModel::whereIn('user_id', $user_ids)->get();
return $qiita_samples->map(fn ($qiita_sample) => QiitaSampleEntity::getInstance($qiita_sample));
}
}
Factory
データベースに挿入するテストデータを管理。
<?php
/** @var \Illuminate\Database\Eloquent\Factory $factory */
use App\Domain\Eloquent\User\QiitaSample as Model;
use Faker\Generator as Faker;
$factory->define(Model::class, function (Faker $faker) {
return [
'id' => 10002,
'user_id' => 1001,
'name' => '太郎',
'value' => 'QiitaSample太郎',
];
}, 'qiita_sample_1');
Test コード
Testのファイル名は、[テスト対象のファイル名]Test.phpとします。
今回は、 QiitaSampleRepository.php のテストなので、 QiitaSampleRepositoryTest.php とします。
基本的なテストコードのフォーマットは以下となります。(一部、苦肉の策もありますが)
<?php
declare(strict_types = 1);
namespace Tests\Unit\Infrastructures\Repositories\User;
use App\Domain\Eloquent\User\QiitaSample as QiitaSampleModel;
use App\Domain\Entities\User\QiitaSample as QiitaSampleEntity;
use App\Infrastructures\Repositories\User\QiitaSampleRepository;
use Tests\TestCase;
// テスト対象が QiitaSampleRepository なので、クラス名は QiitaSampleRepositoryTest します。
class QiitaSampleRepositoryTest extends TestCase
{
protected $repository;
public function setUp(): void
{
parent::setUp();
$this->repository = app(QiitaSampleRepository::class);
}
public function tearDown(): void
{
parent::tearDown();
}
// dataProviderを利用し、テストメソッドの引数は、$params, $expected, $message とします。
/**
* @dataProvider provider_test_findByUserId
* @param mixed $params
* @param mixed $expected
* @param mixed $message
*/
// 対象のメソッド名のプレフィックスに[test_]を付けたメソッド名にします。
public function test_findByUserId($params, $expected, $message): void
{
// 例外
if ($expected->call($this) instanceof \Throwable) {
$this->expectException(get_class($expected->call($this)));
}
// テストデータの挿入・削除をします。(parent::tearDown() で挿入したデータのtruncateをします。)
$this->bulkInsertIterable(QiitaSampleModel::class, $params['qiita_sample_factories']->call($this));
// テスト対象のメソッド
$result = $this->repository->findByUserId($params['user_id']);
// テスト結果の検証
$this->assertEquals($expected->call($this), $result, $message);
}
public function provider_test_findByUserId(): array
{
return [
// テストパターンを記述します。
[
'params' => [
'user_id' => 1001,
// テストデータの挿入の定義
'qiita_sample_factories' => fn () => [
factory(QiitaSampleModel::class, 'qiita_sample_1')->make(),
],
],
// 実行結果との比較
'expected' => fn () => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make()->toArray()),
// テスト内容
'message' => '正常: 取得できる',
],
[
'params' => [
'user_id' => 9999,
'qiita_sample_factories' => fn () => [],
],
'expected' => fn () => null,
'message' => '正常: ユーザが存在しない(user_id=9999)',
],
];
}
/**
* @dataProvider provider_test_getByUserIds
* @param mixed $params
* @param mixed $expected
* @param mixed $message
*/
public function test_getByUserIds($params, $expected, $message): void
{
if ($expected->call($this) instanceof \Throwable) {
$this->expectException(get_class($expected->call($this)));
}
$this->bulkInsertIterable(QiitaSampleModel::class, $params['qiita_sample_factories']->call($this));
$result = $this->repository->getByUserIds($params['user_ids']);
$this->assertEquals($expected->call($this), $result, $message);
}
public function provider_test_getByUserIds(): array
{
return [
[
'params' => [
'user_ids' => [
1001,
2001
],
'qiita_sample_factories' => fn () => array_map(
fn ($fake) => factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake),
[
[
'id' => 1,
'user_id' => 1001,
'name' => 'qiita1'
],
[
'id' => 2,
'user_id' => 2001,
'name' => 'qiita2'
],
],
),
],
'expected' => fn () => collect(array_map(
fn ($fake) => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake)),
[
[
'id' => 1,
'user_id' => 1001,
'name' => 'qiita1'
],
[
'id' => 2,
'user_id' => 2001,
'name' => 'qiita2'
],
],
)),
'message' => '正常: 2件取得できる',
],
[
'params' => [
'user_ids' => [
9999,
2001
],
'qiita_sample_factories' => fn () => array_map(
fn ($fake) => factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake),
[
[
'id' => 1,
'user_id' => 1001,
'name' => 'qiita1'
],
[
'id' => 2,
'user_id' => 2001,
'name' => 'qiita2'
],
],
),
],
'expected' => fn () => collect(array_map(
fn ($fake) => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake)),
[
[
'id' => 2,
'user_id' => 2001,
'name' => 'qiita2'
],
],
)),
'message' => '正常: 1件取得できる',
],
];
}
}
上記のテストを流した結果です。
$ phpunit tests/Unit/Infrastructures/Repositories/User/QiitaSampleRepositoryTest.php
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
.... 4 / 4 (100%)
Time: 8.53 seconds, Memory: 14.00 MB
OK (4 tests, 4 assertions)
補足
Private メソッドのテスト
QiitaSampleRepository.php に privete メソッドを追記します。
/**
* @param int $id
*/
private function privateSample(int $id): ?QiitaSampleEntity
{
$qiita_sample = QiitaSampleModel::find($id);
return QiitaSampleEntity::getInstance($qiita_sample);
}
QiitaSampleRepositoryTest.php に privete メソッドのテストを追記します。
/**
* @dataProvider provider_test_privateSample
* @param mixed $params
* @param mixed $expected
* @param mixed $message
*/
public function test_privateSample($params, $expected, $message): void
{
if ($expected->call($this) instanceof \Throwable) {
$this->expectException(get_class($expected->call($this)));
}
$this->bulkInsertIterable(QiitaSampleModel::class, $params['qiita_sample_factories']->call($this));
$repository = new \ReflectionClass('App\Infrastructures\Repositories\User\QiitaSampleRepository');
$function = $repository->getMethod('privateSample');
$function->setAccessible(true);
$result = $function->invokeArgs($this->repository, [
$params['id'],
]);
$this->assertEquals($expected->call($this), $result, $message);
}
public function provider_test_privateSample(): array
{
return [
[
'params' => [
'id' => 10002,
'qiita_sample_factories' => fn () => [
factory(QiitaSampleModel::class, 'qiita_sample_1')->make(),
],
],
'expected' => fn () => QiitaSampleEntity::getInstance(factory(QiitaSampleModel::class, 'qiita_sample_1')->make()->toArray()),
'message' => '正常: 取得できる',
],
[
'params' => [
'id' => 9999,
'qiita_sample_factories' => fn () => [],
],
'expected' => fn () => null,
'message' => '正常: idが存在しない(id=9999)',
],
];
}
テストを実行します。(filterで追記した private メソッドのテストだけ)
$ phpunit tests/Unit/Infrastructures/Repositories/User/QiitaSampleRepositoryTest.php --filter=test_privateSample
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 5.07 seconds, Memory: 12.00 MB
モックの利用
QiitaSampleRepository.php にメソッドを追記します。
public function getQiitaSample(): string
{
return 'QiitaSample';
}
Domain/Service を作成します。
<?php
declare(strict_types = 1);
namespace App\Domain\Services\User;
use App\Domain\RepositoryInterfaces\User\QiitaSampleRepositoryInterface;
class QiitaSampleService
{
protected QiitaSampleRepositoryInterface $qiita_sample;
/**
* @param QiitaSampleRepositoryInterface $qiita_sample
*/
public function __construct(
QiitaSampleRepositoryInterface $qiita_sample
) {
$this->qiita_sample = $qiita_sample;
}
public function getQiitaSample(): string
{
return $this->qiita_sample->getQiitaSample();
}
}
Domain/Service のテストコード書きます。
<?php
declare(strict_types = 1);
namespace Tests\Unit\Domain\Services\User;
use App\Domain\RepositoryInterfaces\User\QiitaSampleRepositoryInterface;
use App\Domain\Services\User\QiitaSampleService;
use Mockery;
use Tests\TestCase;
class QiitaSampleServiceTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
}
public function tearDown(): void
{
parent::tearDown();
Mockery::close();
}
/**
* @dataProvider provider_test_getQiitaSample
* @param mixed $params
* @param mixed $expected
* @param mixed $message
*/
public function test_getQiitaSample($params, $expected, $message): void
{
if ($expected->call($this) instanceof \Throwable) {
$this->expectException(get_class($expected->call($this)));
}
$this->mock(
QiitaSampleRepositoryInterface::class,
function ($mock) use ($params): void {
$mock->shouldReceive('getQiitaSample')->andReturn($params['mock']['QiitaSampleRepositoryInterface']['getQiitaSample']);
}
);
$service = app(QiitaSampleService::class);
$result = $service->getQiitaSample();
$this->assertEquals($expected->call($this), $result, $message);
}
public function provider_test_getQiitaSample(): array
{
return [
[
'params' => [
'mock' => [
'QiitaSampleRepositoryInterface' => [
'getQiitaSample' => 'QiiiiitaSample',
],
]
],
'expected' => fn () => 'QiiiiitaSample',
'message' => '正常: 取得できる',
],
];
}
}
実行結果は以下です。
$ phpunit tests/Unit/Domain/Services/User/QiitaSampleServiceTest.php
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 4.28 seconds, Memory: 12.00 MB
OK (1 test, 2 assertions)
Factory って素敵
- Factory に定義したデータを Eloquent Model で取得
$ php artisan tinker
Psy Shell v0.10.4 (PHP 7.4.11 — cli) by Justin Hileman
>>> use App\Domain\Eloquent\User\QiitaSample as QiitaSampleModel;
>>> factory(QiitaSampleModel::class, 'qiita_sample_1')->make();
=> App\Domain\Eloquent\User\QiitaSample {#3615
id: 10002,
user_id: 1001,
name: "太郎",
value: "QiitaSample太郎",
}
>>>
- Factory に定義したデータの”name”を変更して_Eloquent Model_ で取得
>>> factory(QiitaSampleModel::class, 'qiita_sample_1')->make(['name'=>'QiitaMan']);
=> App\Domain\Eloquent\User\QiitaSample {#3738
id: 10002,
user_id: 1001,
name: "QiitaMan",
value: "QiitaSample太郎",
}
>>>
- Factory を使って複数件の Eloquent Model で取得
>>> array_map(
... fn ($fake) => factory(QiitaSampleModel::class, 'qiita_sample_1')->make($fake),
... [
... [
... 'id' => 1,
... 'user_id' => 1,
... 'name' => 'qiita_1',
... 'value' => 'qiita--',
... ],
... [
... 'id' => 2,
... 'user_id' => 2,
... 'name' => 'qiita_2',
... 'value' => 'qiita-----n',
... ],
... ]
... );
=> [
App\Domain\Eloquent\User\QiitaSample {#3771
id: 1,
user_id: 1,
name: "qiita_1",
value: "qiita--",
},
App\Domain\Eloquent\User\QiitaSample {#3768
id: 2,
user_id: 2,
name: "qiita_2",
value: "qiita-----n",
},
]
>>>
おわりに
laravel でテスト駆動開発をするにあたり、開発メンバーと話し合いを行い、
テスト方針とテストコードの規約を決めていきました。
まだ、開発途中ということ(かつ Laravel 初心者の私)もあり、拙い部分も多いとは思いますが、
少しでも参考になれば幸いです。