お疲れ様です。GMOインサイトの天河です。
天河は2024年1月をもって、GMOアドマーケティング株式会社からGMOインサイト株式会社に転籍となり、michill という女性向けウェブメディアの開発を担当していました。
michill by GMO
天河は転籍後の3ヶ月間のタスクの中で、michillの自動テスト/ユニットテストを実装してきました。200以上のメソッドのテストを実装し、プロダクト品質向上と、問題点の発見 / 改善に貢献することができました。
この記事では、Laravelのテストを実装する中で「このテスト、どうやって書いたらいいんだ?」と苦労したケースの解決方法を紹介したいと思います!
- 「リレーションを持つデータベースに関するテスト」
- 「データを偽装する必要があるテスト(依存性の注入 / DI)」
の実装方法がメインになります。上記二つはなかなか情報が無く苦労したので、同じ状況で困っている皆さんのお役に立てれば、と思います。
Laravel8を使用していますが、Laravel10, Laravel11 でも有用な内容なので、ぜひ見ていってください。
(なぜこの方法でできるか、の深掘りはそんなにしません。)
【使用言語】 Laravel 8.x, PHP 8.1(Laravel 10にしたい🥺) |
想定ケース
簡単なブログサイトを想定して説明します。Articleテーブルをベースにテーブル間リレーションを確認しましょう。
Databases
- Article:ブログ記事(基準)
- Category:ブログカテゴリ(親)
- User:ブログ執筆者(子)
- ArticleKeyword:ブログキーワード(親)
Article.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<?php namespace App; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; // Factoryを使うために必要 class Article extends Model { use HasFactory; protected $table = 'articles'; protected $connection = 'mysql'; public function author() { return $this->belongsTo('App\User', 'user_id', 'id'); } public function category() { return $this->hasOne('App\Category', 'id', 'category_id'); } public function article_keyword() { return $this->hasMany('App\ArticleKeyword', 'article_id', 'id'); } } |
Category.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<?php namespace App; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; class Category extends Model { use HasFactory; protected $table = 'categories'; protected $connection = 'mysql'; ...(省略) public function article() { return $this->belongsTo('App\Category', 'id', 'category_id'); } } |
ArticleKeyword.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<?php namespace App; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; class ArticleKeyword extends Model { use HasFactory; protected $table = 'article_keywords'; protected $connection = 'mysql'; public function article() { return $this->belongsTo('App\Article', 'article_id', 'id'); } } |
その他条件
- hasOneの外部キーが、
Category
のIDになっています。 - Seederは(個人的に)可読性が悪いという思想の元、使用していません。各テストケース内で必要となるデータは各テストケース内で作るほうが、可読性が高いと思っています。
- 「技法」と銘打ってますが、そんな大層な方法は別にないです。
以上のケースにて直面するケースを説明します。
データベーステスト
hasManyを持つテストデータを作りたい
今回で言う、Article
と ArticleKeyword
間リレーションですね。
以下のArticleFactory
, ArticleKeywordFactory
を用意します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class ArticleFactory extends Factory { protected $model = Article::class; public function definition() { ... // 特に注意して書くことはなし } } class ArticleKeywordFactory extends Factory { protected $model = ArticleKeyword::class; public function definition() { return [ 'article_id' => Article::factory(), 'keyword' => $this->faker->unique()->word, ]; } } |
基本、belongsTo や morphTo 側(親テーブル)である、ArticleKeyword
の外部キーに
'article_id' => Article::factory()
と、Factoryインスタンスを記述しましょう。子テーブル側は設定する必要はありません。
このhasMany関係を持つデータは、以下のようにして作ります。
1 2 3 4 5 6 7 8 |
$articles = Article::factory()->count(10)->has( ArticleKeyword::factory()->count(3), "article_keyword" ); dd(count(Article::all()->toArray())); # => 10 dd(count(ArticleKeyword::all()->toArray())); # => 30 |
こうすると、hasMany関係を持つデータを作成できます。ちなみに以下の件数データが作成されます。
hasManyは hasで定義しないと、belongsTo側のデータは作成されません。
1 2 3 4 |
$articles = Article::factory()->count(10); dd(count(ArticleKeyword::all()->toArray())); # => 0 |
外部キーが primaryキーであるhasOneを持つテストデータを作りたい
今回で言う、Article
と Category
のリレーションですね。
以下のArticleFactory
, CategoryFactory
を用意します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class CategoryFactory extends Factory { protected $model = Category::class; public function definition() { return [ // ...モロモロ ]; } } class ArticleFactory extends Factory { protected $model = Article::class; public function definition() { return [ 'category_id' => Category::factory()->state([ 'id' => $this->faker->unique()->numberBetween(1, 100) ]), ]; } } |
Eloquentは、外部キーの値が親の主キーカラムに一致すると想定しています(ここで言うと category_id
)。しかし今回は外部キーが id
です。
先ほど「基本、belongsTo や morphTo 側(親テーブル)の外部キーにFactoryインスタンスを作成する」と書いていましたが、このケースでは、子テーブル側にFactoryインスタンスを作成しましょう。
この場合のhasOne関係は、以下のように作ります。色々作れるパターンがあります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# OK $article = Article::factory()->has(Category::factory()->state([ 'name' => 'カテゴリ1', ]))->create(); dd($article->first()->category->name); # => カテゴリ1 # => has , category_id 未定義 # OK $article = Article::factory()->has(Category::factory()->count(1)->state([ 'name' => 'カテゴリ1', ]), 'category')->state([ 'category_id' => 1, ])->create(); dd($article->first()->category->name); # => カテゴリ1 # => has , category_id 定義 # NG $article = Article::factory()->has(Category::factory()->count(1))->state([ 'name' => 'カテゴリ1', ])->create(); dd($article->first()->category->name); # => Duplicate entry # => hasとFactoryのインスタンスが同時に作られるせいか、重複idになる # NG $article = Article::factory()->state([ 'category_id' => 1, 'name' => 'カテゴリ1', ])->create(); dd($article->first()->category->name); # => null # => category_id とFactoryで作られる Category のidが一致しないため、リレーションが設定されない |
このケースは has を使わなくても、Factory の方で設定していれば、リレーションが設定されたデータが取得できます。しかし、has と <xxx>_id
の片方だけ設定すると、 リレーションが設定されたデータが取得できません。
メソッドインジェクション
リクエストを偽装したい
メソッドインジェクションを利用します。テスト対象のメソッド内に Request::xxx()
系の処理があった時、以下のように対応します。
Request::root
Request::root()
と request()->root()
の出力は一緒です。
1 2 3 4 5 |
$this->assertEquals( request()->root() . "/sample", Request::root() . "/sample" ); # => OK |
Request::get
リクエスト偽装の方法として、Request::create
を使用します。create するだけでなく、作成したrequestインスタンスをバインドし直して、インスタンスを偽装します。巷ではこれを「メソッドインジェクション」と言うみたいですね。
1 2 3 4 |
$request = Request::create('/', 'GET', ['page' => 3]); $this->app->instance('request', $request); $this->assertEquals(3, Request::get('page')); # => OK |
以下のような「クエリパラメータの値を参考に前後ページへのリンクタグを生成する」と言ったメソッドのテストに利用できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
function get_pager_link() { $tag = ''; $current_index = (int)Request::get('page') ?: 1; if ($current_index > 1) { $tag .= '<link href="'. Request::url() .'?page='. ($current_index - 1) . '" />'. PHP_EOL; } if ($paginate_object->lastPage() > $current_index) { $tag .= '<link href="'. Request::url() .'?page='. ($current_index + 1) . '" />'; } return $tag; } public function test_get_pager_link__ページネーションタグ生成テスト() { $request = Request::create('/', 'GET', ['page' => 1]); $this->app->instance('request', $request); $expectedResult = '<link href="' . Request::root() . '?page=2" />'; $this->assertEquals($expectedResult, get_pager_link()); # => OK } |
Request::server
こちらもメソッドインジェクションを利用しましょう。
1 2 3 4 5 6 7 8 9 |
$request = Request::create('/', 'GET', [], [], [], [ 'HTTP_USER_AGENT' => "Mozilla/5.0 (Linux; Android 6.0.1;..." ]); $this->app->instance('request', $request); $this->assetEquals( "Mozilla/5.0 (Linux; Android 6.0.1;...", Request::server('HTTP_USER_AGENT') ); # => OK |
Request::query
こちらもメソッドインジェクションを利用しましょう。
1 2 3 4 5 |
# Request::query('utm_source') $request = Request::create('/', 'GET', ['news' => "smartnews"]); $this->app->instance('request', $request); $this->assertEquals("smartnews", Request::query('utm_source')); # => OK |
Request::ip
IPアドレスの偽装は、REMOTE_ADDR
パラメータに値を入れます。
1 2 3 4 5 |
# Request::ip() $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '182.161.76.40']); $this->app->instance('request', $request); $this->assertEquals('182.161.76.40', Request::ip()); # => OK |
ページネーションインスタンスを偽装したい
LengthAwarePaginator
を利用します。
1 2 3 4 5 6 7 8 9 10 11 |
// ページネーションオブジェクトの作成 $totalItems = 50; // 総アイテム数 $itemsPerPage = 10; // ページあたりのアイテム数 $currentPage = 1; // ページネーションの最初ページでのテスト $paginateObject = new LengthAwarePaginator([], $totalItems, $itemsPerPage, $currentPage, [ 'path' => 'http://michill.gmo-insight.local', 'pageName' => 'page' ]); |
Cookieの値を偽装する
今回紹介するユースケースとしては、
テストしたいCookie処理を含むメソッドがサービスファイルに存在しており、そのメソッド使用しているcontrollerのURLを呼ぶと色々副作用がありそうで面倒くさそうなので、そのメソッド単体を呼び出せるようにしてテストしたい
というケースです。
Cookie関連の処理を含むメソッドは、Cookieの特性上、URLリクエストを通してテストする必要があります。
この方法で取得されるCookieは暗号化されているので、暗号化処理のミドルウェアを無効にします。use
で WithoutMiddleware
をインポートすると他のミドルウェアも無効になってしまうので、以下の記述を対象のテストケースの先頭に書きます。
1 |
$this->WithoutMiddleware(\App\Http\Middleware\EncryptCookies::class); |
テストしたいメソッドは以下です。Cookie::queue()
メソッドが含まれている処理です。
1 2 3 4 5 6 7 8 9 10 11 12 |
# Services\Article\ArticleService.php public static function refresh_cookie () { $cookie = Cookie::get("cookie1"); if ($cookie) { return $cookie; } $cookieList = ['A', 'B', 'C']; $newCookie = $cookieList[array_rand($cookieList)]; Cookie::queue("test", $newCookie, '30'); return $newCookie; } |
次に、対象メソッドを呼び出すテスト用のパスを作っちゃいましょう。
1 2 3 4 5 6 |
# routes/web.php Route::get('/test-cookie', function () { $newCookie = ArticleService::refresh_cookie(); $cookie = cookie('cookie1', $newCookie, 60); return response('Cookie has been set')->cookie($cookie); }); |
Cookie::queue()
は、リクエストの終了後にクッキーを設定する非同期関数なので、リクエストを走らせてレスポンスを取得する必要があります。そのために、テストリクエスト用のパスを作る必要があると言うことですね。そしてレスポンスにメソッドで取得したクッキーをセットする必要もあります。
こうして環境構築をして、出来上がったテストコードは以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 |
public function test_refresh_cookie__クッキーが設定されているか(): void { // Cookieの暗号化を無効にする $this->WithoutMiddleware(\App\Http\Middleware\EncryptCookies::class); $cookies = ['cookie1' => null]; $response = $this->call('GET', '/test-aaa', $cookies); $cookie = $response->headers->getCookies()[0]; $this->assertContains($cookie->getValue(), ["A", "B", "C"]); # -> OK } |
ところで、公式のCookie偽装方法は、withCookie
, withCookies
を使用してGETリクエストを送信しています。
1 |
$response = $this->withCookie('cookie1', 'A')->get('/'); |
しかしこのメソッドではミドルウェアを無効にしても暗号化がされたものが取得されます。なので以下をミドルウェアの無効化の記述の下に挿入します。
1 2 |
$this->WithoutMiddleware(\App\Http\Middleware\EncryptCookies::class); $this->disableCookieEncryption(); |
この場合の完成系は以下になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public function test_refresh_cookie__クッキーが設定されているか(): void { // Cookieの暗号化を無効にする $this->WithoutMiddleware(\App\Http\Middleware\EncryptCookies::class); $this->disableCookieEncryption(); $cookies = ['cookie1' => null]; $response = $this->withCookies($cookies)->get('/test-cookie'); $cookie = $response->headers->getCookies()[0]; $this->assertContains($cookie->getValue(), ["A", "B", "C"]); # -> OK } |
Cookieをランダムに返しているため、assertionは assertContains
を利用していますが、取得できるCookieが自明の場合なら、assertCookie
や assertPlainCookie
でアサーションしましょう。
こうしてCookieの処理を持つ関数を他に影響を及ぼすことなく / 考慮することなく、テストが出来ます。
おわりに
上で紹介したテスト方法はあまり情報がなく、実装に苦労したものをピックアップしました。
同じ状況に直面している方のお役に立てられたら幸いです。
役に立ったと思う方ははてブ、おかしい箇所があったり、そもそも内容が良くなかったと思う方は罵倒コメントください!
参考文献
Readouble / Laravel 8.x データベーステスト
Readouble / Laravel 8.x HTTPテスト
→ Laravel8ですが、最新のLaravelでも内容はあまり変わりません