Laravel11でなんちゃってCMSサイトを実装したのでテストもやろうと思い立ったのが始まり。前職ではExcelシートに書かれたテスト項目を手動で確認するテストしか行ったことがなかったので初の試みである。書籍やChatGPTを参考に、画像ファイルアップロード処理を含む登録画面と編集画面、一覧画面、CSVアップロード処理、CSVダウンロード処理、メール送信、入力画面から確認画面を通して完了画面に遷移する仕様のテストを実装した。実装するうえで気になったテスト項目を後学のために残しておこうと思う。
前提環境
- Windows11
- WSL2
- Ubuntu 24.04.1 LTS
- Laravel11
- MySQL8.4.3
画像ファイルアップロード
まずは画像ファイルアップロードのバリデーションテストについて
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/** @test */
public function バリデーション(): void
{
// 管理者権限でログイン
$this->actingAs($this->_admin, 'admin');
// 入力エラーでリダイレクト確認
$this->from(route('admin.product.create'))
->post($this->_url, ['first_category_id' => ''])
->assertRedirect(route('admin.product.create'));
// 製品画像
// ファイル準備
Storage::fake('public');
// 空
$this->post($this->_url, ['image' => ''])->assertInvalid(['image' => '製品画像は必須']);
// ファイル以外
$this->post($this->_url, ['image' => 'aaa'])->assertInvalid(['image' => '製品画像には、ファイル形式を指定']);
// 画像ファイル以外
$file = UploadedFile::fake()->image('test.txt');
$this->post($this->_url, ['image' => $file])->assertInvalid(['image' => '製品画像には、画像を指定']);
// jpg, png以外
$file = UploadedFile::fake()->image('test.gif');
$this->post($this->_url, ['image' => $file])->assertInvalid(['image' => '製品画像には、以下のファイルタイプを指定してください。jpg, png']);
// ファイルサイズ5120キロバイトより大きい
$file = UploadedFile::fake()->image('test.png')->size(ProductConsts::IMAGE_FILE_MAX + 1);
$this->post($this->_url, ['image' => $file])->assertInvalid(['image' => '製品画像は、'. ProductConsts::IMAGE_FILE_MAX . ' KB以下']);
// 正常
$file = UploadedFile::fake()->image('test.png');
$this->post($this->_url, ['image' => $file])->assertValid('image');
}
Storage::fake(‘public’); によって、ストレージの疑似環境 を作成することができる。実際のファイルシステムに影響を与えず、仮想のストレージ上でファイルの操作をテストできるようになる。
UploadedFile::fake()->image(‘ファイル名’); でテスト用の偽ファイルを作成し、ファイルアップロード処理を行ったかのように振る舞うオブジェクトを作成することができる。任意の拡張子を含めたファイル名を指定することで拡張子のバリデーションに使える。このメソッドの後に size() メソッドを続けることで、任意のファイルサイズを指定することが可能になりファイルサイズのバリデーションに使える。
次はアップロードしたファイルが存在するかのテスト
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/** @test */
public function 画像ファイル存在チェック(): void
{
$this->actingAs($this->_admin, 'admin');
// 正常レコード
Storage::fake('public');
$fileName = 'test_product_image.png';
// ダミーファイルを作成(実際には物理ファイルは作られない)
$file = UploadedFile::fake()->image($fileName);
$data = [
'first_category_id' => $this->_firstCategories[0]->id,
'second_category_id' => $this->_secondCategories[0]->id,
'tag_ids' => [$this->_tags[0]->id, $this->_tags[1]->id],
'name' => 'test1',
'image' => $file,
'detail' => fake()->sentence(),
'release_flg' => ProductConsts::RELEASE_FLG_ON,
];
$response = $this->from(route('admin.product.create'))->post($this->_url, $data);
// リダイレクトの確認
$response->assertRedirect(route('admin.product.index'));
// セッションにメッセージが存在するか確認
$response->assertSessionHas('msg_success', '製品情報を登録しました。');
Storage::disk('public')->assertExists(ProductConsts::IMAGE_FILE_DIR . '/' . $file->hashName());
}
なぜか私の環境のVScodeでは assertExists() メソッドがエラー表示になるけどちゃんとテストは成功する。今回の画像ファイルアップロード処理では、アップロードされた画像ファイルは $image->storeAs(ProductConsts::IMAGE_FILE_DIR, $fileName, ‘public’); で storage/app/public/product ディレクトリ以下にファイル名をハッシュ化して保存される。よって、assertExists() メソッドを使う場合は上記のようにディレクトリを指定する必要があった。
編集処理で新しい画像ファイルをアップロードした場合は、元の画像ファイルが削除される仕様となっているのでその処理をテストする必要がある。
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/** @test */
public function 画像ありアップデート処理():void
{
$this->actingAs($this->_admin, 'admin');
Storage::fake('public');
$this->_newFileName = 'new_test_product_image.png';
// ダミーファイルを作成(実際には物理ファイルは作られない)
$file = UploadedFile::fake()->image($this->_newFileName);
// 画像あり
$newData = [
'first_category_id' => $this->_firstCategories[1]->id,
'second_category_id' => $this->_secondCategories[1]->id,
'tag_ids' => [$this->_tags[0]->id, $this->_tags[1]->id],
'name' => '更新後のテスト商品',
'image' => $file,
'detail' => 'これは更新後の商品詳細です。',
'release_flg' => ProductConsts::RELEASE_FLG_OFF,
];
// PUTリクエストで更新
$response = $this->from(route('admin.product.edit', $this->_product))->put($this->_url, $newData);
// リダイレクトの確認
$response->assertRedirect(route('admin.product.show', $this->_product));
// セッションにメッセージが存在するか確認
$response->assertSessionHas('msg_success', '製品情報を編集しました。');
// 元の画像ファイルは削除され、新しいファイルが保存される
Storage::disk('public')->assertMissing(str_replace('storage/', '', $this->_product->image));
Storage::disk('public')->assertExists(ProductConsts::IMAGE_FILE_DIR . '/' . $file->hashName());
}
assertMissing() メソッドは assertExists() メソッドと同じようにディレクトリを指定するために、$this->_product->imageからいらない文字列を取り除いている。
CSVファイルアップロード
まずはCSVファイルアップロードのバリデーションテストについて
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/** @test */
public function バリデーション(): void
{
$this->actingAs($this->_admin, 'admin');
// CSVファイル
// ファイル準備
Storage::fake('public');
// 空
$this->post($this->_url, ['csv_file' => ''])->assertInvalid(['csv_file' => 'CSVファイルは必須']);
// ファイル以外
$this->post($this->_url, ['csv_file' => 'aaa'])->assertInvalid(['csv_file' => 'CSVファイルには、ファイル形式を指定']);
// テキストファイル以外
$file = UploadedFile::fake()->image('test.jpg');
$this->post($this->_url, ['csv_file' => $file])->assertInvalid(['csv_file' => 'CSVファイルには、以下のファイルタイプを指定してください。text/plain, text/csv']);
// ファイルサイズ1024キロバイトより大きい
$file = UploadedFile::fake()->create('test.csv', SecondCategoryConsts::CSV_FILE_MAX + 1);
$this->post($this->_url, ['csv_file' => $file])->assertInvalid(['csv_file' => 'CSVファイルは、'. SecondCategoryConsts::CSV_FILE_MAX . ' KB以下']);
// 正常
$file = UploadedFile::fake()->create('test.csv');
$this->post($this->_url, ['csv_file' => $file])->assertValid('csv_file');
}
CSVファイルアップロードのバリデーション自体は画像ファイルのときと似たような感じ。ただ、画像ファイル以外のファイルを作成するときは image() メソッドではなく create() メソッドを使う。
ファイルアップロード以外に、CSVファイルを読み込んでいる処理の中でもバリデーションを行っている場合など、テストしたい内容のCSVファイルを作成する必要が出てくる。
use Illuminate\Http\UploadedFile;
//use Illuminate\Support\Facades\Storage;
/** @test */
public function ファイル内ヘッダーバリデーション(): void
{
$this->actingAs($this->_admin, 'admin');
// 実ファイルを扱うため今回は使わない
//Storage::fake('public');
// ヘッダー行ミス
$content = <<<EOF
大カテゴリ名a,中カテゴリ名a
"test1", "test1test1"
"test2", "test2test2"
EOF;
$file = UploadedFile::fake()->createWithContent('test.csv', $content);
$response = $this->from(route('admin.second_category.csv_upload'))->post($this->_url, [
'code' => SecondCategoryConsts::CSV_CODE_UTF8,
'csv_file' => $file
]);
$response->assertInvalid(['csv_file' => '1行目:ヘッダーの項目名が違っています。']);
// リダイレクト
$response->assertRedirect(route('admin.second_category.csv_upload'));
}
そのような場合は createWithContent() メソッドを使う。CSVファイルの中身はPHPのヒアドキュメント構文(<<<EOFの箇所)を使うと複数行の文字列を扱うことが楽にできる。EOF という識別子は自由に名付けてOKで CSV, DATA, EOT なども使える。注意点として、終了識別子(例:EOF;)の前に空白やタブを入れてはいけない。左端に合わせて書く必要がある。また、今回のテストは実ファイルを扱うため Storage::fake(‘public’); を使わない。実際のストレージにファイルを保存しないようにするための機能であり、使ってしまうとCSVファイルを読み込む SplFileObject で実ファイルが読み込めずエラーが起きてしまう。
CSVダウンロード
/** @test */
public function CSVダウンロード処理(): void
{
$this->actingAs($this->_admin, 'admin');
// 一覧画面でCSVダウンロードボタン押下でCSVダウンロードにリダイレクト
$response = $this->from(route('admin.contact.index'))->get(route('admin.contact.index', ['csv_export' => 'csv_export']));
$response->assertRedirect(route('admin.contact.csv_export'));
// 直接アクセスしても検索条件なしでCSV出力される
$response = $this->get(route('admin.contact.csv_export'));
$response->assertOk();
// Content-Type が CSV であることを確認
$response->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
// Content-Disposition にファイル名が設定されていることを確認
$fileName = 'お問い合わせ.csv';
$encodedFileName = rawurlencode($fileName);
$response->assertHeader('Content-Disposition', "attachment; filename=" . $fileName . "; filename*=UTF-8''" . $encodedFileName);
// レスポンスの内容を取得
// 出力バッファを開始
ob_start();
// ストリームの内容を取得
$response->sendContent();
// バッファ内容を取得&クリア
$csvContent = ob_get_clean();
// CSV のヘッダーとデータを確認
$head = "お問い合わせNO,投稿日,氏名,メールアドレス,お問い合わせ内容,ステータス\n";
$record_1 = "000000000001,2024年03月25日17時17分17秒," . $this->_users[0]->name . "," . $this->_users[0]->email . "," . "おはよう1111ございます。,未対応\n";
$record_2 = "000000000002,2024年05月25日17時17分17秒," . $this->_users[0]->name . "," . $this->_users[0]->email . "," . "こんに1112ちは。,対応中\n";
$record_3 = "000000000003,2025年03月25日17時17分17秒," . $this->_users[1]->name . "," . $this->_users[1]->email . "," . "こんば1113んは。,対応済\n";
$expectedCsv = $head . $record_1 . $record_2 . $record_3;
$this->assertEquals($expectedCsv, mb_convert_encoding($csvContent, 'UTF-8', 'SJIS'));
}
テストの工程を分解して説明する。
本システムのお問い合わせCSVダウンロードは一覧画面から name=”csv_export” value=”csv_export” 属性を持つCSVダウンロードボタンを押すことでリダイレクトされて開始されるため、まずはリダイレクトのためのパラメーターを持たせてリダイレクトが実行されるか確認する。
// 一覧画面でCSVダウンロードボタン押下でCSVダウンロードにリダイレクト
$response = $this->from(route('admin.contact.index'))->get(route('admin.contact.index', ['csv_export' => 'csv_export']));
$response->assertRedirect(route('admin.contact.csv_export'));
次にアクセスしたときに返ってくるヘッダー情報が実装した仕様と一致するか確認する。実際にヘッダー情報を作成している箇所のソースコードを参考にテストを記述する。
// Content-Type が CSV であることを確認
$response->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
// Content-Disposition にファイル名が設定されていることを確認
$fileName = 'お問い合わせ.csv';
$encodedFileName = rawurlencode($fileName);
$response->assertHeader('Content-Disposition', "attachment; filename=" . $fileName . "; filename*=UTF-8''" . $encodedFileName);
今回のCSVダウンロード処理では streamDownload() メソッドのストリーム形式を使用しているので、$response->getContent() ではファイルの中身を取得できない。ストリーム形式の場合はob_start();, 、$response->sendContent(); 、$csvContent = ob_get_clean(); の3点セットを使う。
// レスポンスの内容を取得
// 出力バッファを開始
ob_start();
// ストリームの内容を取得
$response->sendContent();
// バッファ内容を取得&クリア
$csvContent = ob_get_clean();
最後にCSVファイルはSJISに文字コードを変換して出力されているので、確認用のレコードもSJISに変換して同じ内容か比較している。
// CSV のヘッダーとデータを確認
$head = "お問い合わせNO,投稿日,氏名,メールアドレス,お問い合わせ内容,ステータス\n";
$record_1 = "000000000001,2024年03月25日17時17分17秒," . $this->_users[0]->name . "," . $this->_users[0]->email . "," . "おはよう1111ございます。,未対応\n";
$record_2 = "000000000002,2024年05月25日17時17分17秒," . $this->_users[0]->name . "," . $this->_users[0]->email . "," . "こんに1112ちは。,対応中\n";
$record_3 = "000000000003,2025年03月25日17時17分17秒," . $this->_users[1]->name . "," . $this->_users[1]->email . "," . "こんば1113んは。,対応済\n";
$expectedCsv = $head . $record_1 . $record_2 . $record_3;
$this->assertEquals($expectedCsv, mb_convert_encoding($csvContent, 'UTF-8', 'SJIS'));
メール送信
use Illuminate\Support\Facades\Mail;
use App\Mail\Contact as ContactMail;
/** @test */
public function 正常な処理(): void
{
$this->actingAs($this->_web, 'web');
// メール送信チェック
Mail::fake();
// メールが送られていないことを確認
Mail::assertNothingSent();
$this->from(route('contact.create'))->post(route('contact.confirm'), ['body' => 'aaa']);
$response = $this->from(route('contact.confirm'))->post(route('contact.store'), ['submit' => ' submit']);
// メッセージが指定したユーザーに届いたことをアサート
Mail::assertSent(ContactMail::class, function ($mail) {
return $mail->hasTo('test1@test.co.jp');
});
// メールが1回送信されたことをアサート
Mail::assertSent(ContactMail::class, 1);
}
Mail::fake(); はファイルアップロードのテストで使った Storage::fake(‘public’); のメール版ととらえてとよい。実際にメールが送信されないようにメール送信を偽装している。Mail::assertNothingSent(); はその時点でメールが送信されていないことを確認している。
// メッセージが指定したユーザーに届いたことをアサート
Mail::assertSent(ContactMail::class, function ($mail) {
return $mail->hasTo('test1@test.co.jp');
});
ContactMail クラスのメールが送信されたことを確認し、さらに hasTo() で送信先が test1@test.co.jp であることをチェックしている。
// メールが1回送信されたことをアサート
Mail::assertSent(ContactMail::class, 1);
ContactMail クラスのメールががちょうど1通のみ送信されたことを確認している。
入力画面から確認画面を通して完了画面に遷移
今回実装したシステムでは入力画面からバリデーションを行い入力エラーがなかった場合はそのままデータベースに保存して一覧画面にリダイレクトして処理が完了していたが、お問い合わせの画面のみ入力画面から確認画面を通して完了画面に遷移する仕様になっている。よって、以下のようなテストケースが考えられる。
- 入力画面から確認画面へ遷移する際のバリデーション
- 確認画面から登録処理へ遷移する際のバリデーション
- 確認画面から入力画面へ戻る遷移
- 確認画面から登録処理を挟んで完了画面への遷移
入力画面から確認画面へ遷移する際のバリデーションのテスト
/** @test */
public function バリデーション():void
{
$this->actingAs($this->_web, 'web');
// リダイレクト
$this->from(route('contact.create'))->post(route('contact.confirm'), [
'body' => ''
])->assertRedirect(route('contact.create'));
// お問い合わせ内容
// 空
$this->post(route('contact.confirm'), ['body' => ''])->assertInvalid(['body' => 'お問い合わせ内容は必須項目です。']);
// 2000文字より多い
$this->post(route('contact.confirm'), ['body' => str_repeat('a', ContactConsts::BODY_LENGTH_MAX + 1)])->assertInvalid(['body' => 'お問い合わせ内容の文字数は、2000文字以下である必要があります。']);
}
確認画面から登録処理へ遷移する際のバリデーションのテスト
/** @test */
public function 確認画面バリデーション(): void
{
$this->actingAs($this->_web, 'web');
// 入力値セッションなし
$response = $this->from(route('contact.confirm'))->post(route('contact.store'), ['body' => '']);
$response->assertSessionHas('msg_failure', 'セッション期限が切れました。');
$response->assertRedirect(route('top'));
// セッションあり
// リダイレクト
$response = $this->from(route('contact.confirm'))->withSession(['input' => ['body' => '']])->post(route('contact.store'));
$response->assertRedirect(route('contact.create'));
// お問い合わせ内容
// 空
$this->withSession(['input' => ['body' => '']])->post(route('contact.store'))->assertInvalid(['body' => 'お問い合わせ内容は必須項目です。']);
// 2000文字より多い
$this->withSession(['input' => ['body' => str_repeat('a', ContactConsts::BODY_LENGTH_MAX + 1)]])->post(route('contact.store'))->assertInvalid(['body' => 'お問い合わせ内容の文字数は、2000文字以下である必要があります。']);
}
システムの仕様では、確認画面から登録処理へ遷移するときのバリデーションでセッション情報がなかった場合はトップ画面へ、セッション情報はあるがバリデーションで入力エラーだった場合は乳六画面へ遷移するように実装しているのでそのテストを行っている。
確認画面から入力画面へ戻る遷移のテスト
/** @test */
public function 正常な画面遷移(): void
{
$this->actingAs($this->_web, 'web');
// 入力画面から確認画面
$response = $this->from(route('contact.create'))->post(route('contact.confirm'), ['body' => 'aaa']);
$response->assertValid();
$response->assertStatus(200)->assertSee('お問い合わせ確認');
// 確認画面から入力画面
$response = $this->from(route('contact.confirm'))->post(route('contact.store'), ['body' => 'aaa']);
$response->assertRedirect(route('contact.create'));
// 実際にリダイレクト後のページに GET リクエストを送って、その内容を確認
$response = $this->get(route('contact.create'));
$response->assertSee('aaa'); // "aaa" が表示されているか確認
}
以下の方法でもいいのではないか?と思って試すとエラーが出る。
// 確認画面から入力画面
$response = $this->from(route('contact.confirm'))->post(route('contact.store'), ['body' => 'aaa']);
$response->assertRedirect(route('contact.create'))->assertSee('aaa');
assertRedirect() メソッドはリダイレクトレスポンス(302など)を対象にしており、そのリダイレクト後のページの中身(HTML)は含まれていないからである。よってGETリクエストを使ってリダイレクト先をアクセスしなおす必要がある。
確認画面から登録処理を挟んで完了画面への遷移のテスト
/** @test */
public function 正常な処理(): void
{
$this->actingAs($this->_web, 'web');
// メール送信チェック
Mail::fake();
// メールが送られていないことを確認
Mail::assertNothingSent();
$this->from(route('contact.create'))->post(route('contact.confirm'), ['body' => 'aaa']);
$response = $this->from(route('contact.confirm'))->post(route('contact.store'), ['submit' => ' submit']);
// リダイレクト
$response->assertRedirect(route('contact.complete'));
$response = $this->get(route('contact.complete'));
$response->assertSee('000000000001'); // お問い合わせ番号が表示されているか確認
// DB存在チェック
$this->assertDatabaseHas('contacts', [
'no' => '000000000001',
'body' => 'aaa',
]);
// メッセージが指定したユーザーに届いたことをアサート
Mail::assertSent(ContactMail::class, function ($mail) {
return $mail->hasTo('test1@test.co.jp');
});
// メールが1回送信されたことをアサート
Mail::assertSent(ContactMail::class, 1);
// もう一度アクセスしなおすとセッション切れでトップページへ遷移
$response = $this->get(route('contact.complete'));
$response->assertRedirect(route('top'));
// セッションにメッセージが存在するか確認
$response->assertSessionHas('msg_failure', 'セッション期限が切れました。');
}
name=”submit” value=”submit”属性のボタンを押した場合のみ登録処理へ遷移して登録処理を行い完了画面へリダイレクトする仕様なので上記のようなテストになる。
コメント