Dockerで構築したLaravelのローカル環境で画像アップロード系の実装・テストをするためにやったこと

..

@ucan-labさんの記事を丸パクリして立ち上げたローカル環境にて、画像アップロード系の実装とテストを入れました。
どんな手順が必要だったか記録します。

なお、本番環境以外ではpublicへ画像を保存しようと思っているので、あくまでLaravel内だけで完結する内容となっています。

PHPコンテナへGDをインストール

PHPで画像をあれこれするのに必要なPHP拡張モジュールがコンテナにインストールされていないので、Dockerfileをこのように編集しました。

FROM php:8.1-fpm-buster
SHELL ["/bin/bash", "-oeux", "pipefail", "-c"]

ENV COMPOSER_ALLOW_SUPERUSER=1 \
  COMPOSER_HOME=/composer

COPY --from=composer:2.0 /usr/bin/composer /usr/bin/composer

COPY --from=node:12.14 /usr/local/bin /usr/local/bin
COPY --from=node:12.14 /usr/local/lib /usr/local/lib

RUN apt-get update && \
-   apt-get -y install git unzip libzip-dev libicu-dev libonig-dev && \
+   apt-get -y install git unzip libzip-dev libicu-dev libonig-dev libfreetype6-dev libjpeg62-turbo-dev && \
  apt-get clean && \
  rm -rf /var/lib/apt/lists/* && \
-   docker-php-ext-install intl pdo_mysql zip bcmath
+   docker-php-ext-install intl pdo_mysql zip bcmath && \
+   docker-php-ext-configure gd --with-freetype --with-jpeg && \
+   docker-php-ext-install -j$(nproc) gd

COPY ./php.ini /usr/local/etc/php/php.ini

WORKDIR /work

末尾へ雑に書き足してしまいましたが、要するにdocker-php-ext-configureの引数に依存してるパッケージ名を渡せば、あとはdocker-php-ext-installスクリプトを書き足すことで欲しいPHP拡張をインストールできるよ、ということみたいです。

参考記事(http://psychedelicnekopunch.com/archives/2355)
PHPの公式Docker Imageのページ(https://hub.docker.com/_/php)

テストコード

今回作りたいのは「ユーザーアイコンのアップロード機能」なので、画像の格納に加え、ファイルパスをusersテーブルへ保存するところまで行いたいです。

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

		/**
     * @test
     */
    public function ユーザーアイコンアップロード()
    {
        $file = UploadedFile::fake()->image('icon.jpg');
        $expect_file_path = 'icon/' . $file->hashName();

        $response = $this->postJson('/api/user/'. $this->user_id . '/icon', [
            'icon_image' => $file
        ]);
        $response
            ->assertStatus(200);
            
        Storage::disk('public')->assertExists($expect_file_path);
        $this->assertDatabaseHas('users', ['icon_path' => $expect_file_path]);
    }

ダミーの画像ファイルを生成してPOSTし、storage/app/public/の中に渡した画像ファイルが格納されていること、データベースに画像のパスが保存されているかどうかをテストしています。

参考記事(https://readouble.com/laravel/8.x/ja/http-tests.html#testing-file-uploads)

実装

POSTした画像は、$requestインスタンスからfileメソッドで取り出します。

その際、画像はUploadedFileクラスのインスタンスとなって渡されるとのことでしたが、テストコードで作成したフェイクの画像を渡した際にはIlluminate\Http\Testing\Fileクラスのインスタンスが渡ってくるようだったので、引数には両方のクラスを受けられるように書きました。

public function uploadUserIconImage(User $user, UploadFile|File $icon_image): Collection
{
  return ($this->app_repository->isEnvironmentalProduction()) ?
    true: // TODO:S3へアップロードする処理を実装する
  	$this->storeImageToStorage($user, $icon_image);
}

private function storeImageToStorage(User $user, UploadFile|File $icon_image): Collection
{
  $icon_path = $this->image_repository->storeImageToStorage($icon_image);
  $this->repository->update($user, input: ['icon_path' => $icon_path]);

  return $this->createUploadedImageCollection($icon_path);
}

private function createUploadedImageCollection(string $icon_path): Collection
{
  return collect([
    'path' => $icon_path
  ]);
}

isEnvironmentalProductionメソッドは、単にAppファサードのメソッドを使って現在の設定がproductionかどうかを真偽値で返しています。

localstagingなど、要するに本番環境以外であれば以降のプライベートメソッドを実行。
ストレージへ画像を格納する処理は、ドキュメント通りこんな感じです。

public function storeImageToStorage(UploadFile|File $image): string
{
  return $image->storeAs('icon', $image->hashName(), 'public');
}

こうすると、storage/app/public/iconへ画像が保存され、戻り値はそのファイルのパスとなります。
ファイル名を取得するメソッドはhashName以外にもありますが、ファイル名という形で不正な値が渡されることも考えうるため、こちらのメソッドを使ってハッシュ化した上で取得することが推奨されています。

実際にVSCode上でもiconsと、その中にランダムな文字列で名前がつけられた.jpgが増えているのがわかります。
http://localhost:ポート番号/storage/icon/ファイル名.jpgにアクセスして、VSCodeで見えてるのと同じ画像(ちいちゃくて黒い四角)が出ていれば成功です。

スクリーンショット 2022-03-12 21.30.55.png

ただ、このやり方だとテストが終わっても画像自体は削除されないまま残ってしまうので、テストの一行目にStorage::fake('ダミーのディスク名');と書いて、ディスク自体をテスト終了とともに消すようにし、実装側はinstance ofUploadFileFileかを判定して、渡ってきた引数がFileインスタンスであればダミーの方へ保存する、のような工夫をするのがいいのではないかと思います。

S3へのアップロードについては外部APIとやり取りする処理になるので、レスポンスをモックした上でテストを実装する必要があります。なので、実装した暁には別途Postman等で実際の動作を確認する必要がありそうです。