😸

TDDと二重防御戦略で学ぶ、ソート実装 - React + Sequelizeの実践例

に公開

はじめに

こんにちは。FitTrackアプリという健康管理アプリを開発している中で、「最新のワークアウトが降順表示されない」というシンプルなバグに遭遇しました。

このバグを修正する過程で、TDD(Test-Driven Development)二重防御戦略 という2つの重要な設計原則を実践的に学ぶことができたので、その学習過程を記事にまとめます。

この記事では、単なるバグ修正手順ではなく、「なぜそうするのか」という設計思想にフォーカスして解説します。

想定読者

  • React + Node.jsでWebアプリ開発をしている方
  • TDDに興味があるが実践例が欲しい方
  • Sequelizeでのパフォーマンス最適化に興味がある方

実行環境

項目 バージョン
Node.js v18.x
React 18.2.0
Sequelize 6.x
Vitest 0.34.x
その他 TDD、ペアプログラミング形式(Claude Code使用)

🚀 学習のきっかけ

FitTrackアプリには「直近のログを見る(10件)」というアコーディオンがあり、ユーザーの最近のワークアウト履歴を表示します。

しかし、あるとき気づいたのです。最新のワークアウトが最初に表示されていないことに。

1日に複数回トレーニングした場合、時刻順もバラバラで、ユーザーは最新のワークアウトを見つけるためにスクロールが必要な状態でした。これはUXとして明らかに問題です。

「なぜこんなことが起きているのか?」と調査を開始しました。

🔍 Phase 1: 問題との遭遇

バグの根本原因

調査の結果、3つの問題が見つかりました:

  1. バックエンドAPI (backend/routes/workouts.js)

    • createdAtでソートしていない
    • DB挿入順(またはID順)で返却していた
  2. フロントエンド (frontend/src/services/workoutGrouping.js)

    • groupByDate関数が受信順のまま最初の10件を取得
    • 日付グループのソート処理がない
  3. テストの欠如

    • TDD開発設計をしていない

問題のあったコード

バックエンド:

backend/routes/workouts.js
router.get('/', authMiddleware, async (req, res) => {
  const userId = req.user.id;

  try {
    const user = await User.findByPk(userId);
    if (!user) {
      return res.status(404).json({ error: 'ユーザーが見つかりません' });
    }

    // ❌ ソート指定なし → DB挿入順またはID順で返却
    const workouts = await user.getWorkouts();

    const formattedWorkouts = workouts.map((workout) => formatWorkoutData(workout));
    res.json(formattedWorkouts);
  } catch (error) {
    res.status(500).json({ error: 'データ取得中にエラーが発生しました' });
  }
});

フロントエンド:

frontend/src/services/workoutGrouping.js
export const groupByDate = (workouts) => {
  const grouped = {};

  // ❌ ソートせずに最初の10件を取得
  workouts.slice(0, 10).forEach(workout => {
    const dateKey = dayjs(workout.date).format('YYYY-MM-DD');

    if (!grouped[dateKey]) {
      grouped[dateKey] = [];
    }

    grouped[dateKey].push(workout);
  });

  return grouped;
};

データフローの問題点

🧪 Phase 2: TDDでテストを書く

TDDのRed-Green-Refactorサイクル

ここでTDD(Test-Driven Development)を実践します。

TDDの流れ:

  1. Red: テストを先に書き、失敗させる(バグの存在を証明)
  2. Green: 最小限の実装でテストを通す
  3. Refactor: コードをクリーンにする

テストケースの設計

まず、何をテストすべきか明確にします:

テストケース:

  1. ✅ 最新のワークアウトが最初に来る
  2. ✅ 同日複数ワークアウトは時刻降順で並ぶ
  3. ✅ 空配列を渡すと空オブジェクトを返す
  4. ✅ 1件のワークアウトでも正しくグループ化される
  5. ✅ 複数日にまたがるワークアウトが正しくグループ化される
  6. ✅ 10件のみ処理される(11件以上は無視)

:::

テスト実行結果(Red Phase)

❌ FAIL  src/services/__tests__/workoutGrouping.test.js
  ● groupByDate › 最新のワークアウトが最初に来る

    AssertionError: expected 2 to be 3

    - Expected: 3
    + Received: 2

バグの存在が証明されました! これでRed Phaseは完了です。

✅ Phase 3: 実装と解決

二重防御戦略の採用

ここで重要な設計判断を行います。どこでソートするか?

選択肢:

  1. バックエンドのみでソート
  2. フロントエンドのみでソート
  3. 両方でソート(二重防御戦略) ← 採用

なぜ二重防御が必要か?

アプローチ メリット デメリット
バックエンドのみ DBインデックス活用、効率的 APIの実装変更でバグが再発する可能性
フロントエンドのみ ビジネスロジックで制御 大量データでメモリ負荷、ソート負荷
二重防御 堅牢性・パフォーマンス・テスタビリティすべて◎ 実装コストやや高

実装コード

バックエンド修正:

backend/routes/workouts.js
  const userId = req.user.id;

  try {
    const user = await User.findByPk(userId);
    if (!user) {
      return res.status(404).json({ error: 'ユーザーが見つかりません' });
    }

-   // ❌ ソート指定なし → DB挿入順またはID順で返却
-   const workouts = await user.getWorkouts();
+   // ✅ createdAt降順でソート、100件に制限(二重防御の第一層)
+   const workouts = await user.getWorkouts({
+     order: [['createdAt', 'DESC']],
+     limit: 100,
+   });

    const formattedWorkouts = workouts.map((workout) => formatWorkoutData(workout));
    res.json(formattedWorkouts);

フロントエンド修正:

frontend/src/services/workoutGrouping.js
  export const groupByDate = (workouts) => {
    const grouped = {};

-   // ❌ ソートせずに最初の10件を取得
-   workouts.slice(0, 10).forEach(workout => {
+   // ✅ createdAt降順でソート、10件に制限(二重防御の第二層)
+   [...workouts]  // イミュータビリティ保証
+     .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+     .slice(0, 10)
+     .forEach(workout => {
      const dateKey = dayjs(workout.date).format('YYYY-MM-DD');
      if (!grouped[dateKey]) {
        grouped[dateKey] = [];
      }
      grouped[dateKey].push(workout);
    });

    return grouped;
  };

テスト実行結果(Green Phase)

✅ PASS  src/services/__tests__/workoutGrouping.test.js (6/6)
  ✓ 最新のワークアウトが最初に来る
  ✓ 同日複数ワークアウトは時刻降順で並ぶ
  ✓ 空配列を渡すと空オブジェクトを返す
  ✓ 1件のワークアウトでも正しくグループ化される
  ✓ 複数日にまたがるワークアウトが正しくグループ化される
  ✓ 10件のみ処理される

全テスト通過! Green Phaseも完了です 🎉

📚 学びの整理

技術的な学び

1. TDD(Test-Driven Development)

  • Red → Green → Refactor サイクルを実践
  • テストを先に書くことで、バグの存在を客観的に証明
  • 仕様が明確化され、リファクタリングが安心

2. Sequelizeのアソシエーションメソッド

// User.hasMany(Workout) から自動生成
user.getWorkouts({
  order: [['createdAt', 'DESC']],  // ソート
  limit: 100                       // 件数制限
})

3. JavaScriptのイミュータビリティ

  • スプレッド構文[...array]で配列をコピー
  • sort()は破壊的メソッドなので注意
  • 純粋関数を意識した設計

まとめ

「最新のワークアウトが降順表示されない」という単純なバグから、TDDと二重防御戦略という2つの重要な設計原則を実践的に学ぶことができました。

この記事で学んだこと:

  • ✅ TDDのRed-Green-Refactorサイクル
  • ✅ バックエンドとフロントエンドの責任分離
  • ✅ 二重防御戦略(Defense in Depth)
  • ✅ イミュータビリティの重要性
  • ✅ Sequelizeでの効率的なソート実装

重要な教訓:

データの順序は保証されていない。明示的にソートを指定し、複数の層で品質を保証することで、堅牢なアプリケーションを構築できる。

📖 参考資料


最後まで読んでいただきありがとうございました。
もし記事が参考になったら、ぜひ ❤️ ボタンを押していただけると嬉しいです!

質問やフィードバックがあれば、コメント欄でお待ちしています 🙌

Discussion