はじめに
電気プラン比較サービス enegent.jp をリリースして10日。毎日がっつり機能追加した結果、430ファイル・79スクリプトの「それなりの規模」に育ちました。
ただ、速度を優先した代償として:
- 計算エンジンが3つ存在(v1、v2、シナリオ専用の3重実装)
-
except Exception: passが9箇所(障害が起きても気づけない) - 小売事業者の名前が5ファイル以上にハードコード(1社追加するだけで5箇所変更)
- 79本のスクリプトのうち30本以上が「もう使わない」一発モノ
- v2計算エンジンのテストがゼロ(本番で動いている主要コードにテストがない)
「機能追加のたびに既存コードが壊れないか不安」という状態になったので、Claude Codeと一緒に1セッションでリファクタリングスプリントを回しました。
プロジェクト概要
| 項目 | 内容 |
|---|---|
| サービス | 電力プラン比較AI(46社147プラン対応) |
| フロント | Next.js 15 + Vercel |
| バックエンド | FastAPI + Cloud Run |
| DB | Supabase PostgreSQL |
| 記事 | 157ページ(JSON駆動) |
| 運用 | GitHub Actions 13本(日次/月次自動化) |
何が問題だったか
計算エンジンの三重実装
電気料金の月額計算を行う calculate_monthly_bill 関数が、開発の過程で3つに分裂していました。
v1: calculate_monthly_bill() ← 初期版。燃調の扱いが簡易
v2: calculate_monthly_bill_v2() ← 改良版。燃調6分類対応
v3: calculate_jan2023_monthly_bill() ← 高騰シナリオ専用。独自実装
v1は「推薦エンジンのフォールバック」でしか使われていないのに、750行のファイルの中で存在感を放っていました。v2が本番のメインパスなのにテストはv1にしかない。v3は528行のファイルに独自の料金計算を再実装している。
段階制料金の計算ロジック(3段階の単価 × 使用量)が3箇所にコピーされている状態です。バグを直すなら3箇所、新しい料金体系に対応するなら3箇所。
except Exception: pass の蔓延
try:
r = calc_fuel_unit(sb_or_cache, plan.plan_id, area, date(y, m, 1))
last_12m.append(r.unit_price)
except Exception:
pass # ← 全種類の例外を握りつぶし、ログも残さない
これが9箇所。DBが落ちても、型エラーが起きても、何が起きても黙って処理を続ける。本番で「なぜか計算結果がおかしい」と言われても、原因を追えない。
ハードコード事業者名の散在
# fuel_calculator.py
LEGACY_COMPLIANT_RETAILERS = {
"東京電力エナジーパートナー", "関西電力", ...
}
# scenario_jan2023.py(別のファイルに同じリストの別バージョン)
_CATEGORY_PATTERNS = [
("東京電力エナジー", "E"),
("関西電力", "E"), ...
]
# bot/main.py(さらに別のファイルにエリア別リスト)
AREA_RETAILERS = { "tokyo": [...], "kansai": [...] }
新しい電力会社を追加するたびに「あのファイルとあのファイルと……」と探し回る。漏れると計算結果が狂う。
改善スプリントの設計
闇雲にリファクタリングすると本番を壊すので、フェーズを分け、各フェーズ独立でテスト可能な設計にしました。
Phase 0: テストハーネス構築 ← 安全網を先に張る
│
├── Phase 1: 計算エンジン統合 ← 最重要・最高リスク
├── Phase 2: 例外処理正常化 ← 独立
├── Phase 3: 共有モジュール化 ← 独立
├── Phase 4: スクリプト整理 ← 独立
├── Phase 5: シナリオ統合 ← Phase 1に依存
├── Phase 6: DB移行準備 ← Phase 3に依存
└── Phase 7: 長関数分解 ← Phase 1に依存
鉄則:テストを先に書く。
v2計算エンジンにテストがない状態でv1を消すと、何かが壊れても気づけません。Phase 0で安全網を張ることが全ての前提です。
Phase 0: テストハーネス(73テスト追加)
MockFuelCalcCache
燃料費調整の計算はSupabase DBからデータを取得するため、テスト時にDB接続が必要でした。MockFuelCalcCache を作り、DBなしでテスト可能にしました。
class MockFuelCalcCache:
"""FuelCalcCache のテスト用フェイク"""
def __init__(self):
self.sb = None
self._loaded = True # fuel_calculator._resolve_cache() がcacheと認識する条件
self._profiles = {}
self._published = {}
# ...
def add_profile(self, plan_id, profile):
self._profiles[plan_id] = profile
def get_profile(self, plan_id):
return self._profiles.get(plan_id)
_loaded と get_profile を持っていれば fuel_calculator はDBではなくこのキャッシュを使います。テストデータの注入が自由にできるようになりました。
燃調6分類の全パステスト
電力会社の燃料費調整は6つの計算方式に分類されます。
| 方式 | 例 | 特徴 |
|---|---|---|
| legacy_three_fuel | 東京電力、旧一電準拠型新電力 | 旧一電準拠 |
| legacy_with_jepx | auでんき、HTB | 二層構造 |
| jepx_linked | つばさでんき | JEPX連動 |
| jepx_composite | 東急でんき | JEPX合成 |
| market_linked | Looop | 完全市場連動 |
| minden_custom | みんな電力 | 独自方式 |
これまで6分類のどれにもテストがなかったので、各分類ごとにテストを作成。上限キャップ、フォールバック、公表値優先ロジックなどを全て検証しました。
結果: 49テスト → 122テスト(73テスト追加)
Phase 1: 計算エンジン統合(250行削減)
テストが揃ったので、本丸に着手。
v1/v2の統合
# Before: 2つの関数が並存
def calculate_monthly_bill(): # v1: 302行
...
def calculate_monthly_bill_v2(): # v2: 544行
...
# After: 1つに統合
def calculate_monthly_bill(): # 統合版(旧v2ベース): 280行
...
v1が唯一使われていた箇所(推薦エンジンの第3フォールバック)を統合エンジンで直接計算するように書き換え、v1を削除。
# Before(v1フォールバック)
capped_fuel = FuelRange(
median=cap_fuel_adjustment(fuel.median, area),
low=cap_fuel_adjustment(fuel.low, area),
high=cap_fuel_adjustment(fuel.high, area),
)
cost = annual_cost_range(plan_spec, usage, capped_fuel) # v1シグネチャ
# After(統合エンジン直接呼び出し)
from calculator import calculate_monthly_bill, _USAGE_SEASONAL_RATIO
total = 0.0
for month in range(1, 13):
kwh_m = avg_kwh * _USAGE_SEASONAL_RATIO.get(month, 1.0)
bd = calculate_monthly_bill(plan_spec, kwh_m, ampere, capped_median, month=month)
total += bd.total
calculator.py: 754行 → 504行(250行削減)
同時に、v2を参照していた全ファイル(5本)のimportを更新。
Phase 2: 例外処理の正常化
# Before
except Exception:
pass
# After
except (KeyError, ValueError, TypeError) as e:
logger.debug("fuel_unit skip %s %s %04d-%02d: %s", plan.plan_id, area, y, m, e)
加えて、12ヶ月分の燃調データ収集で3ヶ月以上欠損していたらWARNINGを出すようにしました。
if len(last_12m) < 9:
logger.warning("fuel data sparse: %s %s got %d/12 months", plan.plan_id, area, len(last_12m))
これで「なぜか年額が安すぎる」問題(燃調データが欠損して0円扱いになっていた)が即座に検知できるようになります。
Phase 3: 共有モジュール化
事業者名のハードコードを api/lib/ に集約。
# api/lib/retailers.py(唯一の定義場所)
LEGACY_COMPLIANT_RETAILERS: set[str] = {
"東京電力エナジーパートナー", "関西電力", "(旧一電準拠の新電力各社)", ...
}
# fuel_calculator.py(参照するだけ)
from lib.retailers import LEGACY_COMPLIANT_RETAILERS
エリアコードも同様に api/lib/areas.py に集約。新規事業者追加時の変更箇所が 5ファイル → 1ファイル に。
Phase 4: スクリプト整理
79本あったスクリプトを棚卸し。
scripts/
├── archive/ ← 32本を移動(fixes/8, registration/3, seeds/10, articles/6, preview/3, dead/1)
├── jepx/ ← 7本を集約(fetch → ingest → aggregate → render のパイプライン)
├── README.md ← 新規作成(分類表 + データフロー図)
└── (残り45本:日次/月次/テスト/SEO等)
GitHub Actionsのパス参照(3ファイル)も更新。
Phase 7: 長関数分解 + UIコンポーネント分割
calculator.py の分解
annual_cost_range(年額計算)を4関数に分割。
# Before: 90行の1関数に燃調収集 + レンジ算出 + 年額計算が混在
# After:
def _collect_fuel_units(...) # 月次燃調単価を収集
def _collect_fuel_range(...) # メソッド別レンジ算出
def _calc_annual_from_fuel(...) # 純粋関数: 燃調値 → 年額
def annual_cost_range(...) # オーケストレータ(20行)
_calc_annual_from_fuel は純粋関数になったため、単独でテスト可能に。
simulator/page.tsx の分割
1,967行のモノリシックなReactコンポーネントを3ファイルに。
simulator/
├── constants.ts (119行) ← 定数・型・GA4ヘルパー
├── components.tsx (454行) ← UIコンポーネント8個
└── page.tsx (1,402行) ← メインコンポーネント(565行削減)
検証
全てのフェーズ完了後に実行した検証:
| テスト | 結果 |
|---|---|
| pytest 198テスト | 全パス |
| TypeScript型チェック | 0 errors |
| 本番API回帰テスト(40シナリオ) | PASS 36 / FAIL 0 |
| Playwright E2E(25シナリオ フロー完走) | PASS 22 / FAIL 3(既存フラキー) |
| Playwright E2E(17シナリオ コンテンツ突合) | PASS 15 / FAIL 2(既存フラキー) |
| Playwright E2E(サイト全体96チェック) | PASS 94 / FAIL 2(既存フラキー) |
| Playwright E2E(GA4イベント発火) | 全OK |
Playwright の FAIL はリファクタリング前から存在するCloud Runコールドスタート起因のタイムアウト(プラン0件返却)で、コード変更起因ではないことを前回テスト結果との比較で確認しています。
定量的な成果
| 指標 | Before | After |
|---|---|---|
| テスト数 | 49 | 198(+304%) |
| calculator.py | 754行 | 504行(-33%) |
| simulator/page.tsx | 1,967行 | 1,402行(-29%) |
| scripts/直下 | 79本 | 45本(32本archive化) |
| 計算エンジン実装数 | 3 | 1 |
| 事業者追加時の変更箇所 | 5+ファイル | 1ファイル |
except Exception: pass |
9箇所 | 0箇所 |
学んだこと
テストを先に書く=保険をかけてからリファクタする
「v1を消してv2に一本化」は言うのは簡単ですが、v2のテストがない状態でやると壊しても気づけません。Phase 0(テストハーネス)が最も時間がかかりましたが、ここを飛ばすと残りの全フェーズが博打になります。
フェーズを小さく切って各フェーズで検証
7フェーズの各段階で pytest を回し、既存テストが壊れていないことを確認しながら進めました。1つのフェーズで失敗しても、前のフェーズまではロールバックできる。
「いつか使うかも」は使わない
79スクリプトのうち32本が「一回実行して終わり」のもの。git履歴に残っているので、ファイルとしては消して問題ない。ディレクトリの見通しが劇的に改善しました。
ドキュメントもコードと同じく腐る
GA4イベント設計書が15種で止まっていたのに、実装は35種。ドキュメントの更新を後回しにすると、あっという間に乖離します。リファクタリングのタイミングでドキュメントも棚卸しするのが効果的でした。
おわりに
技術的負債の返済はつい後回しにしがちですが、「機能追加のたびに不安になる」のは開発体験を確実に悪化させます。テストを先に書いて、フェーズを小さく切って、各段階で検証する。地味ですがこれが最も安全で確実な方法でした。
個人開発でもテストは書いたほうがいい。特に計算ロジックのような「正解が明確にある」コードは、テストの恩恵が大きいです。