はじめに
2021年12月末現在で在籍している会社の社内勉強会で「PHPUnitでのテストコード実装超入門ハンズオン」を開催したのでその内容について改めて記事に残します。
登壇資料、使用したGiHubリポジトリはこちら↓
なお、APIのテストコードに関するTipsやサンプルコードはQiitaのLaravel Advent Calendar 2021で投稿しました。

PHPUnitの公式Docはこちら。
Laravel学習のオススメな教材はこちらにまとめています。

環境構築
以下のリポジトリのREADME通りに構築。
Mac(Intel)、Windowsどちらにも対応しています。(M1の場合はDockerfileを少しいじる必要があると思います)
テスト対象メソッド
以下3つの処理のテストを書きます。
- ページ表示処理
- データ取得処理
- データ登録処理

Controller
コントローラーの内容は以下の通り、index(一覧画面表示用)とstore(データ登録用)を定義しています。
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\Memo\StoreRequest;
use App\Services\Memo\FetchService;
use App\Services\Memo\StoreService;
use Exception;
use Illuminate\Contracts\View\View;
class MemoController extends Controller
{
public $fetchService;
public $storeService;
public function __construct(FetchService $fetchService, StoreService $storeService)
{
$this->fetchService = $fetchService;
$this->storeService = $storeService;
}
/**
* メモ一覧ページ表示
* @return Illuminate\Contracts\View\View
*/
public function index(): View
{
try {
$memos = $this->fetchService->fetch();
} catch (Exception $e) {
echo "エラー:" . $e->getMessage();
}
return view('index', [
'memos' => $memos
]);
}
/**
* メモ登録
* @param App\Http\Requests\Memo\StoreRequest $request
* @return Illuminate\Contracts\View\View
*/
public function store(StoreRequest $request): View
{
$title = $request->title;
$body = $request->body;
try {
$this->storeService->store($title, $body);
$memos = $this->fetchService->fetch();
} catch (Exception $e) {
echo "エラー:" . $e->getMessage();
}
return view('index', [
'memos' => $memos
]);
}
}
Service
これくらいの規模であればControllerにビジネスロジックを書いてもFatにならないですが、DBからのデータ取得・登録処理だけをテストしたいのでServcice層に切り出しました。(このハンズオンではServiceのテストを単体テストという扱いにしています)
全件取得
<?php
declare(strict_types=1);
namespace App\Services\Memo;
use App\Models\Memo;
use Illuminate\Database\Eloquent\Collection;
class FetchService
{
/**
* メモを全件取得
* @return Illuminate\Database\Eloquent\Collection
*/
public function fetch(): Collection
{
return Memo::latest()->get();
}
}
新規登録
<?php
declare(strict_types=1);
namespace App\Services\Memo;
use App\Models\Memo;
class StoreService
{
/**
* メモをDBに保存
* @param string $title
* @param string $body
* @return void
*/
public function store(string $title, string $body): void
{
$memo = new Memo();
$memo->title = $title;
$memo->body = $body;
$memo->save();
}
}
Controller、ServiceのメソッドはLaravelのコードを書いたことがある方なら簡単に理解できる内容かと思います。
テストの準備
Laravelでテストを実行するためにデータベースを使う場合はテスト用のデータベースを準備する必要がありますが、phpunit.xml
の以下2行のコメントアウトを外してあげるだけインメモリのSQLiteを使えるようになります。
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
Docker環境でテストを実施する場合にテスト用のDBコンテナを準備するような説明をしている記事もありますが正直その必要は全くないかなと思っています。
テストコード実装(Controller)
まずはテストクラスを以下コマンドで作成します。
docker-compose exec app php artisan make:test MemoControllerTest
tests/Feature
ディレクトリにMemoControllerTest.php
が生成されます。
一覧画面に遷移する処理のテスト
まずは完成版のコードです。
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class MemoControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 「@test」をつけるとテストメソッドとして認識されるので日本語でテスト内容を記載できる
* @test
*/
public function index_一覧画面の表示に成功する(): void
{
// シーダーファイルの実行
$this->artisan('db:seed', ['--class' => 'MemoSeeder']);
// GETメソッドでパスにアクセス
$response = $this->get('/');
// ステータスコードとどの画面が表示されているかの検証
$response->assertStatus(200)
->assertViewIs('index');
}
}
各コードにコメントを入れているので理解しやすいと思います。
メモデータの準備はFactoryを使う方がベターなのかなと思ったのですが、簡易的にするために既存シーダーファイルを実行してテストデータをDBに登録しています。
記事投稿して一覧画面に遷移する処理のテスト
Controllerに定義したstoreメソッドのテストも実際には必要なのですが、登録画面(投稿画面)ではなく一覧画面に入力フォームを設けたことにより登録の成功/失敗に関係なく一覧画面のままなので本ハンズオンでは割愛します。
テストコード実装(Service)
まずはテストクラスを以下コマンドで作成します。
docker-compose exec app php artisan make:test FetchServiceTest --unit
docker-compose exec app php artisan make:test StoreServiceTest --unit
このコマンドだとtests/Unit
配下にFetchServiceTest.php
とStoreServiceTest.php
ができますが、実際はtests/Unit/Memo
配下に作成するのが好ましいと思います。(何のFetch?何のStore?と思うので)
データを全件取得する処理のテスト
データ取得処理のテストはこのように書いています。
<?php
namespace Tests\Unit;
use App\Services\Memo\FetchService;
use Illuminate\Foundation\Testing\RefreshDatabase;
// use PHPUnit\Framework\TestCase;
// スーパークラスを書き換える
use Tests\TestCase;
class FetchServiceTest extends TestCase
{
use RefreshDatabase;
/**
* @test
*/
public function fetch_メモを全件取得した時の個数が6つである(): void
{
// シーダーファイルの実行
$this->artisan('db:seed', ['--class' => 'MemoSeeder']);
// サービスクラスのインスタンス生成
$service = new FetchService();
// サービスクラスのメソッド実行
$response = $service->fetch();
$this->assertSame(6, count($response->toArray()));
}
/**
* @test
*/
public function fetch_メモを全件取得した時レスポンスが要件通りである(): void
{
// シーダーファイルの実行
$this->artisan('db:seed', ['--class' => 'MemoSeeder']);
// サービスクラスのインスタンス生成
$service = new FetchService();
// サービスクラスのメソッド実行
$response = $service->fetch();
$this->assertSame([
'id',
'title',
'body',
'created_at',
'updated_at'
], array_keys($response[0]->toArray()));
}
}
コマンドに--unit
オプションをつけてテストクラスを生成した場合、テストクラスのスーパークラスはは以下の通りPHPUnit\Framework\TestCase
になっています。
use PHPUnit\Framework\TestCase;
今回は取得するデータの準備にはControllerのテスト(結合テスト/Featureテスト)同様、シーダーファイルの実行(artisanコマンド)を使うので、use Tests\TestCase;
に書き換えています。書き換える前だと成功するテストコードを書いたとしてもエラーになります。
FetchServiceのテストケースを
- 取得したデータの個数
- 取得したデータの中身
としていますが、本来のメモアプリのメモはユーザーによって増減するので個数をテストする必要はないかもしれません。ただ、テスト用に特定の個数のテストデータを準備してそれが全件取得できているかのテストであればこのテストはそこまでおかしくないと思ってきました。(この辺のテストケースの洗い出しは僕の探り探りの身です…)
fetch_メモを全件取得した時の個数が6つである
/**
* @test
*/
public function fetch_メモを全件取得した時の個数が6つである(): void
{
// シーダーファイルの実行
$this->artisan('db:seed', ['--class' => 'MemoSeeder']);
// サービスクラスのインスタンス生成
$service = new FetchService();
// サービスクラスのメソッド実行
$response = $service->fetch();
$this->assertSame(6, count($response->toArray()));
}
まず、シーダーファイルを実行してメモデータをテスト用のDBに登録します。
サービスのテストをするのでサービスのメソッドを使うためにサービスクラスのインスタンスを生成します。
// サービスクラスのインスタンス生成
$service = new FetchService();
インスタンスを生成したらfetchメソッドを実行します。
// サービスクラスのメソッド実行
$response = $service->fetch();
fetch
メソッド自体は以下の通り、memosテーブルからデータを全件取得してCollectionを返却するだけなので上記$response
にはMemoクラスインスタンスのCollectionが入ります。
/**
* メモを全件取得
* @return Illuminate\Database\Eloquent\Collection
*/
public function fetch(): Collection
{
return Memo::latest()->get();
}
個数の検証なのでtoArray()
でCollectionを配列に変換して、count()
で要素の個数を取り出してassertSame()
で検証します。
$this->assertSame(6, count($response->toArray()));
fetch_メモを全件取得した時レスポンスが要件通りである
/**
* @test
*/
public function fetch_メモを全件取得した時レスポンスが要件通りである(): void
{
// シーダーファイルの実行
$this->artisan('db:seed', ['--class' => 'MemoSeeder']);
// サービスクラスのインスタンス生成
$service = new FetchService();
// サービスクラスのメソッド実行
$response = $service->fetch();
$this->assertSame([
'id',
'title',
'body',
'created_at',
'updated_at'
], array_keys($response[0]->toArray()));
}
シーダーファイル実行→サービスクラスのインスタンス生成→メソッド実行して変数にCollection格納、までは先ほどのテストメソッドも全く同じです。
このメソッドで検証することは取得したデータの中身なので、以下のように$response
の1つのインスタンスを配列に変換して、配列のキーが指定したものと同じかどうかの検証を行っています。
$this->assertSame([
'id',
'title',
'body',
'created_at',
'updated_at'
], array_keys($response[0]->toArray()));
「キーだけでいいの?値は?」と思われるかもしれませんが、確かに値も検証条件に入れても問題ないと思います。今回は超入門という言葉に甘えて簡易的な検証にしています。
少し長くなりましたがこれで「データを全件取得する処理のテスト」は終わりです。
データを1件DBに新規登録する処理のテスト
完成版のテストコードはこちら。
<?php
namespace Tests\Unit;
use App\Services\Memo\StoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
// use PHPUnit\Framework\TestCase;
use Tests\TestCase;
class StoreServiceTest extends TestCase
{
use RefreshDatabase;
/**
* @test
*/
public function store_メモを1件memosテーブルに登録する(): void
{
$title = 'サンプルタイトル';
$body = 'サンプル内容';
$service = new StoreService();
$service->store($title, $body);
$this->assertDatabaseHas('memos', [
'title' => $title,
'body' => $body
]);
}
}
データ登録のテストに関しても基本的な流れは同じですが、事前にDBにデータが存在する必要がないのでシーダーファイルもFactoryも使わず、インスタンス生成→メソッド実行を行います。
artisanコマンドもFactoryも使っていませんが、FetchServiceTest
同様、テストクラスのスーパークラスを書き換えています。
// use PHPUnit\Framework\TestCase;
use Tests\TestCase;
これは最後の検証で使うassertDatabaseHas()
がPHPUnitが用意している機能ではなく、Laravelが用意したPHPUnitでのテスト用のメソッドだからです。
Laravelの公式ドキュメントにも以下のように書かれています。
Laravel provides several database assertions for your PHPUnit feature tests.
https://laravel.com/docs/8.x/database-testing#available-assertions
Laravel には、PHPUnit の機能テスト用にいくつかのデータベースアサーションが用意されています。(

逆にPHPUnitの公式ドキュメントには存在しません。
store
メソッドは以下のとおり、引数を2つとるのでテストメソッド内のstore
メソッドも2つの引数(タイトルとボディー)を渡しています。
/**
* メモをDBに保存
* @param string $title
* @param string $body
* @return void
*/
public function store(string $title, string $body): void
{
$memo = new Memo();
$memo->title = $title;
$memo->body = $body;
$memo->save();
}
データが登録されているかどうかの検証にはassertDatabaseHas()
を使います。第一引数に検証対象のテーブル名、第二引数に登録されているかチェックするデータを配列で渡してあげます。
$this->assertDatabaseHas('memos', [
'title' => $title,
'body' => $body
]);
memos
テーブルのカラムにはtitle
、body
以外にもid
、created_at
、updated_at
がありますが、assertDatabaseHas()
では全てのカラムを検証する必要はないです。
以下のように全てのカラムを検証しても問題なくテストは通ります。
$this->assertDatabaseHas('memos', [
'id' => 1,
'title' => $title,
'body' => $body,
'created_at' => now(),
'updated_at' => now()
]);
最後に
ここまでハンズオンで進めたらPHPUnitの基本的な書き方は理解できると思います。
PHPUnitでのテストコードの書き方は初めは難しく感じるかもしれませんが、慣れるとメソッドを1つずつ再現していく感じで楽しさがあります。(個人差あり)
テストコードはアプリケーションを保守・運用していく上では大事なものなので今後もテストコードの上手な書き方を探っていきたい所存です。
なお、この記事でも自分が現在携わっているPJでも
Routing→Controller→Service→Model
の流れで処理をしているので
- Featureテスト:Controllerのテスト
- Unitテスト:Serviceのテスト
という棲み分けにしています。
ただ、Featureテスト(結合テスト)とUnitテスト(単体テスト)の線引きについては人によって変わる(?)こともあるようで、僕自身もう少し深掘りしたいなと思っています。(以下ツイート参照)
結論:テストは奥が深い!
コメント