PHPUnitでLaravelのAPIをユニットテストする

June 05, 2020
執筆者
Oluyemi Olususi
寄稿者
Twilio の寄稿者によって表明された意見は彼ら自身のものです
レビュー担当者

PHPUnitでLaravelのAPIをユニットテストする

この記事はOluyemi Olususiこちらで公開した記事(英語)を日本語化したものです。

アプリケーションを正常に動作させるためのベストプラクティスとして、テストがあります。指定されたソフトウェア要件が正しく実装されているかを確認するためのユニットテスト、自動機能テスト、APIエンドポイントテストの実施などです。プロジェクトで変更を実施した場合や、新たな機能を実装した場合でも、これらのテストを行うことにより、アプリケーションの動作不良を確実に回避できます。このような開発方法をテスト駆動開発と呼びます。

人気のあるPHPフレームワークであるLaravelも、テストの容易さを念頭に構築されており、PHPUnitというテストスイートが付属しています。PHPUnitはPHP開発者の生産性を向上するテストフレームワークです。ユニットテストと呼ばれる最小単位のコンポーネントでPHPコードをテストすることを主な目的として設計されていますが、ユニットテスト以外のテストにも柔軟に応用することができます。

このチュートリアルでは、テスト駆動開発の手法に従い、Laravel APIプロジェクトのエンドポイントをテストする方法について説明します。まず、失敗する前提のテストを記述します。その後、テストに合格するコードを記述します。チュートリアルを最後まで進めると、基本的なテストの実施方法について学び、新規または既存のLaravel APIプロジェクトに応用できるようになります。

必要条件

このチュートリアルを進めるにあたり、Laravelを使用したアプリケーション構築の基礎知識があると役立ちます。また、依存関係を管理するために、グローバルにComposerがインストールされている必要があります。

はじめに

Laravel APIを使用し、世界の大手テクノロジー企業に所属するCEOのリストを作成し、情報を表示します。過去のブログ記事「Build a Secure API in PHP Using Laravel Passport」で類似のAPIを構築しました。

できるだけ簡単に進められるようスタータープロジェクトをダウンロードします。このプロジェクトには、アプリケーションが適切に動作するための骨組みがあらかじめ用意されています。

まず、Gitを使用してスタータープロジェクトをダウンロードします。以下のコマンドを実行してください。

$ git clone https://github.com/yemiwebby/laravel-api-testing-starter.git

次に、新しいプロジェクトのフォルダーに移動し、 必要な依存関係をすべてインストールします。

// move into the new folder
$ cd laravel-api-testing-starter
 
// install dependencies
$ composer install

このサンプルプロジェクトには、以下の内容が含まれています。

次に、プロジェクトのルートディレクトリに.envファイルを作成し、.env.exampleファイルの内容を入力します。手作業で入力するか、または以下のコマンドを実行してください。

$ cp .env.example .env

 このプロジェクトのLaravelアプリケーションキーを生成します。以下のコマンドを実行してください。

$ php artisan key:generate

プロジェクトを実行します。以下のコマンドを実行してください。

$ php artisan serve

http://localhost:8000にアクセスすると以下のようなホームページが表示されます。

Laravel ホームページ

新しくインストールされたLaravelプロジェクトのデフォルトページなので、簡単な内容のみが表示されています。

データベースのセットアップ

テストを開始するには、テストデータベースのセットアップが必要です。このチュートリアルは、インメモリのSQLiteデータベースを使用し、シンプルな構造で進めていきます。また、このデータベースには、テストスクリプトを高速に実行できるメリットがあります。

databaseフォルダーにtest.sqliteファイルを作成してください。このファイルは、メインのデータベースとは別に設定を保持するために用意するものです。テストデータベースとやり取りを行う際に使用します。次に、.envファイルの.env.testingにあるデータベース環境変数を以下のように置き換えてください。

DB_CONNECTION=sqlite
DB_HOST=null
DB_PORT=null
DB_DATABASE=database/test.sqlite
DB_USERNAME=null
DB_PASSWORD=null

それぞれのテストを行う前に、マイグレーションが必要になります。各テストの前にマイグレーションを実行し、テスト用のデータベースを正しく構築する必要があります。

マイグレーションの設定を行います。tests/TestCase.phpファイルにあるベースのTestCaseクラスを開き、以下のように内容を更新してください。マイグレーションの設定を行います。

<?php

namespace Tests;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication, DatabaseMigrations;

    public function setUp(): void
    {
        parent::setUp();
        Artisan::call('passport:install');
    }
}

 ここでは、DatabaseMigrationsトレイトを組み込み、passportをインストールするためのArtisanの呼び出しを追加しています。
最後に、PHPUnitを実行します。ターミナルで以下のコマンドを実行してください。

$ vendor/bin/phpunit

以下のような結果が表示されます。

PHPUnit 8.5.4 by Sebastian Bergmann and contributors.
 
..                                                                2 / 2 (100%)
 
Time: 2.03 seconds, Memory: 24.00 MB
 
OK (2 tests, 2 assertions)

Laravel アウトプット

上記の出力では、2つのテストが正常に実行されたことが分かります。これらはLaravelによりデフォルトでインストールされたテストです。後でこれらを変更します。
PHPUnitの実行コマンドを紐付けるために、composer.jsonファイルを開き、scriptsに以下のようにテストコマンドを追加してください。
{
    ...
    "scripts": {
        ...,
        "test": [
            "vendor/bin/phpunit"
        ]
    }
}

これで、composer testを実行するとテストコマンドを実行できるようになりました。

CEOファクトリーの作成

Laravelのファクトリーは、FakerPHPライブラリを使用してテスト用のランダムデータを生成できる便利な機能です。Laravelには、Userクラス用のファクトリー定義があらかじめ読み込まれています。

CEOクラス用のファクトリーを生成します。以下のコマンドを実行してください。

$ php artisan make:factory CEOFactory

これにより、CEOFactory.phpという新しいファイルがdatabase/factoriesフォルダー内に作成されます。CEOFactory.phpファイルを開き、以下の内容をペーストしてください。

<?php
  
/** @var \Illuminate\Database\Eloquent\Factory $factory */
  
use App\CEO;
use Faker\Generator as Faker;
  
$factory->define(CEO::class, function (Faker $faker) {
    return [
        'name' => $faker->name,
        'company_name' => $faker->unique()->company,
        'year' => $faker->year,
        'company_headquarters' => $faker->city,
        'what_company_does' => $faker->sentence
    ];
});

CEOテーブルのフィールド指定と、Fakerライブラリの使用により、すべてのフィールドで正しい形式のランダムデータを生成します。

最初のテストの記述

前述したように、テストを記述します。その前に、tests/Featureフォルダーとtests/Unitフォルダーにある2つのサンプルのテストファイルを削除してください。

最初に、認証プロセスのテストを記述します。認証プロセスでは、登録とログインの処理が行われます。APIフォルダーには、この目的で作成されたコントローラーがすでに用意されています。

AuthenticationTestファイルを作成します。以下のコマンドを実行してください。

$ php artisan make:test AuthenticationTest

これにより、test/FeatureフォルダーにAuthenticationTest.phpファイルが作成されます。

AuthenticationTest.phpファイルを開き、以下の内容に置き換えてください。

<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;

class AuthenticationTest extends TestCase
{
    ...

    public function testMustEnterEmailAndPassword()
    {
        $this->json('POST', 'api/login')
            ->assertStatus(422)
            ->assertJson([
                "message" => "The given data was invalid.",
                "errors" => [
                    'email' => ["The email field is required."],
                    'password' => ["The password field is required."],
                ]
            ]);
    }

    public function testSuccessfulLogin()
    {
        $user = factory(User::class)->create([
           'email' => 'sample@test.com',
           'password' => bcrypt('sample123'),
        ]);


        $loginData = ['email' => 'sample@test.com', 'password' => 'sample123'];

        $this->json('POST', 'api/login', $loginData, ['Accept' => 'application/json'])
            ->assertStatus(200)
            ->assertJsonStructure([
               "user" => [
                   'id',
                   'name',
                   'email',
                   'email_verified_at',
                   'created_at',
                   'updated_at',
               ],
                "access_token",
                "message"
            ]);

        $this->assertAuthenticated();
    }
}

 このファイルには、以下のテストが記述されています。

  • testRequiredFieldsForRegistration: 登録プロセスに必要なすべてのフィールドが正しく入力されているかテストします。
  • testRepeatPassword: パスワードは繰り返し入力する必要があります。最初入力したパスワードと同じ文字列を確認用のパスワードとして入力しなければ、このテストに合格することはできません。
  • testSuccessfulRegistration: ユーザーが正しくサインアップできるように、ダミーのデータに基づきユーザーが作成されます。

PHPUnitを使用してテストを実行します。以下のコマンドを実行してください。

$ composer test

以下のような結果が表示されます。

> vendor/bin/phpunit
PHPUnit 8.5.4 by Sebastian Bergmann and contributors.

FFF                                                                 3 / 3 (100%)

Time: 259 ms, Memory: 18.00 MB

There were 3 failures:

1) Tests\Feature\AuthenticationTest::testRequiredFieldsForRegistration
Expected status code 422 but received 500.
Failed asserting that 422 is identical to 500.

/Users/yemiwebby/tutorial/twilio/testing/laravel-api-testing/vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:185
/Users/yemiwebby/tutorial/twilio/testing/laravel-api-testing/tests/Feature/AuthenticationTest.php:14

2) Tests\Feature\AuthenticationTest::testRepeatPassword
Expected status code 422 but received 500.
Failed asserting that 422 is identical to 500.

/Users/yemiwebby/tutorial/twilio/testing/laravel-api-testing/vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:185
/Users/yemiwebby/tutorial/twilio/testing/laravel-api-testing/tests/Feature/AuthenticationTest.php:34

3) Tests\Feature\AuthenticationTest::testSuccessfulRegistration
Expected status code 201 but received 500.
Failed asserting that 201 is identical to 500.

/Users/yemiwebby/tutorial/twilio/testing/laravel-api-testing/vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:185
/Users/yemiwebby/tutorial/twilio/testing/laravel-api-testing/tests/Feature/AuthenticationTest.php:53

FAILURES!
Tests: 3, Assertions: 3, Failures: 3.
Script vendor/bin/phpunit handling the test event returned with error code 1

機能実装前のアウトプット

まだ機能を実装していないため、これは想定内の結果です。

app/Http/Controllers/API/Auth/AuthController.phpを開き、以下のように内容を更新してください。

<?php

namespace App\Http\Controllers\API\Auth;

use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validatedData = $request->validate([
            'name' => 'required|max:55',
            'email' => 'email|required|unique:users',
            'password' => 'required|confirmed'
        ]);

        $validatedData['password'] = bcrypt($request->password);

        $user = User::create($validatedData);

        $accessToken = $user->createToken('authToken')->accessToken;

        return response([ 'user' => $user, 'access_token' => $accessToken, 'message' => 'Register successfully'], 201);
    }
}

composer testを実行してください。

今度はテストに合格します。

ログインエンドポイントのテスト

AuthenticationTest.phpファイルに以下のメソッドを追加してください。

<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;

class AuthenticationTest extends TestCase
{
    ...

    public function testMustEnterEmailAndPassword()
    {
        $this->json('POST', 'api/login')
            ->assertStatus(422)
            ->assertJson([
                "message" => "The given data was invalid.",
                "errors" => [
                    'email' => ["The email field is required."],
                    'password' => ["The password field is required."],
                ]
            ]);
    }

    public function testSuccessfulLogin()
    {
        $user = factory(User::class)->create([
           'email' => 'sample@test.com',
           'password' => bcrypt('sample123'),
        ]);


        $loginData = ['email' => 'sample@test.com', 'password' => 'sample123'];

        $this->json('POST', 'api/login', $loginData, ['Accept' => 'application/json'])
            ->assertStatus(200)
            ->assertJsonStructure([
               "user" => [
                   'id',
                   'name',
                   'email',
                   'email_verified_at',
                   'created_at',
                   'updated_at',
               ],
                "access_token",
                "message"
            ]);

        $this->assertAuthenticated();
    }
}

testMustEnterEmailAndPassword()メソッドを用いて、ユーザーが必須フィールドを空にしていないか確認するテストも作成しています。testSuccessfulLogin()メソッドでは、ユーザーが正常に認証されるよう、ダミーユーザーを作成しました。

composer testを使用して再度テストを実行します。予想どおり、このテストも失敗します。テストに合格するように、AuthController.phpファイルを以下のように更新してください。

// app/Http/Controllers/API/Auth/AuthController.php
<?php

namespace App\Http\Controllers\API\Auth;

use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;

class AuthController extends Controller
{
    ...

    public function login(Request $request)
    {
        $loginData = $request->validate([
            'email' => 'email|required',
            'password' => 'required'
        ]);

        if (!auth()->attempt($loginData)) {
            return response(['message' => 'Invalid Credentials']);
        }

        $accessToken = auth()->user()->createToken('authToken')->accessToken;

        return response(['user' => auth()->user(), 'access_token' => $accessToken, 'message' => 'Login successfully'], 200);

    }
}

合計で、5つの異なる重要なテストを記述しました。一部のテストケースには、APIからのレスポンスのステータスとjson()構造が含まれています。

次のセクションでは、CEOエンドポイントに対して一連のテストを作成します。

CEOエンドポイントのテストの記述

このセクションでは、まずCEOエンドポイントのテストスクリプトを格納する新しいテストファイルを作成します。

以下のコマンドを実行してください。

$ php artisan make:test CEOTest

このコマンドにより、tests/FeatureフォルダーにCEOTest.phpという新しいテストファイルが作成されます。このファイルを開き、以下の内容に置き換えてください。

<?php

namespace Tests\Feature;

use App\CEO;
use App\User;
use Tests\TestCase;

class CEOTest extends TestCase
{
    public function testCEOCreatedSuccessfully()
    {
        $user = factory(User::class)->create();
        $this->actingAs($user, 'api');

        $ceoData = [
            "name" => "Susan Wojcicki",
            "company_name" => "YouTube",
            "year" => "2014",
            "company_headquarters" => "San Bruno, California",
            "what_company_does" => "Video-sharing platform"
        ];

        $this->json('POST', 'api/ceo', $ceoData, ['Accept' => 'application/json'])
            ->assertStatus(201)
            ->assertJson([
                "ceo" => [
                    "name" => "Susan Wojcicki",
                    "company_name" => "YouTube",
                    "year" => "2014",
                    "company_headquarters" => "San Bruno, California",
                    "what_company_does" => "Video-sharing platform"
                ],
                "message" => "Created successfully"
            ]);
    }

    public function testCEOListedSuccessfully()
    {

        $user = factory(User::class)->create();
        $this->actingAs($user, 'api');

        factory(CEO::class)->create([
            "name" => "Susan Wojcicki",
            "company_name" => "YouTube",
            "year" => "2014",
            "company_headquarters" => "San Bruno, California",
            "what_company_does" => "Video-sharing platform",
        ]);

        factory(CEO::class)->create([
            "name" => "Mark Zuckerberg",
            "company_name" => "FaceBook",
            "year" => "2004",
            "company_headquarters" => "Menlo Park, California",
            "what_company_does" => "The world's largest social network",
        ]);

        $this->json('GET', 'api/ceo', ['Accept' => 'application/json'])
            ->assertStatus(200)
            ->assertJson([
                "ceos" => [
                    [
                        "id" => 1,
                        "name" => "Susan Wojcicki",
                        "company_name" => "YouTube",
                        "year" => "2014",
                        "company_headquarters" => "San Bruno, California",
                        "what_company_does" => "Video-sharing platform"
                    ],
                    [
                        "id" => 2,
                        "name" => "Mark Zuckerberg",
                        "company_name" => "FaceBook",
                        "year" => "2004",
                        "company_headquarters" => "Menlo Park, California",
                        "what_company_does" => "The world's largest social network"
                    ]
                ],
                "message" => "Retrieved successfully"
            ]);
    }
}

複雑そうに見えるかもしれませんが、先ほど記述したテストと似た内容です。詳しく見てみましょう。

2つの異なるメソッドを作成しました。

  • testCEOCreatedSuccessfully: 適切なデータを使用してCEOレコードを作成できるかテストします。
  • testCEOListedSuccessfully: 作成されたCEOのリストを取得し、レスポンスとして返すことができるかテストします。

AuthenticationTestとは異なり、CEOTestauth:apiというミドルウェアにより保護されたエンドポイント向けに記述されています。新たに作成したダミーユーザーが認証を受けた後にエンドポイントにアクセスするよう、$this->actingAs($user, 'api')を使用してこのユーザーを認証、認可した後に、CRUD(作成、読み取り、更新、削除)処理を実行します。

再度テストを実行します。composer testを実行してください。

今回もテストに失敗しました。もうテストに失敗した理由はお分かりかと存じます。まだこれらのテストのロジックを学習中の方のために説明すると、テストに合格するには、CEOController.phpファイルに適切なコードを入力する必要があります。

CEOControllerの更新

app/Http/Controllers/API/CEOController.phpファイルを開き、以下のように内容を更新してください。

<?php

namespace App\Http\Controllers\API;

use App\CEO;
use App\Http\Controllers\Controller;
use App\Http\Resources\CEOResource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class CEOController extends Controller
{
    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $data = $request->all();

        $validator = Validator::make($data, [
            'name' => 'required|max:255',
            'company_name' => 'required|max:255',
            'year' => 'required|max:255',
            'company_headquarters' => 'required|max:255',
            'what_company_does' => 'required'
        ]);

        if($validator->fails()){
            return response(['error' => $validator->errors(), 'Validation Error']);
        }

        $ceo = CEO::create($data);

        return response([ 'ceo' => new CEOResource($ceo), 'message' => 'Created successfully'], 201);
    }


    public function index()
    {
        $ceos = CEO::all();

        return response([ 'ceos' => CEOResource::collection($ceos), 'message' => 'Retrieved successfully'], 200);
    }
}

ここでは、新しいCEOのレコードを格納するメソッドと、データベースからCEOのリストを取得するメソッドを作成しています。

テストを再度実行すると、今回は合格します。

最後に、特定のCEOの詳細情報を取得、更新、削除するテストを追加しましょう。CEOTest.phpファイルを開き、以下のメソッドを追加します。

<?php

namespace Tests\Feature;

use App\CEO;
use App\User;
use Tests\TestCase;

class CEOTest extends TestCase
{
    ...

    public function testRetrieveCEOSuccessfully()
    {
        $user = factory(User::class)->create();
        $this->actingAs($user, 'api');

        $ceo = factory(CEO::class)->create([
            "name" => "Susan Wojcicki",
            "company_name" => "YouTube",
            "year" => "2014",
            "company_headquarters" => "San Bruno, California",
            "what_company_does" => "Video-sharing platform"
        ]);

        $this->json('GET', 'api/ceo/' . $ceo->id, [], ['Accept' => 'application/json'])
            ->assertStatus(200)
            ->assertJson([
                "ceo" => [
                    "name" => "Susan Wojcicki",
                    "company_name" => "YouTube",
                    "year" => "2014",
                    "company_headquarters" => "San Bruno, California",
                    "what_company_does" => "Video-sharing platform"
                ],
                "message" => "Retrieved successfully"
            ]);
    }

    public function testCEOUpdatedSuccessfully()
    {
        $user = factory(User::class)->create();
        $this->actingAs($user, 'api');

        $ceo = factory(CEO::class)->create([
            "name" => "Susan Wojcicki",
            "company_name" => "YouTube",
            "year" => "2014",
            "company_headquarters" => "San Bruno, California",
            "what_company_does" => "Video-sharing platform"
        ]);

        $payload = [
            "name" => "Demo User",
            "company_name" => "Sample Company",
            "year" => "2014",
            "company_headquarters" => "San Bruno, California",
            "what_company_does" => "Video-sharing platform"
        ];

        $this->json('PATCH', 'api/ceo/' . $ceo->id , $payload, ['Accept' => 'application/json'])
            ->assertStatus(200)
            ->assertJson([
                "ceo" => [
                    "name" => "Demo User",
                    "company_name" => "Sample Company",
                    "year" => "2014",
                    "company_headquarters" => "San Bruno, California",
                    "what_company_does" => "Video-sharing platform"
                ],
                "message" => "Updated successfully"
            ]);
    }

    public function testDeleteCEO()
    {
        $user = factory(User::class)->create();
        $this->actingAs($user, 'api');

        $ceo = factory(CEO::class)->create([
            "name" => "Susan Wojcicki",
            "company_name" => "YouTube",
            "year" => "2014",
            "company_headquarters" => "San Bruno, California",
            "what_company_does" => "Video-sharing platform"
        ]);

        $this->json('DELETE', 'api/ceo/' . $ceo->id, [], ['Accept' => 'application/json'])
            ->assertStatus(204);
    }

}

上記のテストでは、適切なHTTP動詞(GETPATCHDELETE)を使用し、api/ceoエンドポイントにパラメーターとして一意のidを渡すことにより、特定のCEOレコードを対象に操作を行います。

次に、新しいテストも合格するようにCEOControllerファイルを更新します。app/Http/Controllers/API/CEOController.phpを開き、以下のメソッドを追加してください。

<?php

namespace App\Http\Controllers\API;

use App\CEO;
use App\Http\Controllers\Controller;
use App\Http\Resources\CEOResource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class CEOController extends Controller
{
    ...

    /**
     * Display the specified resource.
     *
     * @param  \App\CEO  $ceo
     * @return \Illuminate\Http\Response
     */
    public function show(CEO $ceo)
    {
        return response([ 'ceo' => new CEOResource($ceo), 'message' => 'Retrieved successfully'], 200);

    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\CEO  $ceo
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, CEO $ceo)
    {

        $ceo->update($request->all());

        return response([ 'ceo' => new CEOResource($ceo), 'message' => 'Updated successfully'], 200);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param \App\CEO $ceo
     * @return \Illuminate\Http\Response
     * @throws \Exception
     */


    public function destroy(CEO $ceo)
    {
        $ceo->delete();

        return response(['message' => 'Deleted'], 204);
    }
}

ここで作成したメソッドは、データベースにあるCEOのレコードを取得、更新、削除します。

最後にcomposer testコマンドでテストを実行すると、以下の出力が表示されます。

> vendor/bin/phpunit
PHPUnit 8.5.4 by Sebastian Bergmann and contributors.
 
..........                                                      10 / 10 (100%)
 
Time: 915 ms, Memory: 32.00 MB
 
OK (10 tests, 35 assertions)

まとめ

このチュートリアルでは、テスト駆動開発の手法を使用し、Laravel APIプロジェクトのエンドポイント向けのテストをご紹介いたしました。テスト駆動開発では、機能を追加したとしても、新たな実装は既存のコードベースの機能に一切影響を与えることがありません。ユーザーにも大きなメリットをもたらします。

チュートリアルの全コードはGitHubから入手できます。ぜひコードを分析して考察したり、このコードにテストを追加し、そのテストに合格するコードを記述してみてください。

Olususi Oluyemi氏はテクノロジー、プログラミング、Web開発の熱心な愛好家で、最先端のテクノロジーに強い関心を持っています。