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アプリケーション開発に挑戦してみた!(テスト・デバッグ編③)~Unitテスト3[モデル]~

0
Posted at

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その45)

0. 初めに

こんにちは!
今日もWebアプリケーション開発を解説していきます!

GWはどうにか、宣言通り毎日記事を更新することができました。

前回から本格的にUnitテストのテストケースを作成しておりました。

今回は、ポリシーに続いて、モデルのテストケースの作成に取り組みたいと思います!

1. ブランチ運用

developブランチを最新化させて、新規ブランチをそこから切って作業を始めましょう。
ブランチ名は、test/unit/modelsなどとしておきましょう。

2. ユーザーモデル

ユーザーモデルのメソッドをテストします。

\project-root\src\tests\Unit配下にModelsというフォルダを作り、その下に新しくUserTest.phpというファイルを作りましょう。
内容は以下のようにしてみてください。

\project-root\src\tests\Unit\Models\UserTest.php
<?php

namespace Tests\Unit\Models;

use App\Models\User;
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function test_管理者フラグがtrueの場合(): void
    {
        // Arrange
        $user = new User();
        $user->is_admin = true;

        // Act
        $result = $user->is_admin();

        // Assert
        $this->assertTrue($result);
    }

    public function test_管理者フラグがfalseの場合(): void
    {
        // Arrange
        $user = new User();
        $user->is_admin = false;

        // Act
        $result = $user->is_admin();

        // Assert
        $this->assertFalse($result);
    }
}

今回もAAAパターンを採用しております!

できたら確認してみましょう。

実行コマンド

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

実行結果
image.png

3. 研究室モデル

\project-root\src\tests\Unit\Models\LabTest.php
<?php

namespace Tests\Unit\Models;

use App\Models\Lab;
use App\Models\Review;
use PHPUnit\Framework\TestCase;

class LabTest extends TestCase
{
    // --- getAveragePerItem ---

    public function test_各評価項目の平均値を正しく計算できる(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 3,
                'lab_atmosphere' => 4,
                'achievement_activity' => 5,
                'constraint_level' => 2,
                'facility_quality' => 4,
                'work_style' => 3,
                'student_balance' => 4,
            ]),
            new Review([
                'mentorship_style' => 5,
                'lab_atmosphere' => 2,
                'achievement_activity' => 3,
                'constraint_level' => 4,
                'facility_quality' => 2,
                'work_style' => 5,
                'student_balance' => 2,
            ]),
        ]));

        // Act
        $result = $lab->getAveragePerItem();

        // Assert
        $this->assertEquals(4.0, $result['mentorship_style']);
        $this->assertEquals(3.0, $result['lab_atmosphere']);
        $this->assertEquals(4.0, $result['achievement_activity']);
        $this->assertEquals(3.0, $result['constraint_level']);
        $this->assertEquals(3.0, $result['facility_quality']);
        $this->assertEquals(4.0, $result['work_style']);
        $this->assertEquals(3.0, $result['student_balance']);
    }

    public function test_レビューが1件の場合はその値がそのまま返る(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 3,
                'lab_atmosphere' => 4,
                'achievement_activity' => 5,
                'constraint_level' => 2,
                'facility_quality' => 4,
                'work_style' => 3,
                'student_balance' => 4,
            ]),
        ]));

        // Act
        $result = $lab->getAveragePerItem();

        // Assert
        $this->assertEquals(3.0, $result['mentorship_style']);
        $this->assertEquals(4.0, $result['lab_atmosphere']);
        $this->assertEquals(5.0, $result['achievement_activity']);
        $this->assertEquals(2.0, $result['constraint_level']);
        $this->assertEquals(4.0, $result['facility_quality']);
        $this->assertEquals(3.0, $result['work_style']);
        $this->assertEquals(4.0, $result['student_balance']);
    }

    public function test_レビューが0件の場合はnullが返る(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([]));

        // Act
        $result = $lab->getAveragePerItem();

        // Assert
        foreach (Lab::RATING_COLUMNS as $column) {
            $this->assertNull($result[$column]);
        }
    }

    // --- getOverallAverage ---

    public function test_全項目の総合平均値を正しく計算できる(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 2,
                'lab_atmosphere' => 3,
                'achievement_activity' => 5,
                'constraint_level' => 4,
                'facility_quality' => 5,
                'work_style' => 5,
                'student_balance' => 4,
            ]),
            new Review([
                'mentorship_style' => 5,
                'lab_atmosphere' => 4,
                'achievement_activity' => 3,
                'constraint_level' => 5,
                'facility_quality' => 3,
                'work_style' => 4,
                'student_balance' => 4,
            ]),
        ]));

        // Act
        $result = $lab->getOverallAverage();

        // Assert
        // 各項目平均: 3.5, 3.5, 4.0, 4.5, 4.0, 4.5, 4.0 → 総合平均 = 4.0
        $this->assertEquals(4.0, $result);
    }

    public function test_レビューが0件の場合の総合平均はnullが返る(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([]));

        // Act
        $result = $lab->getOverallAverage();

        // Assert
        $this->assertNull($result);
    }

    // --- getUserReviewAverage ---

    public function test_特定レビューの全項目平均を正しく計算できる(): void
    {
        // Arrange
        $review = new Review([
            'mentorship_style' => 3,
            'lab_atmosphere' => 4,
            'achievement_activity' => 5,
            'constraint_level' => 2,
            'facility_quality' => 4,
            'work_style' => 3,
            'student_balance' => 4,
        ]);

        // Act
        $result = Lab::getUserReviewAverage($review);

        // Assert
        // (3+4+5+2+4+3+4) / 7 ≒ 3.571...
        $this->assertEquals(3.571, $result);
    }

    // --- appendRatingAverages ---

    public function test_動的属性が正しくセットされる(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 3,
                'lab_atmosphere' => 4,
                'achievement_activity' => 5,
                'constraint_level' => 2,
                'facility_quality' => 4,
                'work_style' => 3,
                'student_balance' => 4,
            ]),
            new Review([
                'mentorship_style' => 5,
                'lab_atmosphere' => 2,
                'achievement_activity' => 3,
                'constraint_level' => 4,
                'facility_quality' => 2,
                'work_style' => 5,
                'student_balance' => 2,
            ]),
        ]));

        // Act
        $result = $lab->appendRatingAverages();

        // Assert
        // 各項目平均: 4.0, 3.0, 4.0, 3.0, 3.0, 4.0, 3.0 → 総合平均 ≒ 3.429
        $this->assertEquals(3.429, $result->overall_avg);
        $this->assertEquals(4.0, $result->avg_mentorship_style);
        $this->assertEquals(3.0, $result->avg_lab_atmosphere);
        $this->assertEquals(4.0, $result->avg_achievement_activity);
        $this->assertEquals(3.0, $result->avg_constraint_level);
        $this->assertEquals(3.0, $result->avg_facility_quality);
        $this->assertEquals(4.0, $result->avg_work_style);
        $this->assertEquals(3.0, $result->avg_student_balance);
        $this->assertEquals(2, $result->reviews_count);
    }
}

これは少し複雑なので、一つずつ見ていきましょう。

3.1 getAveragePerItem

例によって、リレーション系のメソッドは飛ばして、研究室モデルに特有のレビューの評価値計算系のメソッドを順々にテストしていきます。

最初は、このgetAveragePerItemメソッドです。

\project-root\src\app\Models\Lab.php
    /**
     * 各評価項目のユーザー間の平均値を取得する
     * reviews リレーションがロード済みである前提
     */
    public function getAveragePerItem(): Collection
    {
        return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
            return [$column => $this->reviews->avg($column)];
        });
    }

例の「Aクラスの全生徒の英語の平均点、数学の平均点、国語の平均点...」を求める計算ですね。

test_各評価項目の平均値を正しく計算できる()を見てみましょう。

まずは、AAAパターンに従って、Arrange(準備)ブロックを作成しています。

\project-root\src\tests\Unit\Models\LabTest.php
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 3,
                'lab_atmosphere' => 4,
                'achievement_activity' => 5,
                'constraint_level' => 2,
                'facility_quality' => 4,
                'work_style' => 3,
                'student_balance' => 4,
            ]),
            new Review([
                'mentorship_style' => 5,
                'lab_atmosphere' => 2,
                'achievement_activity' => 3,
                'constraint_level' => 4,
                'facility_quality' => 2,
                'work_style' => 5,
                'student_balance' => 2,
            ]),
        ]));

研究室モデルをインスタンス化してsetRelationメソッドでリレーションを定義しています。
注意したいのは、あくまでDB操作をせずにリレーションを組んでいる点です。
リレーション自体のテストはしていないことに気を付けましょう。

その中で二つの異なる評価値を持ったレビューインスタンスを用意してコレクションとして渡しています。
なぜ二つも必要なのかと言いますと、当然ながら平均値を計算するためです。

次に、Act(実行)ブロックです。
メソッドを呼び出し、結果を$resultに格納します。

\project-root\src\tests\Unit\Models\LabTest.php
        // Act
        $result = $lab->getAveragePerItem();

最後に、Assert(検証)です。

\project-root\src\tests\Unit\Models\LabTest.php
        // Assert
        $this->assertEquals(4.0, $result['mentorship_style']);
        $this->assertEquals(3.0, $result['lab_atmosphere']);
        $this->assertEquals(4.0, $result['achievement_activity']);
        $this->assertEquals(3.0, $result['constraint_level']);
        $this->assertEquals(3.0, $result['facility_quality']);
        $this->assertEquals(4.0, $result['work_style']);
        $this->assertEquals(3.0, $result['student_balance']);

assertEqualsは第一引数と第二引数が等しい時に成功とみなすメソッドですね。

例えば、'mentorship_style'(5 + 3) ÷ 2 = 4となるのが正しいので、期待される結果として4.0を第一引数に渡しています。

一方で、実際の結果である$result['mentorship_style']は第二引数に渡しています。

つまり、実際の具体的な数字で計算したものとメソッドによる計算結果が一致するかどうかを確認しているということですね!
他の評価指標についても同様です。

二つ目のtest_レビューが1件の場合はその値がそのまま返る()では、レビューが複数ないときは平均値は当然同じ値になる(Aクラスに太郎さん一人しかいない場合太郎さんの点数がそのままAクラスの平均点数になる)ことを確認しているだけです。

\project-root\src\tests\Unit\Models\LabTest.php
    public function test_レビューが1件の場合はその値がそのまま返る(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 3,
                'lab_atmosphere' => 4,
                'achievement_activity' => 5,
                'constraint_level' => 2,
                'facility_quality' => 4,
                'work_style' => 3,
                'student_balance' => 4,
            ]),
        ]));

        // Act
        $result = $lab->getAveragePerItem();

        // Assert
        $this->assertEquals(3.0, $result['mentorship_style']);
        $this->assertEquals(4.0, $result['lab_atmosphere']);
        $this->assertEquals(5.0, $result['achievement_activity']);
        $this->assertEquals(2.0, $result['constraint_level']);
        $this->assertEquals(4.0, $result['facility_quality']);
        $this->assertEquals(3.0, $result['work_style']);
        $this->assertEquals(4.0, $result['student_balance']);
    }

また、レビューがない時は何も返らないことも確認します。

\project-root\src\tests\Unit\Models\LabTest.php
    public function test_レビューが0件の場合はnullが返る(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([]));

        // Act
        $result = $lab->getAveragePerItem();

        // Assert
        foreach (Lab::RATING_COLUMNS as $column) {
            $this->assertNull($result[$column]);
        }
    }

assertNullは引数がnullなら成功とみなすメソッドですね。

また、::スコープ定義演算子と呼ばれ、インスタンス化せずともクラスのプロパティに静的にアクセスできるもので、本シリーズでも何度か登場していたはずです。
モデルに定義されている定数をプロパティとして呼び出しています。

\project-root\src\app\Models\Lab.php
    public const RATING_COLUMNS = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];

3.2 getOverallAverage

次に、getOverallAverageに関するテストを見てみましょう。

モデルに定義されていたメソッドは以下の通りです。

\project-root\src\app\Models\Lab.php
    /**
     * 全評価項目の総合平均値を取得する
     */
    public function getOverallAverage(): ?float
    {
        return $this->getAveragePerItem()->avg();
    }

各評価項目のさらに平均値を求めます。
いわば、「Aクラスの英語、数学、国語の3科目の平均点を求めることで、それがAクラスの総合学力とみなす」ようなイメージです。

テストケースは以下のようにしました。

\project-root\src\tests\Unit\Models\LabTest.php
    public function test_全項目の総合平均値を正しく計算できる(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 2,
                'lab_atmosphere' => 3,
                'achievement_activity' => 5,
                'constraint_level' => 4,
                'facility_quality' => 5,
                'work_style' => 5,
                'student_balance' => 4,
            ]),
            new Review([
                'mentorship_style' => 5,
                'lab_atmosphere' => 4,
                'achievement_activity' => 3,
                'constraint_level' => 5,
                'facility_quality' => 3,
                'work_style' => 4,
                'student_balance' => 4,
            ]),
        ]));

        // Act
        $result = $lab->getOverallAverage();

        // Assert
        // 各項目平均: 3.5, 3.5, 4.0, 4.5, 4.0, 4.5, 4.0 → 総合平均 = 4.0
        $this->assertEquals(4.0, $result);
    }

    public function test_レビューが0件の場合の総合平均はnullが返る(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([]));

        // Act
        $result = $lab->getOverallAverage();

        // Assert
        $this->assertNull($result);
    }

3.3 getUserReviewAverage

続いて、getUserReviewAverageです。

\project-root\src\app\Models\Lab.php
    /**
     * 特定レビューの全評価項目の平均値を取得する
     */
    public static function getUserReviewAverage(Review $review): ?float
    {
        return collect(self::RATING_COLUMNS)
            ->map(fn($column) => $review->$column)
            ->filter(fn($value) => $value !== null)
            ->avg();
    }

これは、「太郎さん一人の、英語、数学、国語の3科目の平均点を求めることで、太郎さんの総合学力値とする」イメージですね。

\project-root\src\tests\Unit\Models\LabTest.php
    public function test_特定レビューの全項目平均を正しく計算できる(): void
    {
        // Arrange
        $review = new Review([
            'mentorship_style' => 3,
            'lab_atmosphere' => 4,
            'achievement_activity' => 5,
            'constraint_level' => 2,
            'facility_quality' => 4,
            'work_style' => 3,
            'student_balance' => 4,
        ]);

        // Act
        $result = Lab::getUserReviewAverage($review);

        // Assert
        // (3+4+5+2+4+3+4) / 7 ≒ 3.571...
        $this->assertEqualsWithDelta(3.571, $result, 0.001);
    }

ただし、このままだとテストが通りません(後で解説します)。

3.4 appendRatingAverages

最後は、appendRatingAveragesですね。
scopeWithRatingAveragesメソッドはクエリビルダに関わるものなので、Featureテストに回します。

    /**
     * overall_avg, avg_{col}, reviews_count を動的属性としてセットする
     * MyPageController 等でコレクション内の各 Lab に付与する用途
     */
    public function appendRatingAverages(): self
    {
        $averagePerItem = $this->getAveragePerItem();

        $this->overall_avg = $averagePerItem->avg();
        foreach (self::RATING_COLUMNS as $column) {
            $this->{"avg_{$column}"} = $averagePerItem[$column];
        }
        $this->reviews_count = $this->reviews->count();

        return $this;
    }

こちらは、平均値だけでなく、「テストの受験者数」も付与できるメソッドです。
これを使うことで、「Aクラスの男子の平均点と男子の人数を求め」たりすることができるようになります(たとえが分かりにくくなってきているのは気のせいだと思いたいところです...)。

\project-root\src\tests\Unit\Models\LabTest.php
    public function test_動的属性が正しくセットされる(): void
    {
        // Arrange
        $lab = new Lab();
        $lab->setRelation('reviews', collect([
            new Review([
                'mentorship_style' => 3,
                'lab_atmosphere' => 4,
                'achievement_activity' => 5,
                'constraint_level' => 2,
                'facility_quality' => 4,
                'work_style' => 3,
                'student_balance' => 4,
            ]),
            new Review([
                'mentorship_style' => 5,
                'lab_atmosphere' => 2,
                'achievement_activity' => 3,
                'constraint_level' => 4,
                'facility_quality' => 2,
                'work_style' => 5,
                'student_balance' => 2,
            ]),
        ]));

        // Act
        $result = $lab->appendRatingAverages();

        // Assert
        // 各項目平均: 4.0, 3.0, 4.0, 3.0, 3.0, 4.0, 3.0 → 総合平均 ≒ 3.429
        $this->assertEquals(3.429, $result->overall_avg, 0.001);
        $this->assertEquals(4.0, $result->avg_mentorship_style);
        $this->assertEquals(3.0, $result->avg_lab_atmosphere);
        $this->assertEquals(4.0, $result->avg_achievement_activity);
        $this->assertEquals(3.0, $result->avg_constraint_level);
        $this->assertEquals(3.0, $result->avg_facility_quality);
        $this->assertEquals(4.0, $result->avg_work_style);
        $this->assertEquals(3.0, $result->avg_student_balance);
        $this->assertEquals(2, $result->reviews_count);
    }

お気づきかもしれませんが、実はこれも通りません。

3.5 実行結果

実行コマンド

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

実行結果
image.png

7つのテストケースのうち、2つが失敗しています。

3.6 デバッグ(小数点以下の丸め込み処理追加)

失敗している場所を見てみると...
image.png
image.png
image.png

小数点以下が結果に表れるときですね。

これまで、平均値を計算する処理について小数点以下の丸め込み処理(何桁目を四捨五入するのか・切り捨てるのかなど)を全く考えていませんでした。

正確には以下のJSファイル(フロントエンド側)で行っていました。

\project-root\src\resources\js\utils\formatRating.js
/**
 * 評価値を小数点以下2桁にフォーマット
 * @param {number|null} value - 評価値
 * @param {string} fallback - 値がnullの場合の代替文字列(デフォルト: null)
 * @returns {string|null} フォーマットされた評価値
 */
export const formatRating = (value, fallback = null) => {
  return value != null ? Number(value).toFixed(2) : fallback;
};

バックエンドからフロントエンドにデータを送るときに割り切れずにとんでもない桁数になる可能性もありそうで怖いので、念のためバックエンドの方でも丸め込みをしておくように修正するのがベターかなと思います。

今後「画面側で3桁まで表示したい」とか「1桁だけでよいペーも作りたい」みたいな要望が出てくるかもしれないということで、フロントエンドでのフォーマット処理はそのまま残しておきます。

バックエンドでは、小数点以下4桁目を四捨五入して3桁の値を返すように修正します。

\project-root\src\app\Models\Lab.php
    /**
     * 各評価項目のユーザー間の平均値を取得する
     * reviews リレーションがロード済みである前提
     */
    public function getAveragePerItem(): Collection
    {
        return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
            $avg = $this->reviews->avg($column);
            return [$column => $avg !== null ? round($avg, 3) : null];
        });
    }

    /**
     * 全評価項目の総合平均値を取得する
     */
    public function getOverallAverage(): ?float
    {
        $avg = $this->getAveragePerItem()->avg();
        return $avg !== null ? round($avg, 3) : null;
    }

    /**
     * 特定レビューの全評価項目の平均値を取得する
     */
    public static function getUserReviewAverage(Review $review): ?float
    {
        $avg = collect(self::RATING_COLUMNS)
            ->map(fn($column) => $review->$column)
            ->filter(fn($value) => $value !== null)
            ->avg();
        return $avg !== null ? round($avg, 3) : null;
    }

    /**
     * overall_avg, avg_{col}, reviews_count を動的属性としてセットする
     * MyPageController 等でコレクション内の各 Lab に付与する用途
     */
    public function appendRatingAverages(): self
    {
        $averagePerItem = $this->getAveragePerItem();

        $avg = $averagePerItem->avg();
        $this->overall_avg = $avg !== null ? round($avg, 3) : null;
        foreach (self::RATING_COLUMNS as $column) {
            $this->{"avg_{$column}"} = $averagePerItem[$column];
        }
        $this->reviews_count = $this->reviews->count();

        return $this;
    }

    /**
     * 一覧表示用: 各評価項目の平均・総合評価・レビュー数をクエリに付与するスコープ
     */
    public function scopeWithRatingAverages(Builder $query): Builder
    {
        $query->select('labs.*')->withCount('reviews');

        foreach (self::RATING_COLUMNS as $column) {
            $query->addSelect([
                "avg_{$column}" => Review::query()
                    ->selectRaw("ROUND(AVG($column), 3)")
                    ->whereColumn('reviews.lab_id', 'labs.id'),
            ]);
        }

        $avgSum = implode(' + ', array_map(fn($c) => "ROUND(AVG($c), 3)", self::RATING_COLUMNS));
        $count = count(self::RATING_COLUMNS);

        $query->addSelect([
            'overall_avg' => Review::query()
                ->selectRaw("ROUND(($avgSum) / $count, 3)")
                ->whereColumn('reviews.lab_id', 'labs.id'),
        ]);

        return $query;
    }

ブランチは分けてもよかったのですが、テストを書いて初めて発覚したということもありこのままのブランチでコミットしましょう。

分かりやすくするために以下のようにして修正のコミットとテストコード追加のコミットは分けて二つにしましょう。

/project-root/src
$ git add app/Models/Lab.php

これでいったんコミットします。

3.7 再テスト実行

テストケースの方は最初から小数点以下3桁で作っていたので修正は必要ありません。

全て通ればOK!!
image.png

作成したテストコードもコミットしてみましょう。

/project-root/src
$ git add tests/Unit/Models/

できたら、プッシュして、プルリクエストを作成・マージ、ブランチの削除までしてしまいましょう!

4. デバッグ(モデルメソッドの型注釈)

今日の作業としては異常なのですが、もう少しだけやっていきましょうか。

これはバグではないので、厳密にはデバッグではなくリファクタリングに該当すると思いますが、モデルのメソッドのテストを書いていて気が付いたので修正したいと思います。

それが、型注釈がされていない点です。
以前、コントローラーのメソッド定義には型注釈をつけていましたが、モデルには付けていませんでした。
良い機会ですので、可読性高以上のためにここで付けておきましょう!

4.1 ブランチ運用

先ほどの修正をdevelopブランチに取り込んで、新規にrefactor/backend/model-method-typeというブランチを切って作業開始です。

不要になった、test/unit/modelsブランチは削除しましょう。

4.2 ユーザーモデル

まずは、ユーザーモデルからです。
ついでにPHPDoc風のコメントアウトも付与しましょう。

例えば、こんな感じです!

    /**
     * 管理者かどうかを判定するメソッド
     */
    public function is_admin(): bool
    {
        return $this->is_admin;
    }

キャメルケースに直したくて気持ち悪いのですw
(is_admin => isAdmin)

次回以降時間があれば直すかもです...(笑)

全体のコードはこんな感じです。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable, SoftDeletes;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'is_admin', // 管理者フラグ
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    /**
     * 管理者かどうかを判定するメソッド
     */
    public function is_admin(): bool
    {
        return $this->is_admin;
    }

    /**
     * リレーションの定義
     *
     * 大学とのリレーション(多対多)
     * 中間テーブル名を明示的に指定
     */
    public function universities(): BelongsToMany
    {
        return $this->belongsToMany(University::class, 'university_edit_histories')->withTimestamps();
    }

    /**
     * 学部とのリレーション(多対多)
     * 
     * 中間テーブル名を明示的に指定
     */
    public function faculties(): BelongsToMany
    {
        return $this->belongsToMany(Faculty::class, 'faculty_edit_histories')->withTimestamps();
    }

    /**
     * 研究室とのリレーション(多対多)
     * 
     * 中間テーブル名を明示的に指定
     */
    public function labs(): BelongsToMany
    {
        return $this->belongsToMany(Lab::class, 'lab_edit_histories')->withTimestamps();
    }

    /**
     * レビューとのリレーション(一対多)
     */
    public function reviews(): HasMany
    {
        return $this->hasMany(Review::class);
    }

    /**
     * コメントとのリレーション(一対多)
     */
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    /**
     * ブックマークとのリレーション(一対多)
     */
    public function bookmarks(): HasMany
    {
        return $this->hasMany(Bookmark::class);
    }

    /**
     * 通知とのリレーション(一対多)
     */
    public function notifications(): MorphMany
    {
        return $this->morphMany(DatabaseNotification::class, 'notifiable')->latest();
    }
}

4.3 大学モデル

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class University extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'name',
        'type',
    ];

    /**
     * 論理削除時に関連する学部も論理削除するように設定
     */
    protected static function boot(): void
    {
        parent::boot();

        static::deleting(function ($university) {
            // 論理削除時に関連する学部も論理削除
            $university->faculties()->get()->each->delete();
        });
    }

    // リレーションの定義
    /**
     * ユーザーとのリレーション(多対多)
     * 
     * 中間テーブル名を明示的に指定
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'university_edit_histories')->withTimestamps();
    }

    /**
     * 学部とのリレーション(一対多)
     */
    public function faculties(): HasMany
    {
        return $this->hasMany(Faculty::class);
    }

    /**
     * 削除依頼とのポリモーフィックリレーション(一対多)
     */
    public function deletionRequests(): MorphMany
    {
        return $this->morphMany(DeletionRequest::class, 'target');
    }

    /**
     * 作成者とのリレーション(多対一)
     */
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }
}

4.4 学部モデル

\project-root\src\app\Models\Faculty.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Faculty extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'name', 'university_id'
    ];

    /**
     * 論理削除時に関連する研究室も論理削除するように設定
     */
    protected static function boot(): void
    {
        parent::boot();

        static::deleting(function ($faculty) {
            // 論理削除時に関連する研究室も論理削除
            $faculty->labs()->get()->each->delete();
        });
    }

    // リレーションの定義
    /**
     * ユーザーとのリレーション(多対多)
     *
     * 中間テーブル名を明示的に指定
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'faculty_edit_histories')->withTimestamps();
    }

    /**
     * 大学とのリレーション(多対一)
     */
    public function university(): BelongsTo
    {
        return $this->belongsTo(University::class);
    }

    /**
     * 研究室とのリレーション(一対多)
     */
    public function labs(): HasMany
    {
        return $this->hasMany(Lab::class);
    }

    /**
     * 削除依頼とのポリモーフィックリレーション(一対多)
     */
    public function deletionRequests(): MorphMany
    {
        return $this->morphMany(DeletionRequest::class, 'target');
    }

    /**
     * 作成者とのリレーション(多対一)
     */
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }
}

4.6 レビューモデル

\project-root\src\app\Models\Lab.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;

class Lab extends Model
{
    use HasFactory, SoftDeletes;

    public const RATING_COLUMNS = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];

    protected $fillable = [
        'name',
        'faculty_id',
    ];

    // リレーションの定義
    /**
     * ユーザーとのリレーション(多対多)
     *
     * 中間テーブル名を明示的に指定
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'lab_edit_histories')->withTimestamps();
    }

    /**
     * 学部とのリレーション(多対一)
     */
    public function faculty(): BelongsTo
    {
        return $this->belongsTo(Faculty::class);
    }

    /**
     * レビューとのリレーション(一対多)
     */
    public function reviews(): HasMany
    {
        return $this->hasMany(Review::class);
    }

    /**
     * コメントとのリレーション(一対多)
     */
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    /**
     * ブックマークとのリレーション(一対多)
     */
    public function bookmarks(): HasMany
    {
        return $this->hasMany(Bookmark::class);
    }

    /**
     * 削除依頼とのポリモーフィックリレーション(一対多)
     */
    public function deletionRequests(): MorphMany
    {
        return $this->morphMany(DeletionRequest::class, 'target');
    }

    /**
     * 作成者とのリレーション(多対一)
     */
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }

    /**
     * 各評価項目のユーザー間の平均値を取得する
     * reviews リレーションがロード済みである前提
     */
    public function getAveragePerItem(): Collection
    {
        return collect(self::RATING_COLUMNS)->mapWithKeys(function ($column) {
            $avg = $this->reviews->avg($column);
            return [$column => $avg !== null ? round($avg, 3) : null];
        });
    }

    /**
     * 全評価項目の総合平均値を取得する
     */
    public function getOverallAverage(): ?float
    {
        $avg = $this->getAveragePerItem()->avg();
        return $avg !== null ? round($avg, 3) : null;
    }

    /**
     * 特定レビューの全評価項目の平均値を取得する
     */
    public static function getUserReviewAverage(Review $review): ?float
    {
        $avg = collect(self::RATING_COLUMNS)
            ->map(fn($column) => $review->$column)
            ->filter(fn($value) => $value !== null)
            ->avg();
        return $avg !== null ? round($avg, 3) : null;
    }

    /**
     * overall_avg, avg_{col}, reviews_count を動的属性としてセットする
     * MyPageController 等でコレクション内の各 Lab に付与する用途
     */
    public function appendRatingAverages(): self
    {
        $averagePerItem = $this->getAveragePerItem();

        $avg = $averagePerItem->avg();
        $this->overall_avg = $avg !== null ? round($avg, 3) : null;
        foreach (self::RATING_COLUMNS as $column) {
            $this->{"avg_{$column}"} = $averagePerItem[$column];
        }
        $this->reviews_count = $this->reviews->count();

        return $this;
    }

    /**
     * 一覧表示用: 各評価項目の平均・総合評価・レビュー数をクエリに付与するスコープ
     */
    public function scopeWithRatingAverages(Builder $query): Builder
    {
        $query->select('labs.*')->withCount('reviews');

        foreach (self::RATING_COLUMNS as $column) {
            $query->addSelect([
                "avg_{$column}" => Review::query()
                    ->selectRaw("ROUND(AVG($column), 3)")
                    ->whereColumn('reviews.lab_id', 'labs.id'),
            ]);
        }

        $avgSum = implode(' + ', array_map(fn($c) => "ROUND(AVG($c), 3)", self::RATING_COLUMNS));
        $count = count(self::RATING_COLUMNS);

        $query->addSelect([
            'overall_avg' => Review::query()
                ->selectRaw("ROUND(($avgSum) / $count, 3)")
                ->whereColumn('reviews.lab_id', 'labs.id'),
        ]);

        return $query;
    }
}

4.7 ブックマークモデル

\project-root\src\app\Models\Bookmark.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Bookmark extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'lab_id',
    ];

    // リレーション定義
    /**
     * ユーザーとのリレーション(多対一)
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * 研究室とのリレーション(多対一)
     */
    public function lab(): BelongsTo
    {
        return $this->belongsTo(Lab::class);
    }
}

4.8 コメントモデル

\project-root\src\app\Models\Comment.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Comment extends Model
{
    protected $fillable = [
        'user_id',
        'lab_id',
        'content',
    ];

    // リレーションの定義
    /**
     * ユーザーとのリレーション(多対一)
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * 研究室とのリレーション(多対一)
     */
    public function lab(): BelongsTo
    {
        return $this->belongsTo(Lab::class);
    }
}

4.9 削除依頼モデル

\project-root\src\app\Models\DeletionRequest.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class DeletionRequest extends Model
{
    protected $fillable = [
        'requested_by',
        'processed_by',
        'target_id',
        'target_type',
        'status',
        'reason',
        'processed_at',
    ];

    protected $casts = [
        'processed_at' => 'datetime',
    ];

    // リレーションの定義
    /**
     * 削除対象モデルとのポリモーフィックリレーション(多対一)
     */
    public function target(): MorphTo
    {
        return $this->morphTo();
    }

    /**
     * 依頼者とのリレーション(多対一)
     */
    public function requester(): BelongsTo
    {
        return $this->belongsTo(User::class, 'requested_by');
    }

    /**
     * 対応する管理者とのリレーション(多対一)
     */
    public function processor(): BelongsTo
    {
        return $this->belongsTo(User::class, 'processed_by');
    }
}

できたら、コミット・プッシュ、PR作成・マージ、ブランチ削除を忘れずに行っておきましょう!

5. まとめ・次回予告

お疲れ様でした!
今日は、Uniteテスト3日目ということで、モデルのメソッドに対するテストケースを作成しました!

これでいったUnitテストは完了です!

次回からはFeatureテストに取り組みたいと思います。
お楽しみに~

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

☆テスト・デバッグ編

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"

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?