0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(テスト・デバッグ編④)~Featureテスト1[コントローラー①/レビュー/コメント/ブックマーク]~

0
Posted at

実務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 ファイル作成

実行コマンド

/var/www
$ php artisan make:test Controllers/ReviewControllerTest
\project-root\src\tests\Feature\Controllers\ReviewControllerTest.php
<?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メソッドです。

\project-root\src\app\Http\Controllers\ReviewController.php
    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'です。

\project-root\src\routes\web.php
    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でデータが登録されているかをテストしています。

\project-root\src\tests\Feature\Controllers\ReviewControllerTest.php
        $this->assertDatabaseHas('reviews', [
            'user_id' => $user->id,
            'lab_id' => $lab->id,
            'mentorship_style' => 3,
        ]);

ログインしていない場合は、そもそも作成モーダルが表示されないようにしていますが、念のためPOSTメソッドが飛んできた場合は、'/'にリダイレクトさせるようにしています。

\project-root\src\tests\Feature\Controllers\ReviewControllerTest.php
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のミドルウェアで制御しています。
そのため、ミドルウェアを使えるように正しく設定ができているか(ルーティングを書く場所が正しいかどうか)をテストしているということになります。
※決して、ミドルウェアの機能自体をテストしているわけではないのでご注意ください。

さらに、バリデーションのテストもしています。

\project-root\src\tests\Feature\Controllers\ReviewControllerTest.php
    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のデフォルトのルールを上書きして新たにバリデーションを作成したため、これだけは別途テストする必要があります。

\project-root\src\app\Http\Requests\ReviewRatingRequest.php
    // 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 更新

\project-root\src\tests\Feature\Controllers\ReviewControllerTest.php
    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 削除

\project-root\src\tests\Feature\Controllers\ReviewControllerTest.php
    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,
        ]);
    }

削除も同様です。
バリデーションはありません。

実行

実行コマンド

/var/www
$ php artisan test --filter=ReviewControllerTest

このコマンドを実行することで、少し前に設定したSQLiteが起動します!
これによって、DB登録のテストができるというわけです。

\project-root\src\phpunit.xml
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>

結果
image.png

3. コメントコントローラー

次は、コメントコントローラーです。
同様に作成・実行していきましょう。

実行コマンド

/var/www
$ php artisan make:test Controllers/CommentControllerTest
\project-root\src\tests\Feature\Controllers\CommentControllerTest.php
<?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 作成

storeupdatedestroyindexと4つもメソッドがあるので大変ですが、落ち着いていきましょう~。

準備としてコメントファクトリーを作っておき、モデルからも利用できるようにしておきます。

  • database/factories/CommentFactory.php
\project-root\src\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
\project-root\src\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メソッドは少しだけ特殊なので解説を入れておきます!

\project-root\src\app\Http\Controllers\CommentController.php
    /**
     * コメントの一覧を取得する
     */
    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が付きますね。

\project-root\src\database\factories\CommentFactory.php
    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 デバッグ

できたら、実行して見ましょう。

実行コマンド

/var/www
$ php artisan test --filter=CommentControllerTest

実行結果
image.png

一つ通らなかったです。
結論から言うと、コントローラーにバグがあったわけではなく、テスト用に用意したユーザーファクトリーに誤りがありました。

\project-root\src\database\factories\UserFactory.php
    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_adminundefinedになってしまいました。

\project-root\src\app\Policies\CommentPolicy.php
    // ユーザーがコメントを削除できるかどうかを判定
    public function delete(User $user, Comment $comment)
    {
        // 投稿した本人もしくは管理者のみ削除可能
        return $user->id === $comment->user_id || $user->is_admin();
    }

この修正で通るようになりましたね。
image.png

テストケースが多いということもありますが、Unitテストの時と比べて実行時間が長くなっていることも感じてもらえればよいかなと思います。

4. ブックマークコントローラー

本日最後は、ブックマークコントローラーです!

4.1 作成

実行コマンド

/var/www
$ php artisan make:test Controllers/BookmarkControllerTest
  • tests/Feature/Controllers/BookmarkControllerTest.php
\project-root\src\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 実行

先ほどのコメントコントローラーに比べればかなりシンプルだと思いますので、このまま実行してしまいましょう~!

実行コマンド

/var/www
$ php artisan test --filter=BookmarkControllerTest

実行結果
image.png

問題なさそうですね!

5. まとめ・次回予告

今回からFeaureテストということで、最コントローラーについてテストをしました!

レビューコントローラーコメントコントローラーブックマークコントローラーについてのテストケース作成・実施・デバッグを完了させましたね。

次回は、続きとして、大学・学部・研究室コントローラーについてのテストケースを作成していく予定です!!

最後まで読んでくださってありがとうございました。

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

☆テスト・デバッグ編

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?