見出し画像

laravel (sail)でテストしてまっか? (3) 本当の機能テスト

もう一度コントローラーを眺めてみよう

<?php

namespace App\Http\Controllers;

use App\Models\UploadedFile;
use Illuminate\Http\Request;

use App\Http\Requests\UploadedFileRequest;

class UploaderController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        $uploadedFiles = UploadedFile::latest()->get();
        return view('uploaders.index', ['uploadedFiles' => $uploadedFiles]);
    }

    /**
     * Store a newly created resource in storage.
     */
    // public function store(Request $request)
    public function store(UploadedFileRequest $request)
    {
        $file = $request->file('file');

        $originalName = $file->getClientOriginalName();
        $mime = $file->getMimeType();
        $size = $file->getSize();
        $savedName = $savedName = \Str::random(10).md5($originalName);
        $data = [
            'original_name' => $originalName,
            'saved_name'    => $savedName,
            'mime_type'     => $mime,
            'size'          => $size,
        ];

        \DB::beginTransaction();
        $uploadedFile = UploadedFile::create($data);
        $extension = $file->getClientOriginalExtension();
        $savedName = sprintf('%05d.%s', $uploadedFile->id, $extension);
        $path = $file->storeAs('uploaded_files', $savedName, 'public');

        // saved_nameを更新
        $uploadedFile->update(['saved_name' => $savedName]);
        \DB::commit();

        // dd($request->all());
        return redirect(route('uploaders.index'))
            ->with(['status' => __('File uploaded')]);
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(UploadedFile $uploader)
    {
        \Storage::delete('uploaded_files/' . $uploader->saved_name);
        $uploader->delete();
        return redirect()->route('uploaders.index')
                         ->with('status', __('File deleted successfully.'));
    }
}

ここでは

  • index

  • store

  • destroy

という3つのコントローラーのメソッド(アクション)が見られる。これをテストするときにどうしますか?

コントローラーのテストは(基本)unitテストを使わない

まずこれが原則である。unitテストの事は忘れてfeatureテストに注力しよう。とくにindexはちょっと置いといてstoreに注目する

storeのテスト

    public function store(UploadedFileRequest $request)
    {
        $file = $request->file('file');

        $originalName = $file->getClientOriginalName();
        $mime = $file->getMimeType();
        $size = $file->getSize();
        $savedName = $savedName = \Str::random(10).md5($originalName);
        $data = [
            'original_name' => $originalName,
            'saved_name'    => $savedName,
            'mime_type'     => $mime,
            'size'          => $size,
        ];

        \DB::beginTransaction();
        $uploadedFile = UploadedFile::create($data);
        $extension = $file->getClientOriginalExtension();
        $savedName = sprintf('%05d.%s', $uploadedFile->id, $extension);
        $path = $file->storeAs('uploaded_files', $savedName, 'public');

        // saved_nameを更新
        $uploadedFile->update(['saved_name' => $savedName]);
        \DB::commit();

        // dd($request->all());
        return redirect(route('uploaders.index'))
            ->with(['status' => __('File uploaded')]);
    }

このメソッドはファイルを受けとって保存するという「機能」をもっている。しかし前段で

public function store(UploadedFileRequest $request)

これが入っていることから

app/Http/Requests/UploadedFileRequest.php 

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadedFileRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'file' => 'required|file|image|max:300', // 300KB以下の画像
        ];
    }
}

これも機能しているかどうかのテストが必要だ。ここではvalidationで300kbのイメージ画像に限定している。

テストを作っていく

これは前にやったように

% ./vendor/bin/sail artisan make:test Uploader/StoreTest


   INFO  Test [tests/Feature/Uploader/StoreTest.php] created successfully.

こういった形で雛形が作れる。このtests/Feature/Uploader/StoreTest.php を改良していく

class StoreTest extends TestCase
{  
    public function test_empty_request()
    {
        $response = $this->post(route('uploaders.store'), []);
        $response->assertSessionHasErrors();
    }

これは通過する。しかしどのようなエラーが起きてるのかイマイチよくわからんから確認したいという事もまあまああるだろう。一応そういう時は

class StoreTest extends TestCase
{
    public function test_empty_request()
    {
        $response = $this->post(route('uploaders.store'), []);
        $errors = session('errors')->all();
        dump($errors);
        $response->assertSessionHasErrors();
    }
}

dumpしておくと

% ./vendor/bin/sail test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true

   PASS  Tests\Feature\ExampleTest
  ✓ the application returns a successful response                        0.27s

   PASS  Tests\Feature\Uploader\IndexTest
  ✓ application name is not default                                      0.01s
  ✓ initial screen without data                                          0.02s
array:1 [
  0 => "Fileは必須項目です。"
] // tests/Feature/Uploader/StoreTest.php:15

   PASS  Tests\Feature\Uploader\StoreTest
  ✓ empty request                                                        0.03s

  Tests:    5 passed (8 assertions)
  Duration: 0.42s

このようにsessionがdumpされてくる。ただ、このメッセージの内容までチェックするとassertSee()と同じような問題が発生してくるから、この辺は匙加減という所だろう。

ファイルをアップロードしてみる

uploadテストするときにローカルのファイルを使う事もできるんだけど、疑似的にそれっぽいファイルを作ってくれる便利すぎるブツがある。テストで

use Illuminate\Http\UploadedFile;

しておく。そしたら

    public function test_upload_success()
    {
        $file = UploadedFile::fake()->create('image.jpg', 200); // kb

        $response = $this->post(route('uploaders.store'), [
            'file' => $file,
        ]);
        $response->assertSessionHasNoErrors();
    }

となるだろう。ここでは

'file' => 'required|file|image|max:300', // 300KB以下の画像

というvalidationで300kb以下の画像のみ許可しているので、この200kbのjpegは通過する。実際テストも通過する

DBの状態はどうなっているの?

今、ここまでやってDBに書いている。実際状態を見てみよう。

% ./vendor/bin/sail mysql

でmysqlが起動するから

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| performance_schema |
| simple_uploader    |
| testing            |
+--------------------+
4 rows in set (0.00 sec)

となっている。ここで

mysql> use testing

testingに切り替えて

mysql> select * from uploaded_files \G
*************************** 1. row ***************************
           id: 1
original_name: image.jpg
   saved_name: 00001.jpg
    mime_type: image/jpeg
         size: 204800
   created_at: 2024-02-24 04:50:21
   updated_at: 2024-02-24 04:50:21
1 row in set (0.00 sec)

とすると、先程テストしたファイルが残っている。なお、2回目のテストを行うと

' [UTF-8](length: 4857) contains "まだファイルがアップロードされていません" [UTF-8](length: 60).

  at tests/Feature/Uploader/IndexTest.php:29
     25▕         $response->assertStatus(200);
     26▕         $response->assertSeeInOrder([$uniqueAppName, $uniqueAppName]);
     2728▕         $response->assertSee(__('Uploaded Files'));
  ➜  29▕         $response->assertSee(__('No files uploaded yet.'));
     30▕     }
     31▕ }

など出てくるだろう。これは

    public function test_initial_screen_without_data(): void
    {
        // ユニークなアプリケーション名を生成
        $uniqueAppName = 'TestApp' . \Str::random();

        config(['app.name' => $uniqueAppName]);
        $response = $this->get('/');
        $response->assertStatus(200);
        $response->assertSeeInOrder([$uniqueAppName, $uniqueAppName]);

        $response->assertSee(__('Uploaded Files'));
        $response->assertSee(__('No files uploaded yet.'));
    }

ここで既にtestingにデーターが入っているためだ。そりゃそうだよね。データーが入ってる段階で初期の「No files uploaded yet.」は出てはこない

これはテストの状態としてはよろしくない。いいすか、Featureテストは「まっさらな状態でやる」のが基本である。DBも含めて。これに外れたテストは理論的には上記のように可能ではあるが、これは本当にイリーガルなパターンだし、そういうテストは大体クソテストなのだから、ここは普通にパターンに従っておく事。

RefreshDatabaseトレイト

これは簡単で、

class StoreTest extends TestCase
{
    use RefreshDatabase;

このように書いておくだけだ。これでlaravelの機能テストはまっさらな状態のDBから始めてまっさらな状態のDBに戻して終わる事ができる。なお、ファイルはfake()されているから実際には保存されないのだが、ただassertExists()で調査することも可能ではある、面倒だからしないけど…

従ってこれによりファイルが正常にアップロードされる事がテストされる

<?php

namespace Tests\Feature\Uploader;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Tests\TestCase;

class StoreTest extends TestCase
{
    use RefreshDatabase;

    public function test_empty_request()
    {
        $response = $this->post(route('uploaders.store'), []);
        $response->assertSessionHasErrors();
    }

    public function test_upload_success()
    {
        $file = UploadedFile::fake()->create('image.jpg', 200); // kb

        $response = $this->post(route('uploaders.store'), [
            'file' => $file,
        ]);
        $response->assertSessionHasNoErrors();
    }
}

さらなるvalidationテスト

今は正常な200kbの画像(jpeg)を模したが、それ以外はどうだろう、基本的には「通らないvalidationテスト」もやっておいた方がよい。

    public function test_upload_over_filesize()
    {
        $file = UploadedFile::fake()->create('image.jpg', 1200); // kb

        $response = $this->post(route('uploaders.store'), [
            'file' => $file,
        ]);
        $errors = session('errors')->all();
        dump($errors);
        $response->assertSessionHasErrors();
    }

としておけば

% ./vendor/bin/sail test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true                                                        0.01s

   PASS  Tests\Feature\ExampleTest
  ✓ the application returns a successful response                            0.27s

   PASS  Tests\Feature\Uploader\IndexTest
  ✓ application name is not default                                          0.42s
  ✓ initial screen without data                                              0.02s
array:1 [
  0 => "Fileは、300 KB以下のファイルである必要があります。"
] // tests/Feature/Uploader/StoreTest.php:38

   PASS  Tests\Feature\Uploader\StoreTest
  ✓ empty request                                                            0.03s
  ✓ upload success                                                           0.05s
  ✓ upload over filesize                                                     0.02s

  Tests:    7 passed (10 assertions)
  Duration: 0.92s

となるだろうし

    public function test_upload_no_imagefile()
    {
        $file = UploadedFile::fake()->create('document.pdf', 200); // kb

        $response = $this->post(route('uploaders.store'), [
            'file' => $file,
        ]);
        $errors = session('errors')->all();
        dump($errors);
        $response->assertSessionHasErrors();
    }

こうすれば

array:1 [
  0 => "Fileには、画像を指定してください。"
] // tests/Feature/Uploader/StoreTest.php:50

こんな感じになるだろう。それ以外もあると思うから考えてみて。
いずれにせよテストはこんな感じで全て通過させる

% ./vendor/bin/sail test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true                                                        0.01s

   PASS  Tests\Feature\ExampleTest
  ✓ the application returns a successful response                            0.28s

   PASS  Tests\Feature\Uploader\IndexTest
  ✓ application name is not default                                          0.61s
  ✓ initial screen without data                                              0.02s

   PASS  Tests\Feature\Uploader\StoreTest
  ✓ empty request                                                            0.03s
  ✓ upload success                                                           0.05s
  ✓ upload over filesize                                                     0.02s
  ✓ upload no imagefile                                                      0.02s

  Tests:    8 passed (11 assertions)
  Duration: 1.12s

いいすか、テストは必ず通過させてくださいよ。ここでのテストが通過してないという事は必ずバグがあるという事を逆保証するわけだ。そういうテストを書かないと意味が無いともいえる。

でも俺のアプリは認証使ってコントローラーの中とか入れねえんだけど

って場合は今回は簡単なテストのための小さなコードだからとりあげないけど、多くのproductionではそうなっているのが普通とも思う。今認証しないアプリの方が珍しい。その場合は

この辺にやり方が書いてあるから、見てもらうとして、要するに擬似的に認証を完了した状態を作りだす。このとき先述したように

Featureテストは「まっさらな状態でやる」のが基本

であるから、ユーザーデーターなどは存在しない。その場合に

    public function test_authenticated_user_can_access_dashboard()
    {
        // テスト用のユーザーを作成
        $user = User::factory()->create();

        // 作成したユーザーとして認証し、ダッシュボードにアクセス
        $response = $this->actingAs($user)->get('/dashboard');

        // レスポンスをアサート
        $response->assertStatus(200); // HTTPステータスコード200(OK)を期待
        $response->assertSee('Dashboard'); // レスポンスに「Dashboard」という文字列が含まれていることを期待
    }

factory() で1つダミーユーザーを作成し、そいつで認証させるという事を多々やる。もちろんseedがそろってるならseedをかける事もできる。

つまり、factory() ってのは大量のデーターを作るのに適した形で用意されるべきであり、それはseedの加工用で使う事もあるんだけど、テストを意識した形でfactory()が作られているかどうかがポイントだ。この辺、見る人が見たら一発でわかるから気をつけといてくださいよ(何を)

まとめ

ここまで簡単な機能(Feature)テストのやり方を書いた。このような自動化されたテストは書かない人は本当に一切書かないし触れようともしない所であるが、わかってる人はちゃんとわかってるから、何かの何かでコードを提出してみ?とか言われたときにちゃんとテスト意識して書いてる奴と、全くテストなんて知らんという奴ではアプリの構造に経験の差が出てくるから、何事も勉強と思いますよ。

次はもう一歩進んでCI/CDをやるかもしれないし、やらないかもしれない(最近あんまnoteのモチベない)







この記事が気に入ったらサポートをしてみませんか?