実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その46)
0. 初めに
こんにちは!
今日もWebアプリケーション開発の解説をしていきます。
ついに
先週はさぼってしまってすみませんでした。
このシリーズで開発している、LabChartですがそのデプロイに苦戦しておりました。
ようやくデプロイすることができましたので、この記事を読んでくれているあなたに特別に画面をお見せいたします!
いつも読んでくださってありがとうございます。
これからもよろしくお願いします。
今日で、テスト・デバッグ編も第4回目となりました。
前回まででUnitテストを完了したので、今日からはFeatureテストを作成していきたいと思います。
1. ブランチ運用
例によって、developブランチを最新化させて、新規ブランチを切って作業をしていきます!
ブランチ名は、test/feature/controllersとかにしましょう。
なお、前回使ったrefactor/backend/model-method-typeブランチは不要なので削除しましょう。
2. レビューコントローラー
FeatureテストはUnitテストとは異なり、DBを使っていきます!
また、コントローラーとポリシー・モデルの残り(Unitテストで対応しなかったもの)を作っていく予定で、今日はコントローラーを作ります!
まずは、レビューコントローラーからです。
これまでテストケースを作るときにファイルを手作業で作成していましたが、コマンドで作る方が楽なのでそうしましょう(今更ですがw)。
2.1 ファイル作成
実行コマンド
$ php artisan make:test Controllers/ReviewControllerTest
<?php
namespace Tests\Feature\Controllers;
use App\Models\Lab;
use App\Models\Review;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReviewControllerTest extends TestCase
{
use RefreshDatabase;
public function test_認証済みユーザーがレビューを投稿できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act
$response = $this->actingAs($user)
->post(route('reviews.store', $lab), [
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect(route('labs.show', $lab));
$this->assertDatabaseHas('reviews', [
'user_id' => $user->id,
'lab_id' => $lab->id,
'mentorship_style' => 3,
]);
}
public function test_未認証ユーザーはレビューを投稿できない(): void
{
// Arrange
$lab = Lab::factory()->create();
// Act
$response = $this->post(route('reviews.store', $lab), [
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect('/');
}
public function test_バリデーションエラーで投稿が拒否される(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act(範囲外の値 6 を送信)
$response = $this->actingAs($user)
->post(route('reviews.store', $lab), [
'mentorship_style' => 6,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertSessionHasErrors('mentorship_style');
$this->assertDatabaseMissing('reviews', [
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
}
public function test_評価値0はバリデーションエラーになる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act(prepareForValidation で 0 → null に変換 → required で弾かれる)
$response = $this->actingAs($user)
->post(route('reviews.store', $lab), [
'mentorship_style' => 0,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertSessionHasErrors('mentorship_style');
$this->assertDatabaseMissing('reviews', [
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
}
public function test_自分のレビューを更新できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $user->id,
'lab_id' => $lab->id,
'mentorship_style' => 3,
]);
// Act
$response = $this->actingAs($user)
->put(route('reviews.update', $review), [
'mentorship_style' => 5,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect(route('labs.show', $lab));
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
'mentorship_style' => 5,
]);
}
public function test_バリデーションエラーでレビューを更新できない(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $user->id,
'lab_id' => $lab->id,
'mentorship_style' => 3,
]);
// Act(範囲外の値 6 を送信)
$response = $this->actingAs($user)
->put(route('reviews.update', $review), [
'mentorship_style' => 6,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertSessionHasErrors('mentorship_style');
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
'mentorship_style' => 3,
]);
}
public function test_他人のレビューは更新できない(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $otherUser->id,
'lab_id' => $lab->id,
]);
// Act
$response = $this->actingAs($user)
->put(route('reviews.update', $review), [
'mentorship_style' => 5,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertForbidden();
}
public function test_自分のレビューを削除できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
// Act
$response = $this->actingAs($user)
->delete(route('reviews.destroy', $review));
// Assert
$response->assertRedirect(route('labs.show', $lab));
$this->assertDatabaseMissing('reviews', [
'id' => $review->id,
]);
}
public function test_他人のレビューは削除できない(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $otherUser->id,
'lab_id' => $lab->id,
]);
// Act
$response = $this->actingAs($user)
->delete(route('reviews.destroy', $review));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
]);
}
}
2.2 投稿
public function test_認証済みユーザーがレビューを投稿できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act
$response = $this->actingAs($user)
->post(route('reviews.store', $lab), [
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect(route('labs.show', $lab));
$this->assertDatabaseHas('reviews', [
'user_id' => $user->id,
'lab_id' => $lab->id,
'mentorship_style' => 3,
]);
}
public function test_未認証ユーザーはレビューを投稿できない(): void
{
// Arrange
$lab = Lab::factory()->create();
// Act
$response = $this->post(route('reviews.store', $lab), [
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect('/');
}
Featureテストは、Unitテストとは異なり、PHPUnitの機能をLaravel風に拡張されたメソッドを使うことができます。
その一つがactingAsメソッドで、ログインしている状態を作り出すことができます。
assertRedirectメソッドも同様です。
こちらは、レスポンスが正しいリダイレクトであるときに成功とみなすアサーションです。
以下は、レビューコントローラーに作ったstoreメソッドです。
public function store(ReviewRatingRequest $request, Lab $lab): RedirectResponse
{
// ポリシーで認可をチェック
$this->authorize('create', [Review::class, $lab]);
$validated = $request->validated();
// バリデーション済みのデータを保存
$review = new Review();
$review->user_id = Auth::id();
$review->lab_id = $lab->id;
$review->mentorship_style = $validated['mentorship_style'];
$review->lab_atmosphere = $validated['lab_atmosphere'];
$review->achievement_activity = $validated['achievement_activity'];
$review->constraint_level = $validated['constraint_level'];
$review->facility_quality = $validated['facility_quality'];
$review->work_style = $validated['work_style'];
$review->student_balance = $validated['student_balance'];
$review->save();
return redirect()->route('labs.show', ['lab' => $lab])->with('success', 'レビューが保存されました。');
}
最後のreturn文の中で'labs.show'にリダイレクトするようにしていますね。
このメソッドの名前付きルーティングは、以下のweb.phpで定義した通り、'reviews.store'です。
Route::post(('/labs/{lab}/reviews'), [ReviewController::class, 'store'])->name('reviews.store');
一方で、テストケースの方は、以下のようにActとAssertで'reviews.store'と'labs.show'を呼び出しています。
// Act
$response = $this->actingAs($user)
->post(route('reviews.store', $lab), [
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect(route('labs.show', $lab));
つまり、コントローラーのメソッドを呼び出して、リダイレクト先が正しいかどうかをテストしているということになります!
さらに、assertDatabaseHasでデータが登録されているかをテストしています。
$this->assertDatabaseHas('reviews', [
'user_id' => $user->id,
'lab_id' => $lab->id,
'mentorship_style' => 3,
]);
ログインしていない場合は、そもそも作成モーダルが表示されないようにしていますが、念のためPOSTメソッドが飛んできた場合は、'/'にリダイレクトさせるようにしています。
public function test_未認証ユーザーはレビューを投稿できない(): void
{
// Arrange
$lab = Lab::factory()->create();
// Act
$response = $this->post(route('reviews.store', $lab), [
'mentorship_style' => 3,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect('/');
}
そもそもこれらは、Laravelのミドルウェアで制御しています。
そのため、ミドルウェアを使えるように正しく設定ができているか(ルーティングを書く場所が正しいかどうか)をテストしているということになります。
※決して、ミドルウェアの機能自体をテストしているわけではないのでご注意ください。
さらに、バリデーションのテストもしています。
public function test_バリデーションエラーで投稿が拒否される(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act(範囲外の値 6 を送信)
$response = $this->actingAs($user)
->post(route('reviews.store', $lab), [
'mentorship_style' => 6,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertSessionHasErrors('mentorship_style');
$this->assertDatabaseMissing('reviews', [
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
}
public function test_評価値0はバリデーションエラーになる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act(prepareForValidation で 0 → null に変換 → required で弾かれる)
$response = $this->actingAs($user)
->post(route('reviews.store', $lab), [
'mentorship_style' => 0,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertSessionHasErrors('mentorship_style');
$this->assertDatabaseMissing('reviews', [
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
}
繰り返しになりますが、バリデーション機能自体はLaravelにもとから備わっているため、本アプリケーションのテストケースとしてはそれをテストする必要はありません。
あくまで、バリデーションのルールが正しく適用されるかどうかをテストするだけで十分です。
一方で、評価値0の場合のバリデーションは、あえてLaravelのデフォルトのルールを上書きして新たにバリデーションを作成したため、これだけは別途テストする必要があります。
// 0 => nullという本アプリ独自のルールをリクエストクラスで定義していた
protected function prepareForValidation(): void
{
$ratingFields = [
'mentorship_style',
'lab_atmosphere',
'achievement_activity',
'constraint_level',
'facility_quality',
'work_style',
'student_balance',
];
$converted = [];
foreach ($ratingFields as $field) {
if ($this->has($field) && (int) $this->input($field) === 0) {
$converted[$field] = null;
}
}
if ($converted !== []) {
$this->merge($converted);
}
}
2.3 更新
public function test_自分のレビューを更新できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $user->id,
'lab_id' => $lab->id,
'mentorship_style' => 3,
]);
// Act
$response = $this->actingAs($user)
->put(route('reviews.update', $review), [
'mentorship_style' => 5,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertRedirect(route('labs.show', $lab));
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
'mentorship_style' => 5,
]);
}
public function test_バリデーションエラーでレビューを更新できない(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $user->id,
'lab_id' => $lab->id,
'mentorship_style' => 3,
]);
// Act(範囲外の値 6 を送信)
$response = $this->actingAs($user)
->put(route('reviews.update', $review), [
'mentorship_style' => 6,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertSessionHasErrors('mentorship_style');
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
'mentorship_style' => 3,
]);
}
public function test_他人のレビューは更新できない(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $otherUser->id,
'lab_id' => $lab->id,
]);
// Act
$response = $this->actingAs($user)
->put(route('reviews.update', $review), [
'mentorship_style' => 5,
'lab_atmosphere' => 4,
'achievement_activity' => 5,
'constraint_level' => 2,
'facility_quality' => 4,
'work_style' => 3,
'student_balance' => 4,
]);
// Assert
$response->assertForbidden();
}
更新は、本人かそれ以外かで認可が分かれているのでそれもassertForbiddenでテストします。
2.4 削除
public function test_自分のレビューを削除できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
// Act
$response = $this->actingAs($user)
->delete(route('reviews.destroy', $review));
// Assert
$response->assertRedirect(route('labs.show', $lab));
$this->assertDatabaseMissing('reviews', [
'id' => $review->id,
]);
}
public function test_他人のレビューは削除できない(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$lab = Lab::factory()->create();
$review = Review::factory()->create([
'user_id' => $otherUser->id,
'lab_id' => $lab->id,
]);
// Act
$response = $this->actingAs($user)
->delete(route('reviews.destroy', $review));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
]);
}
削除も同様です。
バリデーションはありません。
実行
実行コマンド
$ php artisan test --filter=ReviewControllerTest
このコマンドを実行することで、少し前に設定したSQLiteが起動します!
これによって、DB登録のテストができるというわけです。
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
3. コメントコントローラー
次は、コメントコントローラーです。
同様に作成・実行していきましょう。
実行コマンド
$ php artisan make:test Controllers/CommentControllerTest
<?php
namespace Tests\Feature\Controllers;
use App\Models\Comment;
use App\Models\Lab;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CommentControllerTest extends TestCase
{
use RefreshDatabase;
// -------------------------
// store
// -------------------------
public function test_認証済みユーザーがコメントを投稿できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act
$response = $this->actingAs($user)
->postJson(route('comments.store', $lab), [
'content' => 'テストコメントです。',
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas('comments', [
'user_id' => $user->id,
'lab_id' => $lab->id,
'content' => 'テストコメントです。',
]);
}
public function test_未認証ユーザーはコメントを投稿できない(): void
{
// Arrange
$lab = Lab::factory()->create();
// Act
$response = $this->postJson(route('comments.store', $lab), [
'content' => 'テストコメントです。',
]);
// Assert
$response->assertUnauthorized();
}
public function test_バリデーションエラーでコメントを投稿できない(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act(content が空)
$response = $this->actingAs($user)
->postJson(route('comments.store', $lab), [
'content' => '',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors('content');
$this->assertDatabaseMissing('comments', [
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
}
// -------------------------
// update
// -------------------------
public function test_自分のコメントを更新できる(): void
{
// Arrange
$user = User::factory()->create();
$comment = Comment::factory()->create([
'user_id' => $user->id,
'content' => '更新前のコメント',
]);
// Act
$response = $this->actingAs($user)
->putJson(route('comments.update', $comment), [
'content' => '更新後のコメント',
]);
// Assert
$response->assertOk();
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
'content' => '更新後のコメント',
]);
}
public function test_バリデーションエラーでコメントを更新できない(): void
{
// Arrange
$user = User::factory()->create();
$comment = Comment::factory()->create([
'user_id' => $user->id,
'content' => '更新前のコメント',
]);
// Act(content が空)
$response = $this->actingAs($user)
->putJson(route('comments.update', $comment), [
'content' => '',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors('content');
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
'content' => '更新前のコメント',
]);
}
public function test_他人のコメントは更新できない(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$comment = Comment::factory()->create([
'user_id' => $otherUser->id,
]);
// Act
$response = $this->actingAs($user)
->putJson(route('comments.update', $comment), [
'content' => '書き換えようとしたコメント',
]);
// Assert
$response->assertForbidden();
}
// -------------------------
// destroy
// -------------------------
public function test_自分のコメントを削除できる(): void
{
// Arrange
$user = User::factory()->create();
$comment = Comment::factory()->create([
'user_id' => $user->id,
]);
// Act
$response = $this->actingAs($user)
->deleteJson(route('comments.destroy', $comment));
// Assert
$response->assertOk();
$this->assertDatabaseMissing('comments', [
'id' => $comment->id,
]);
}
public function test_管理者は他人のコメントを削除できる(): void
{
// Arrange
$admin = User::factory()->create(['is_admin' => true]);
$otherUser = User::factory()->create();
$comment = Comment::factory()->create([
'user_id' => $otherUser->id,
]);
// Act
$response = $this->actingAs($admin)
->deleteJson(route('comments.destroy', $comment));
// Assert
$response->assertOk();
$this->assertDatabaseMissing('comments', [
'id' => $comment->id,
]);
}
public function test_他人のコメントは削除できない(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$comment = Comment::factory()->create([
'user_id' => $otherUser->id,
]);
// Act
$response = $this->actingAs($user)
->deleteJson(route('comments.destroy', $comment));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
]);
}
// -------------------------
// index
// -------------------------
public function test_コメント一覧を取得できる(): void
{
// Arrange
$lab = Lab::factory()->create();
Comment::factory()->count(3)->create(['lab_id' => $lab->id]);
// Act
$response = $this->getJson(route('comments.index', $lab));
// Assert
$response->assertOk();
$response->assertJsonStructure([
'comments' => [['id', 'content', 'user']],
'hasMore',
'nextCursor',
]);
$response->assertJsonCount(3, 'comments');
}
public function test_カーソルを使って続きを取得できる(): void
{
// Arrange
$lab = Lab::factory()->create();
// 新しい順に3件作成(idが小さい順に作成)
$comments = Comment::factory()->count(3)->create(['lab_id' => $lab->id]);
// 最も新しいコメントをカーソルとして指定すると、それより古い2件が返る
$cursor = $comments->last()->id;
// Act
$response = $this->getJson(route('comments.index', ['lab' => $lab, 'cursor' => $cursor]));
// Assert
$response->assertOk();
// カーソル指定のコメント自体は含まれず、それより古い2件が返る
$response->assertJsonCount(2, 'comments');
$response->assertJsonPath('hasMore', false);
}
public function test_コメントがlimitより多い場合はhasMoreがtrueになる(): void
{
// Arrange
$lab = Lab::factory()->create();
// デフォルト limit=20 を超える21件作成
Comment::factory()->count(21)->create(['lab_id' => $lab->id]);
// Act
$response = $this->getJson(route('comments.index', $lab));
// Assert
$response->assertOk();
$response->assertJsonPath('hasMore', true);
$response->assertJsonCount(20, 'comments');
}
public function test_limitを指定して件数を絞れる(): void
{
// Arrange
$lab = Lab::factory()->create();
Comment::factory()->count(5)->create(['lab_id' => $lab->id]);
// Act(limit=3 を指定)
$response = $this->getJson(route('comments.index', ['lab' => $lab, 'limit' => 3]));
// Assert
$response->assertOk();
$response->assertJsonCount(3, 'comments');
$response->assertJsonPath('hasMore', true);
}
public function test_コメントが0件の場合は空配列が返る(): void
{
// Arrange
$lab = Lab::factory()->create();
// Act
$response = $this->getJson(route('comments.index', $lab));
// Assert
$response->assertOk();
$response->assertJsonCount(0, 'comments');
$response->assertJsonPath('hasMore', false);
$response->assertJsonPath('nextCursor', null);
}
}
3.1 作成
store、update、destroy、indexと4つもメソッドがあるので大変ですが、落ち着いていきましょう~。
準備としてコメントファクトリーを作っておき、モデルからも利用できるようにしておきます。
database/factories/CommentFactory.php
<?php
namespace Database\Factories;
use App\Models\Lab;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Comment>
*/
class CommentFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'lab_id' => Lab::factory(),
'content' => fake()->realText(200),
];
}
}
app/Models/Comment.php
use Illuminate\Database\Eloquent\Factories\HasFactory; // 追加
class Comment extends Model
{
use HasFactory; // 追加
コメントの投稿は、ページリダイレクトではなくJSONを返すという違いがありました。
201はステータスコードで作成成功を意味していましたね。
他のコードも貼っておくので確認しておきましょう。
| コード | メソッド | 意味 |
|---|---|---|
| 201 | assertStatus(201) | 作成成功 |
| 200 | assertOk() | 成功(assertStatus(200)と同じ) |
| 401 | assertUnauthorized() | 未認証(ログインしていない) |
| 403 | assertForbidden() | 認可エラー(他人のリソース) |
| 422 | assertStatus(422) | バリデーションエラー |
後は、管理者についての権限も考慮したテストケースも作成しています。
3.2 読み取り
indexメソッドは少しだけ特殊なので解説を入れておきます!
/**
* コメントの一覧を取得する
*/
public function index(Lab $lab, Request $request): JsonResponse
{
// 取得上限数を定義する
$limit = min((int) $request->input('limit', 20), 50);
// カーソル(どこまで進んでいるか)を定義
$cursor = $request->input('cursor');
// コメントのクエリを作成
$query = $lab->comments()
->with('user')
->latest()
->orderBy('id', 'desc');
// データの開始位置をカーソルで指定
// 念のため、created_at と id の組み合わせでカーソルを処理
if ($cursor) {
$cursorComment = Comment::find($cursor);
if ($cursorComment) {
$query->where(function ($q) use ($cursorComment) {
$q->where('created_at', '<', $cursorComment->created_at)
->orWhere(function ($q2) use ($cursorComment) {
$q2->where('created_at', '=', $cursorComment->created_at)
->where('id', '<', $cursorComment->id);
});
});
}
}
$comments = $query->limit($limit + 1)->get();
// 次のページがあるかどうかを判定
$hasMore = $comments->count() > $limit;
if ($hasMore) {
$comments = $comments->slice(0, $limit);
}
return response()->json([
'comments' => $comments->values(),
'hasMore' => $hasMore,
'nextCursor' => $comments->last()?->id,
]);
}
test_コメント一覧を取得できる()から見ていきましょう。
// Arrange
$lab = Lab::factory()->create();
Comment::factory()->count(3)->create(['lab_id' => $lab->id]);
count(3)で研究室に紐づけられたコメントが3件ある状態を作ります。
// Act
$response = $this->getJson(route('comments.index', $lab));
認証なしでデータを取得しています。
// Assert
$response->assertOk();
$response->assertJsonStructure([
'comments' => [['id', 'content', 'user']],
'hasMore',
'nextCursor',
]);
$response->assertJsonCount(3, 'comments');
3つのアサーションを当てています。
assertOkで通信成功を確認。
assertJsonStructureでJSONの形が正しいかを確認。
indexメソッドのreturn文に対応しています。
return response()->json([
'comments' => $comments->values(),
'hasMore' => $hasMore,
'nextCursor' => $comments->last()?->id,
]);
commentsには、id, contentとリレーションとしてuserが付きますね。
public function definition(): array
{
return [
'user_id' => User::factory(),
'lab_id' => Lab::factory(),
'content' => fake()->realText(200),
];
}
assertJsonCount(3, 'comments')で、comments の配列の中身がちょうど3件であることを確認しています。
二つ目のtest_カーソルを使って続きを取得できる()は少し難しいです。
public function test_カーソルを使って続きを取得できる(): void
{
// Arrange
$lab = Lab::factory()->create();
// 新しい順に3件作成(idが小さい順に作成)
$comments = Comment::factory()->count(3)->create(['lab_id' => $lab->id]);
// 最も新しいコメントをカーソルとして指定すると、それより古い2件が返る
$cursor = $comments->last()->id;
// Act
$response = $this->getJson(route('comments.index', ['lab' => $lab, 'cursor' => $cursor]));
// Assert
$response->assertOk();
// カーソル指定のコメント自体は含まれず、それより古い2件が返る
$response->assertJsonCount(2, 'comments');
$response->assertJsonPath('hasMore', false);
}
カーソルという考え方を用いて、コメントがどこまで取得できているかを管理していました。
まずは、先ほどと同様にコメントを3件用意して、idが最新の元を取得します。
// Arrange
$lab = Lab::factory()->create();
// 新しい順に3件作成(idが小さい順に作成)
$comments = Comment::factory()->count(3)->create(['lab_id' => $lab->id]);
// 最も新しいコメントをカーソルとして指定すると、それより古い2件が返る
$cursor = $comments->last()->id;
今度は、それを渡してあげます。
// Act
$response = $this->getJson(route('comments.index', ['lab' => $lab, 'cursor' => $cursor]));
カーソルが指定するコメントは含まれず、それよりも古いコメントすなわち2件のコメントがあればOKなはずです。
// Assert
$response->assertOk();
// カーソル指定のコメント自体は含まれず、それより古い2件が返る
$response->assertJsonCount(2, 'comments');
$response->assertJsonPath('hasMore', false);
assertJsonPathは特定のキーの値を検証します。
つまり、こうなっていればおK。
{ "hasMore": false }
コメントは全部で2件ですべて読み込み済みなので、'hasMore'がfalseです。
これがtrueになるバージョンと取得件数を絞れるパターン(フロントエンドは20件にしていたはずなので前者で十分とは思いますが...)も試しています。
あとは、コメント数が0件の時のテストもあります。
3.3 デバッグ
できたら、実行して見ましょう。
実行コマンド
$ php artisan test --filter=CommentControllerTest
一つ通らなかったです。
結論から言うと、コントローラーにバグがあったわけではなく、テスト用に用意したユーザーファクトリーに誤りがありました。
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'is_admin' => false, // ←これを追加
];
}
これがないせいで、コメントポリシーのdeleteが呼ばれた際にis_adminがundefinedになってしまいました。
// ユーザーがコメントを削除できるかどうかを判定
public function delete(User $user, Comment $comment)
{
// 投稿した本人もしくは管理者のみ削除可能
return $user->id === $comment->user_id || $user->is_admin();
}
テストケースが多いということもありますが、Unitテストの時と比べて実行時間が長くなっていることも感じてもらえればよいかなと思います。
4. ブックマークコントローラー
本日最後は、ブックマークコントローラーです!
4.1 作成
実行コマンド
$ php artisan make:test Controllers/BookmarkControllerTest
tests/Feature/Controllers/BookmarkControllerTest.php
<?php
namespace Tests\Feature\Controllers;
use App\Models\Bookmark;
use App\Models\Lab;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BookmarkControllerTest extends TestCase
{
use RefreshDatabase;
// -------------------------
// store
// -------------------------
public function test_認証済みユーザーがブックマークを登録できる(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
// Act
$response = $this->actingAs($user)
->post(route('bookmarks.store'), [
'lab_id' => $lab->id,
]);
// Assert
$response->assertRedirect(route('labs.show', $lab));
$this->assertDatabaseHas('bookmarks', [
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
}
public function test_未認証ユーザーはブックマークを登録できない(): void
{
// Arrange
$lab = Lab::factory()->create();
// Act
$response = $this->post(route('bookmarks.store'), [
'lab_id' => $lab->id,
]);
// Assert
$response->assertRedirect('/');
}
public function test_同じ研究室を重複してブックマークできない(): void
{
// Arrange
$user = User::factory()->create();
$lab = Lab::factory()->create();
Bookmark::factory()->create([
'user_id' => $user->id,
'lab_id' => $lab->id,
]);
// Act(同じ研究室に再度ブックマーク)
$response = $this->actingAs($user)
->post(route('bookmarks.store'), [
'lab_id' => $lab->id,
]);
// Assert(エラーメッセージ付きでリダイレクト)
$response->assertRedirect(route('labs.show', $lab));
$response->assertSessionHas('error');
$this->assertDatabaseCount('bookmarks', 1);
}
// -------------------------
// destroy
// -------------------------
public function test_自分のブックマークを削除できる(): void
{
// Arrange
$user = User::factory()->create();
$bookmark = Bookmark::factory()->create([
'user_id' => $user->id,
]);
// Act
$response = $this->actingAs($user)
->delete(route('bookmarks.destroy', $bookmark));
// Assert
$response->assertRedirect(route('labs.show', $bookmark->lab_id));
$this->assertDatabaseMissing('bookmarks', [
'id' => $bookmark->id,
]);
}
public function test_他人のブックマークは削除できない(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$bookmark = Bookmark::factory()->create([
'user_id' => $otherUser->id,
]);
// Act
$response = $this->actingAs($user)
->delete(route('bookmarks.destroy', $bookmark));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas('bookmarks', [
'id' => $bookmark->id,
]);
}
}
database/factories/BookmarkFactory.php
<?php
namespace Database\Factories;
use App\Models\Lab;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Bookmark>
*/
class BookmarkFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'lab_id' => Lab::factory(),
];
}
}
ブックマークモデルにはもともとuse HasFactory;があったので大丈夫です。
4.2 実行
先ほどのコメントコントローラーに比べればかなりシンプルだと思いますので、このまま実行してしまいましょう~!
実行コマンド
$ php artisan test --filter=BookmarkControllerTest
問題なさそうですね!
5. まとめ・次回予告
今回からFeaureテストということで、最コントローラーについてテストをしました!
レビューコントローラー、コメントコントローラー、ブックマークコントローラーについてのテストケース作成・実施・デバッグを完了させましたね。
次回は、続きとして、大学・学部・研究室コントローラーについてのテストケースを作成していく予定です!!
最後まで読んでくださってありがとうございました。
これまでの記事一覧
☆要件定義・設計編
☆環境構築編
☆バックエンド実装編
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~
- その17: バックエンド実装編⑪ ~排他制御・トランザクション処理~
- その18: バックエンド実装編⑫ ~マイページ機能作成~
- その19: バックエンド実装編⑬ ~管理者アカウント機能作成~
- その20: バックエンド実装編⑭ ~通知機能作成~
- その21: バックエンド実装編⑮ ~ソーシャルログイン機能作成~
☆フロントエンド実装編
- その22: フロントエンド実装編① ~メインコンテンツ領域作成~
- その23: フロントエンド実装編② ~ヘッダー作成~
- その24: フロントエンド実装編③ ~サイドバー作成~
- その25: フロントエンド実装編④ ~ホームページ作成~
- その26: フロントエンド実装編⑤ ~大学検索結果画面作成~
- その27: フロントエンド実装編⑥ ~大学詳細・学部一覧画面作成~
- その28: フロントエンド実装編⑦ ~学部詳細・研究室一覧画面作成~
- その29: フロントエンド実装編⑧前編 ~研究室詳細・レビュー画面作成前編~
- その29.5: フロントエンド実装編⑧後編 ~研究室詳細・レビュー画面作成後編~
- その30: フロントエンド実装編⑨ ~マイページ作成~
- その31: フロントエンド実装編⑩ ~パンくずリスト作成~
- その32: フロントエンド実装編⑪ ~ログイン・新規登録モーダル作成~
- その33: フロントエンド実装編⑫ ~大学作成・編集モーダル作成~
- その34: フロントエンド実装編⑬ ~学部作成・編集モーダル作成~
- その35: フロントエンド実装編⑭ ~研究室作成・編集モーダル作成~
- その36: フロントエンド実装編⑮ ~レビュー作成・編集モーダル作成~
- その37: フロントエンド実装編⑯ ~コメント一覧表示モーダル作成~
- その38: フロントエンド実装編⑰ ~編集履歴ページ・削除依頼ページ・通知ドロップダウン作成~
- その39: フロントエンド実装編⑱ ~トースト作成~
- その40: フロントエンド実装編⑲ ~退会ページ作成~
- その41: フロントエンド実装編⑳ ~ファビコン作成~
- その42: フロントエンド実装編㉑ ~レスポンシブデザイン対応~



