every Tech Blog

株式会社エブリーのTech Blogです。

初めて経験したLaravel、Pestを利用した単体テストで感じたこと

はじめに

この記事は every Tech Blog Advent Calendar 2024 の8日目の記事です。

こんにちは、リテールハブ開発部でバックエンドエンジニアをしています。
実はまだ転職して2ヶ月のため、まだまだわからないことだらけですが、
現在、Laravelを利用したAPI開発をしていて、その中でPestを利用した単体テストを行なっています。

前職のAPIテストは結合テストメインで行っていて、単体テストはほとんど行なっていなかったのですが、 検証データの準備や継続的なテストにおいて課題がありました。
今回LaravelもPestも初めて利用するのですが、今回行った単体テストを経験する中で、 これを利用すれば以前感じていた課題が解決できるかもしれないと思いました。

本ブログでは、課題解決できると思った点や実際に単体テストを経験して感じたことなどをお伝えできればと思っています。 同じ課題感をお持ちの方やテストに興味のある方はぜひ読んでいただけると嬉しいです。

今回はLaravel、Pestを例にしたお話にはなりますが、他のフレームワークでも同様のケースは多いのかなと思っています。

前職で行っていたAPIテストで感じていた課題

私が今まで行っていたAPIのテストは基本的にはDBにテストデータを投入し、
データと各パラメーターによって変わるAPI結果を検証する結合テストがメインでした。
できる限りのパターンを網羅し、各ロジックが検証できるようデータ準備をしていたりもしました。 その後はSeleniumなどを使用してテストパターンを登録して自動テストなども行なっていました。

しかし、以下のような課題がありました。

「データ整備の課題」

  • 検証用のデータをDBにあらかじめ入れないと検証できない
  • DBデータの最新化や別件の対応などで内容が変わったりと検証データの担保がしにくい
  • 検証データが不足していて意図した結果が得られていないケースがある
  • 外部要因による結果を利用するテストが行いにくい

「検証データの信頼性の課題」

  • 自動テストの実行でエラーが発生した場合、検証データによるものかロジックによるものかの特定に時間がかかる

上記課題は、DBデータを固定化したり、検証用DBを別途作ったりして回避しようとしていましたが、 やはりそれだけではデータパターンの担保や変更されないことの保証はうまくできていないことがありました。 特にデータ担当が別部署だったりするとなお難しい状況でした。

Laravel、Pestを利用したテストについて

LaravelやPest以外でも同じような機能はあると思いますが、
今回のテストでは、LaravelのModel、Factoryを利用して、コード記述によって任意のテストデータを作成(DB投入)し、 Pestを使って各テストケース毎に独立した検証ができるテストを経験しました。

簡単ではありますが、単体テストのメリットとデメリットを記載してみました。

メリット

  • メソッド単位で1つ1つ検証することができる
  • ケース毎に独立したテストができるため、環境やデータ状況に左右されない
  • Mockを利用して仮想的な結果を混ぜることで、データ整備だけでは難しいケースにも記述次第で対応できる

デメリット

  • テストケースが増えるほどコストも増加する
  • 機能仕様の変更などをすると単体テストにも影響が大きくあり変更負荷が高い

当然ではありますが、今回行ったような単体テストは、1ケースずつ細かく行うため工数はどうしてもかかるなと思いました。
様々なケースを考えながら記述もするので単純作業というわけでもなく、 そもそもテストコードが間違えていたら意味もないため、手早くこなすのも厳しそうです。 また、最終的にはAPIの機能検証も別途必要なため、複合して行う必要があります。

課題が解決できると思った点

上記だけでは、やはり工数の問題などで優先度を下げて対応してしまいそうではありますが、 今回自分の中で非常に使えそうと思ったのは、

  • 指定のテーブルに任意のデータをコード記述により固定で入れられ、検証したいデータパターンを柔軟に記述することができる点
  • 簡単にテストデータを作る仕組みがあり、複数データ作成の手間も省ける点
  • 1テストケース毎に他のデータの影響を与えず独立して実行することができる点

かなと思いました。

簡単な例ですが、以下の要件に合わせたPestのサンプルコードがあります。

  • getListメソッドのテストを行いたい
  • APIリクエストからのパラメーターはidのみ
  • status引数の値によって分岐があり結果が変わる
  • statusはAPIのリクエストからは指定できず、外部要因によって決定される

コードの記述のみでデータ投入、メソッド実行、結果比較を行うことができます。

Pest記述のサンプルコード

<?php

namespace Tests\Unit\Sample;

// ここにTest前の処理を記述
beforeEach(function () {
    $this->sample = new Sample();
});

// ここにTest後の処理を記述
afterEach(function () {
    
});

// 1テストケースずつ以下の形式で作成していく
it('指定IDとステータスがokの場合一覧を検索できること。', function () { 
    // 検索したいパラメーター
    $id = 1;
    $status= 'ok'; // statusはAPIのパラメータからは指定できない

    // 任意のテーブルにデータを入れる機能
    // この記述によりデータがテストケース毎に固定できる。
    SampleTable::factory()->create(
        [
            'id' => 1, // primary key
            'status' => 'ok',
            'name' => 'サンプル1',
        ]
    );
    SampleTable::factory()->create(
        [
            'id' => 2, // primary key
            'status' => 'ok',
            'name' => 'サンプル2',
        ]
    );

    // id, statusから一覧を取得するメソッドのテスト
    $result = $this->sample->getList($id, $status);

    // 返却内容は以下の記述でチェックできます。

    // 結果が何件返ってきているかをチェック
    expect($result->count())->toBe(2);

    // 結果のnameが正しいかチェック
    expect($result[0]['name'])->toBe('サンプル1'); 
    expect($result[1]['name'])->toBe('サンプル2');     
});

//  2ケース目
it('指定IDは合っていてもステータスがok以外のためヒットしないこと。', function () { 
    // 検索したいパラメーター
    $id = 1;
    $status= 'test'; // statusはAPIのパラメータからは指定できない

    // 任意のテーブルにデータを入れる機能
    SampleTable::factory()->create(
        [
            'id' => 1,  // primary key
            'status' => 'ok',
            'name' => 'サンプル1',
        ]
    );
    SampleTable::factory()->create(
        [
            'id' => 2, // primary key
            'status' => 'ok',
            'name' => 'サンプル2',
        ]
    );

    // id, statusから一覧を取得するメソッドのテスト
    $result = $this->sample->getList($id, $status);

    // 返却内容は以下の記述でチェックできます。

    // 結果が何件返ってきているかをチェック
    expect($result->count())->toBe(0);
});
<?php
class Sample
{ 
    public function getList(int $id, string $status): Collection
    {
        $query = SampleTable::query()
            ->where('id', $id);
        
        if ($status == 'ok') {
            $query->where('status', $status);
        } else {
            $query->where('status', 'ng');
        }

        return $query->get();
    }
}

上記、2つのテストケースがありますが、

  • 1つ目はid=1, status=okでデータが取得できるケース
  • 2つ目はid=1, status=testでデータが取得できないケース

どちらのケースもid=1、id=2と入れていますが、各テストは独立しているのでデータの重複や状態を考慮する必要がなく、 自由なデータを入れることができます。
また、サンプル内の「status」はAPIパラメータ指定ではなく、何らかの条件で決まる場合、

  • 外部APIの結果などの別要因で決まる値
  • 他のデータの組み合わせで決まる値

上記のようなケースでも単体テストでは値を固定してそれぞれのパターンを簡単に試すこともできます。

前職で行っていたテストではこういった外部要因で決まった値によって変わるテストが、
データ調整や外部APIの結果を無理やり変えたりと大変苦労していました。

また、サンプルでは非常にシンプルなテストですが、

  • 複数使用したテーブルやテーブルの項目が増える場合
  • いろんな条件によって検索する値が変わる場合
  • 通常は起こり得ないデータを入れないとテストできない場合

など複雑化していくケースの場合、

  • あらかじめ検証したいデータを投入しておく
  • API検証時に各パラメーターのパターンを変えて試す

だけではどうしても漏れが起きてしまったり、複雑なケースのデータ投入の限界や他のテストケースに影響を与えないデータ考慮などが必要になってきてしまいます。
また、もしエラーが発生した場合にそれが検証データによるものか、ロジックによるものかの判断もより難しくなってきます。

これが1テストケースごとで独立したデータ、テストであれば、

  • 難しいデータパターンもコードに1度書いてしまえば、同じ検証、同じ結果が担保できる
  • 各ケースが様々な記述をしても、他のケースに影響を与えない
     (データパターンも自由に変更可能)
  • ある時からエラーが発生するケースを検知した場合でもデータによるものではなくロジックによるものと判断しやすい

このメリットを利用することができれば、今まで課題だった問題が解決できそうと思ったところです。

実際に使ってみて感じたところ

ただ今回のような単体テスト実行は、前職のプロジェクトでは一時的に導入はしたものの、
前述の通り、

  • 対応コストが多く掛かってしまう
  • 機能変更した場合の修正コストも都度必要
  • 通常のプログラムとは別の記述方法が必要

が、やはり大きく影響し、最初は導入しても徐々に対応しなくなってしまうケースがほとんどでした・・・。

しかし、今までのようにすべてやらなくなるのではなく、
一部だけでも取り入れる方法はメリット部分を大きく活かせそうかなと思いました。

この一部だけというのが、また別の課題も生みそうではありますが、

  • 検証しにくいデータパターンだが重要なケース
  • データの変化が起きやすく安定した検証がしにくいケース
  • 一部の重要なロジックだけでも固定データと合わせて動作保証を担保したいケース
  • よく利用されるもの、共通のケース
    など

もちろん現在のプロジェクトで実施しているようなできる限り網羅するのが良いと思っていますが、 それぞれのプロジェクトの状況によっては、上記のような必要な部分だけに今回のようなテストコードのコストをかけるだけでも大きな効果があるのではないかと思いました。

最後に

抽象的な表現でわかりにくい点もあったかと思いますが、
データパターン検証、安定した検証に今回のような単体テストも有効そうであることは伝わりましたでしょうか。

今回のような単体テスト方法はしばらく触れてきていなかったのもあり、大変勉強になりました。

今後のテスト検証の際にぜひ少しでも参考にしていただければ幸いです。
最後までお読みいただき、ありがとうございました。