【PHPunit】やらかしがち!?PHPunitテスト実装ミス3選

2021/10/23

はじめまして
ヨコイと申します。

今回はPHPunitに関して書こうと思います。
なぜかテストが通らない....実装が間違えているのだろうか....???いや、そもそもテストが間違えていた。
そんなケースを少しでも減らしていきたいと考え、今回筆を取りました 。

私がよくやらかすPHPunit設計ミス3選

ケース① リレーション漏れ

public function up()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->unsignedBigInteger('administrator_id');
            $table->timestamps();

            $table->foreign('administrator_id')->references('id')->on('users');
        });
    }

上記のmigrationファイルからproductsテーブルを作成し、

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
use App\User;
use App\Product;

class ProductCreateApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();
    }

    /**
     * A basic feature test example.
     * @test
     * @return void
     */
    public function should_product_create()
    {
        $data = [
            'name' => 'SmartProduct',
            'administrator_id' => 1
        ];
        $response = $this->json('POST', route('product.create'), $data);
        $product = Product::first();
        $this->assertEquals($data['name'], $product->name);

        $response->assertStatus(201)->assertJson(['name' => $product->name]);
    }
}

上記のようなテストを書きました。ProductControllerのコードは省略しますが、fill()'name''administrator_id'をインサートする基本的なコードです。'administrator_id'Auth::id()ではなく、フォームからjsonで取得する想定です。

なんてことはない難しくないコードのはずなんですが、このテストは必ず失敗します。
理由はリレーションが紐づいているからです。Userテーブルにレコードが存在していないのに、ユーザーにリレーションが設定されています。
冷静になって考えてみれば、辻褄があっていないことがわかるのですが、コードを書いているときはこの前提が頭から抜けがちです。(おい)
この問題を解決するためには下記のようにfactoryで事前にユーザーレコードを作成する必要があります。

public function setUp(): void
    {
        parent::setUp();
        $this->user = factory(User::class)->create();
    }

本当になんてことはないのですが、陥りがちなポイントだと感じていますのでリレーションをはる必要がある場合はこの点に気をつけてみると幸せになれるのではないでしょうか?

ケース② ページネーションの考慮漏れ

2つ目のケースはページネーションを設定しているテーブルをまとめて取り出す場合です。

public function should_product_page_list()
    {
        $product_count = 24;
        $listMaximumValue = 12;
        $pageMaximumValue = 2;
        $products = factory(Product::class,$product_count)->create();
        $response = $this->json('GET', route('proposition.list'));

        $response->assertStatus(200)->assertJson($products);
    }

もちろんこのコードも失敗します。
なぜかはもう、おわかりですね?

そうです。paginateしたテーブルのレスポンスはページネーションのためのパラメーターが付与されます。そのため本来はArrayとして取得できるデータはdataとして取得することになります。さきほどのコードでは

$response->assertStatus(200)->assertJson($products,'data');

とする必要があったのです。
これもEloquentの挙動をきちんと理解していればまず遭遇しないエラーだと思います。
なにごともしっかり理解するということは大事ですね。

ケース③ インクリメントの罠

最後のケースになりますが、これはおそらくバグ?なのではないかと思うのですが、
Laravelでテストをする場合おそらく多くの人が本番のデータベースとテスト用のデータベースを分離するかと思います。
テストコードにはRefreshDatabaseというものがあり、こちらを使うとデータベースを初期化して次のテストの際に影響がでないようにできるのですが、なんと自動増分のID番号だけは初期化されません。レコードはちゃんと初期化されるのに
!です。
つまり下記のようなコードは2回目以降失敗します。

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
use App\User;
use App\Product;

class ProductCreateApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();
        $this->user = factory(User::class)->create();
    }

    /**
     * A basic feature test example.
     * @test
     * @return void
     */
    public function should_product_create()
    {
        $data = [
            'name' => 'SmartProduct',
            'administrator_id' => 1
        ];
        $response = $this->json('POST', route('product.create'), $data);
        $product = Product::first();
        $this->assertEquals($data['name'], $product->name);

        $response->assertStatus(201)->assertJson(['name' => $product->name]);
    }
}

1回目のケースの間違っているやつだからでは?いえ違います。きちんとsetUp()でユーザーレコードを作成しています。
このテスト最初の1回だけであればおそらく成功するかもしれません。しかし2回目以降はかならず失敗します。

それは

 'administrator_id' => 1

こうなっているからです。
自動増分のIDは初期化されないため、テストを実行するたびにIDが変わってしまうため、このように直接数字を埋め込んでしまうと失敗してしまうのです。(ちなみにこのように数字を直接打ち込むことをマジックナンバーといい、アンチパターンなのですが、今回は割愛します。)

そのため下記のようにするべきです。

 public function should_product_create()
    {
        $userId = User::first()->id;
        $data = [
            'name' => 'SmartProduct',
            'administrator_id' => $userId
        ];
        $response = $this->json('POST', route('product.create'), $data);
        $product = Product::first();
        $this->assertEquals($data['name'], $product->name);

        $response->assertStatus(201)->assertJson(['name' => $product->name]);
    }

こうすることで確実に存在するレコードのIDが取得できます。

まとめ

いかがでしたでしょうか?参考になりますでしょうか
思った以上に分量が多くなってしまったような気も致しますが、ためになれば幸いです。
テストコードの品質の担保はアプリケーションの品質につながります。バグが起きてしまうこと自体はしょうがないことだとは思いますが、きちんとそのバグを潰しアプリケーションの品質を上げていきたいですね。