• #vue
  • #vitest
  • #loan-simulator
  • #refactoring
  • #testing
開発blog-platformメモ

ローンシミュレーターのリファクタリング:カンマ入力・比較表示・テスト追加

Nuxt 3で作ったローン返済シミュレーターに3つの改善を加えた。入力欄へのカンマ自動表示、元利均等・元金均等返済の並列比較表示、そして計算ロジックを分離してVitestで29件のテストを書いた。

変更点

今回のリファクタリングで対応したのは以下の3点。

  1. 入力欄のカンマ自動表示 — 借入金額・ボーナス返済額の入力時にカンマ区切りで表示
  2. 元利均等・元金均等の並列比較 — タブ切り替えを廃止し、両方のパターンを左右に並べて表示
  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-width1100px から 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つの返済方式を同時に比較でき、利息差額もひと目でわかる。