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つの問題が見つかりました:
-
バックエンドAPI (
backend/routes/workouts.js)-
createdAtでソートしていない - DB挿入順(またはID順)で返却していた
-
-
フロントエンド (
frontend/src/services/workoutGrouping.js)-
groupByDate関数が受信順のまま最初の10件を取得 - 日付グループのソート処理がない
-
-
テストの欠如
- TDD開発設計をしていない
問題のあったコード
バックエンド:
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: 'データ取得中にエラーが発生しました' });
}
});
フロントエンド:
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の流れ:
- Red: テストを先に書き、失敗させる(バグの存在を証明)
- Green: 最小限の実装でテストを通す
- Refactor: コードをクリーンにする
テストケースの設計
まず、何をテストすべきか明確にします:
テストケース:
- ✅ 最新のワークアウトが最初に来る
- ✅ 同日複数ワークアウトは時刻降順で並ぶ
- ✅ 空配列を渡すと空オブジェクトを返す
- ✅ 1件のワークアウトでも正しくグループ化される
- ✅ 複数日にまたがるワークアウトが正しくグループ化される
- ✅ 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: 実装と解決
二重防御戦略の採用
ここで重要な設計判断を行います。どこでソートするか?
選択肢:
- バックエンドのみでソート
- フロントエンドのみでソート
- 両方でソート(二重防御戦略) ← 採用
なぜ二重防御が必要か?
| アプローチ | メリット | デメリット |
|---|---|---|
| バックエンドのみ | DBインデックス活用、効率的 | APIの実装変更でバグが再発する可能性 |
| フロントエンドのみ | ビジネスロジックで制御 | 大量データでメモリ負荷、ソート負荷 |
| 二重防御 | 堅牢性・パフォーマンス・テスタビリティすべて◎ | 実装コストやや高 |
実装コード
バックエンド修正:
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);
フロントエンド修正:
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