ローンシミュレーターのリファクタリング:カンマ入力・比較表示・テスト追加
Nuxt 3で作ったローン返済シミュレーターに3つの改善を加えた。入力欄へのカンマ自動表示、元利均等・元金均等返済の並列比較表示、そして計算ロジックを分離してVitestで29件のテストを書いた。
変更点
今回のリファクタリングで対応したのは以下の3点。
- 入力欄のカンマ自動表示 — 借入金額・ボーナス返済額の入力時にカンマ区切りで表示
- 元利均等・元金均等の並列比較 — タブ切り替えを廃止し、両方のパターンを左右に並べて表示
- 計算ロジックの分離とテスト追加 — Vueコンポーネントからロジックを抽出し、Vitestで検証
カンマ自動表示の実装
type="number" を type="text" + inputmode="numeric" に変更し、focus/blurイベントで表示を切り替える方式にした。
- フォーカス時: 生の数値を表示して全選択(値が0のときは空欄になる)
- フォーカスが外れた時:
Intl.NumberFormatでカンマ区切り表示
<input
:value="principalDisplay"
@focus="onCurrencyFocus($event, principal)"
@blur="onCurrencyBlur($event, v => principal = v)"
type="text"
inputmode="numeric"
/>
const formatNumber = (v) => v ? new Intl.NumberFormat('ja-JP').format(v) : '0'
const principalDisplay = computed(() => formatNumber(principal.value))
const onCurrencyFocus = (e, currentValue) => {
e.target.value = currentValue || ''
e.target.select()
}
const onCurrencyBlur = (e, setter) => {
const num = Number(e.target.value.replace(/[^0-9]/g, '')) || 0
setter(num)
}
入力中にリアルタイムでカンマを挿入するとカーソル位置の制御が複雑になる。focus/blur方式なら実装がシンプルで、使い勝手も十分よい。
並列比較表示
タブ切り替えUIを廃止し、CSSグリッドで2カラムレイアウトにした。
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.2rem;
}
max-width を 1100px から 1500px に拡大して横幅を確保した。1024px以下では1カラムに折り返す。
さらに、両方の利息合計の差額を下部に表示するカードを追加した。
<div v-if="interestDiff !== 0" class="card diff-card">
<p class="diff-text">
<strong>利息差額:</strong>
元金均等の方が {{ formatCurrency(Math.abs(interestDiff)) }}
{{ interestDiff > 0 ? '少ない' : '多い' }}
</p>
</div>
interestDiff は「元利均等の利息合計 − 元金均等の利息合計」で計算しているため、通常(利率 > 0)は正の値になり「元金均等の方が少ない」と表示される。利率0%では差額がゼロになるので、v-if="interestDiff !== 0" で非表示にしている。
借入条件を変えると差額がリアルタイムに更新されるため、どちらの返済方式が有利かひと目でわかる。
計算ロジックの分離
Vueコンポーネントの <script setup> に直接書いていた計算ロジックを app/utils/loan-calculator.ts に抽出した。
抽出した関数
| 関数 | 役割 |
|---|---|
getPaymentDate | 返済月の日付文字列を生成 |
isBonusMonth | ボーナス月かどうかを判定 |
calcEqualPaymentSchedule | 元利均等返済スケジュールを計算 |
calcEqualPrincipalSchedule | 元金均等返済スケジュールを計算 |
makeSummary | スケジュールから合計値を算出 |
Vueコンポーネント側はパラメータを渡して結果を受け取るだけになった。
const loanParams = computed(() => ({
principal: principal.value,
annualRate: annualRate.value,
totalPayments: totalPayments.value,
startDate: startDate.value,
bonusAmount: bonusAmount.value,
bonusMonths: bonusMonths.value,
}))
const equalPaymentSchedule = computed(() => calcEqualPaymentSchedule(loanParams.value))
const equalPrincipalSchedule = computed(() => calcEqualPrincipalSchedule(loanParams.value))
テスト
Vitestで29件のテストを書いた。テストは以下のカテゴリに分かれている。
テストカテゴリと検証内容
| カテゴリ | 件数 | 検証内容 |
|---|---|---|
| 日付ヘルパー | 3 | 年月生成、年またぎ |
| ボーナス判定 | 2 | 該当/非該当 |
| 元利均等返済 | 8 | 最終残高ゼロ、回数一致、元金合計≈借入額、残高単調減少、利率0%など |
| 元金均等返済 | 6 | 最終残高ゼロ、回数一致、利息単調減少など |
| 比較 | 1 | 元金均等の方が総利息が少ない |
| ボーナス返済 | 4 | 残高ゼロ、回数減少、利息減少、非ボーナス月のbonus=0 |
| エッジケース | 5 | 金額0、回数0、回数1、少額、高金利 |
丸め誤差の扱い
Math.round で各フィールドを個別に丸めているため、返済額 ≠ 元金分 + 利息分 となるケースがある。テストでは許容誤差を設けている。
- 各行: ±1円
- 合計: ±返済回数分
it('各行の返済額 ≈ 元金分 + 利息分(丸め誤差±1円)', () => {
const schedule = calcEqualPaymentSchedule(baseParams)
schedule.forEach(row => {
const diff = Math.abs(row.payment - (row.principalPart + row.interestPart))
expect(diff).toBeLessThanOrEqual(1)
})
})
「最終回で残高がゼロになる」は返済計算が正しいための必要条件であり、残高の単調減少や元金合計≈借入額、利息の整合性などのテストと組み合わせて初めて信頼度が上がる。
月末日の繰り越し(1/31→2月)やボーナス返済額が残高を超えるケースなど、境界系テストは今回未対応。必要に応じて追加する。
ファイル構成
apps/web/
├── app/
│ ├── pages/
│ │ └── loan-simulator.vue # UIコンポーネント
│ └── utils/
│ └── loan-calculator.ts # 計算ロジック(新規)
└── tests/
└── loan-calculator.test.ts # テスト29件(新規)
まとめ
計算ロジックをVueコンポーネントから分離したことでテストが書けるようになり、計算結果の正しさを機械的に検証できるようになった。タブUIを並列比較に変えたことで2つの返済方式を同時に比較でき、利息差額もひと目でわかる。