リモート開発メインのソフトウェア開発企業のエンジニアブログです

テストしやすいLaravel設計:Service×Strategyでソートを外に出す

背景

あるプロジェクト内でLaravelを使用しており、Modelに複雑なソート処理が組み込まれていました。このような構成ではテストコードの作成が難しく、いわゆるFat Modelの状態となっていました。

LaravelではModelにロジックを詰め込んでしまうことが多く、保守性・テスト性の観点から課題が多くなりがちです。そこで本記事では、このような状態を **Serviceクラス+Strategyパターン** で改善しようと試みた記録をまとめます。Strategyパターンの効果について、実装例やテストの変化も交えて書いていきます。

Strategyパターンとは、処理の方針(戦略)をオブジェクトとして切り出し、条件に応じて使い分けるデザインパターンです。今回のように条件によってロジックを変えたいケースに適しています。

Serviceクラスは、ビジネスロジックの切り出しを行います。

実装例

Serviceクラスの導入

例としてStudentモデルを扱います。

以下のように、Studentモデルに複雑な条件でソート処理が書かれていました。if文が多く、ネストもしていましたが、例のため簡略化しています。

class Student extends Model
{
    public static function studentSort($students, $test_date, $test_type')
    {
        // テスト日が8月であれば性別でソート
        if($test_type === 1 && $test_date->month === 8) {
            $students = $students->sortBy('gender');
        } else if($test_type === 1) {
            $students = $students->sortBy('height');
        } else if ...
        ((中略))
        } else {
            // デフォルトのソート
            $students->sort(function ($a, $b) {
                return ($a->class <=> $b->class) // クラス別
                    ?: ($a->order <=> $b->order); // 生徒番号順
            });
        }
        return $students;
    }
}

まずはこれを専用のStudentSortServiceクラスに切り出します。

class StudentSortService
{
    public static function sort($students, $test_date, $test_type)
    {
        // テスト日が8月であれば性別でソート
        if($test_type === 1 && $test_date->month === 8) {
            $students = $students->sortBy('gender');
        } else if($test_type === 1) {
            $students = $students->sortBy('height');
        } else if ...
        ((中略))
        } else {
            // デフォルトのソート
            $students->sort(function ($a, $b) {
                return ($a->class <=> $b->class) // クラス別
                    ?: ($a->order <=> $b->order); // 生徒番号順
            });
        }
        return $students;
    }
}

これによって役割分担が明確になります。

  • Model:データ管理のみ
  • Service:ビジネスロジックを担当
  • Controller:依頼のみ行う

ただ、このままでは相変わらずテストコードが書きづらいです。

このServiceクラスをテストしようとすると下記のようになります。

/** @test */
public function testGenderBasedSortInAugust()
{
    $students = collect([
        new Student(['name' => 'A', 'gender' => ' 1']),
        new Student(['name' => 'B', 'gender' => '0']),
        new Student(['name' => 'C', 'gender' => '0']),
    ]);

    $sorted = StudentSortService::sort(
        $students,
        Carbon::create(2025, 8, 1, 9), // 8月なら性別でのソートを選択するはず
        1
    );

    $this->assertEquals(
        ['B', 'C', 'A'],
        $sorted->pluck('name')->toArray() // 結果は確認できるが、何が選択されたか分からない
    );
}

これだと内部でどの条件が選択されたかが分かりません。

本当に想定通りのソート結果になっているかが分かりづらく、ブラックボックス化しています。

どの条件を選択したか、正しい結果になっているかを確認する、明瞭なテストを行えるようにする必要があります。

Moba Pro

Strategyパターンの導入

Serviceに条件分岐が増えるとServiceがFatになります。

そこで、Strategyパターンを導入して分岐ごとのロジックをクラスに切り出そうと思います。

フォルダ、ファイル構成

App/
├── Services/
│   └── StudentSortService.php         ← どのStrategyを使うか決定
├── Strategies/
│   ├── SortStrategyInterface.php      ← Strategy共通のインターフェース
│   ├── DefaultSortStrategy.php        ← デフォルトのソート戦略
│   ├── GenderBasedSortStrategy.php    ← 性別で切り替える戦略
│   └── HeightBasedSortStrategy.php    ← 身長で切り替える戦略

Strategyのクラス図は下記。

インターフェース

interface SortStrategyInterface
{
    public function sort(Collection $students): Collection;
}

Strategyの一例

class DefaultSortStrategy implements SortStrategyInterface
{
    public function sort(Collection $students): Collection
    {
        return $students->sort(function ($a, $b) {
            return ($a->class <=> $b->class) // クラス別
                ?: ($a->order <=> $b->order); // 生徒番号順
        })->values();
    }
}

Serviceクラスの一例

class StudentSortService
{
    public static function sort(Collection $students, Carbon $test_date, int $test_type): Collection
    {
        $strategy = self::selectStrategy($students, $test_date, $test_type);
        return $strategy->sort($students);
    }

    protected static function selectStrategy(Collection $students, Carbon $test_date, int $test_type): SortStrategyInterface
    {
        // テスト種別が1deテスト日が8月であれば
        if ($test_type === 1 && $test_date->month === 8) {
            return new GenderBasedSortStrategy();
        }
        
        // テストの種別が1であれば
        if ($test_type === 1) {
            return new HeightBasedSortStrategy();
        }
        ((中略))
        // それ以外はデフォルトソート
        return new DefaultSortStrategy();
    }
}

StudentSortServiceではどのStrategyを使うかを判定するのみとしてロジックはそれぞれのStrategy(戦略)へ渡します。

Strategy(戦略)ごとのクラスにすることでテストコードは下記のようになります。

それぞれの条件ごとにソート結果を確認することができるようになり、ブラックボックス化していた状態が解消されました。戦略ごとのテストコードの例は以下です。

public function testDefaultSort()
{
    $students = collect([
        new Student(['name' => 'B', 'order' => 2, 'class' => 1]),
        new Student(['name' => 'A', 'order' => 1, 'class' => 1]),
        new Student(['name' => 'C', 'order' => 1, 'class' => 2]),
    ]);

    $strategy = new DefaultSortStrategy();
    $sorted = $strategy->sort($students);

    $this->assertEquals(['A', 'B', 'C'], $sorted->pluck('name')->toArray());
}

更に、Serviceはソート結果ではなく、どれが選択されたかをテストします。呼び出しはMock等必要ですが、今回は割愛。

public function testSelectDefaultStrategy()
{
    $students = collect([]);
    $date = Carbon::create(2025, 9, 1);
    $type = 2;

    // protectedのSelectStrategyを呼び出すためのメソッド
    $strategy = $this->callProtectedSelectStrategy($students, $date, $type);

    $this->assertInstanceOf(DefaultSortStrategy::class, $strategy);
}

ソートロジックと戦略選択を分離したことで可読性と保守性が向上しました。

実装することによるメリットとデメリット

メリット

1.テスト性が上がる。

戦略ごとのテストが可能になります。ソート条件と、条件の選択が分離され、可読性も良くなります。

2.拡張、修正がしやすい

もし他のソート条件を追加したい場合、共通のインターフェースを実装した具体的なソート条件のクラスを作成します。条件が増えても対応がしやすいです。

3.複数人での実装の分担がしやすい

戦略ごとの実装ができるため、担当しているStrategyのみ修正するといったことができます。チーム作業との相性が良いはずです。

デメリット

1.クラス数が膨大になる可能性

条件を増やすのが簡単ということで、どんどん増やしていくとクラス数が多くなりすぎる可能性があります。

ほとんど同じなのに別クラス、ということにもなりがち。

2.過剰設計になる可能性

今回の課題(複雑な条件を持つソート)に対しては有効でしたが、条件が増えない、数が少ない場合には無駄な設計になる可能性もあります。

まとめ

Modelに詰め込まれた複雑なロジックをService+Strategyパターン によって分離し、テスト性・保守性を高めることができました。

現在の構成の切り出しを考えることで、コードを書く際の手段も増えたと思います。

今後の開発、リファクタリングに活かしていきたいです。

← 前の投稿

次の投稿 →

Claude Code Subagents: A Developer&#8217;s Guide to Specialized AI Assistants

コメントを残す