第24章:ミニ演習①:カフェ会計(税・端数)☕️🧾

この章は「TDDの基礎セット(正常→境界→異常 / AAA / 命名)」を 1本の小さなアプリに凝縮して完走する回だよ〜!💪🧪 (テストは Vitest v4系が現行だよ🧪✨ (NPM))
ゴール🎯
- 注文(明細)から、小計 / 税額 / 合計を出す関数をTDDで作る☕️
- **端数処理(切り捨て/切り上げ/四捨五入)**をテストで固定する💡
- 税額は「税率ごとにまとめて1回だけ端数処理」する(日本のインボイス制度の考え方に寄せる)📌 ※実務でも「税率ごとにまとめて丸め」はよく使われるよ〜 (Stripe)
お題(仕様)📘
入力(注文)
- 明細の配列:
name,unitPriceYen,qty,taxRatePercent(8 or 10 など) - 端数処理モード:
'floor' | 'ceil' | 'halfUp'
出力(会計結果)
subtotalYen(税抜小計)taxYen(税額)totalYen(税込合計)- (おまけ)税率ごとの内訳
taxByRate(学習が捗る✨)
実装方針(ミスりにくいコツ)🧠✨
- お金は 小数を使わず、円の整数で扱う(浮動小数の誤差を避ける)💴
- 税は
taxable * rate / 100で「割り切れない」ことがある → 端数処理が必要🌀 - 端数処理は 「税率ごとの合計」に対して1回だけやる (行ごとに丸めるのと結果がズレることがあるよ⚠️ (Stripe))
まずファイルを用意📁
src/cafeBilling.tstests/cafeBilling.test.ts
テストから作る🧪(TDDの順番:小さく!)
以下は「この順番でコミットしていく」想定だよ😊✨ (1コミット=1〜2テスト+最小実装+軽い整理、が気持ちいい💞)
ステップ1:空の注文は0円🫧
tests/cafeBilling.test.ts
import { describe, it, expect } from 'vitest'
import { calcCafeBill } from '../src/cafeBilling'
describe('calcCafeBill', () => {
it('空の注文は 0円', () => {
const result = calcCafeBill([], { rounding: 'floor' })
expect(result).toEqual({
subtotalYen: 0,
taxYen: 0,
totalYen: 0,
taxByRate: {},
})
})
})
src/cafeBilling.ts(最小実装)
export type RoundingMode = 'floor' | 'ceil' | 'halfUp'
export type LineItem = {
name: string
unitPriceYen: number
qty: number
taxRatePercent: number
}
export type CafeBill = {
subtotalYen: number
taxYen: number
totalYen: number
taxByRate: Record<string, number>
}
export function calcCafeBill(items: LineItem[], opts: { rounding: RoundingMode }): CafeBill {
if (items.length === 0) {
return { subtotalYen: 0, taxYen: 0, totalYen: 0, taxByRate: {} }
}
// 次のステップで育てる🌱
return { subtotalYen: 0, taxYen: 0, totalYen: 0, taxByRate: {} }
}
✅ チェック:まず“形”だけ通す!ここで頑張りすぎない🙆♀️
ステップ2:明細1つ(10%)☕️
「コーヒー 500円 ×1、税10%、切り捨て」
it('コーヒー 500円×1(10%・切り捨て)=> 小計500 税50 合計550', () => {
const result = calcCafeBill(
[{ name: 'coffee', unitPriceYen: 500, qty: 1, taxRatePercent: 10 }],
{ rounding: 'floor' }
)
expect(result.subtotalYen).toBe(500)
expect(result.taxYen).toBe(50)
expect(result.totalYen).toBe(550)
expect(result.taxByRate).toEqual({ '10': 50 })
})
実装(まずベタでもOK🙆♀️)
function assertValidItem(item: LineItem): void {
if (!Number.isInteger(item.unitPriceYen) || item.unitPriceYen < 0) throw new Error('unitPriceYen')
if (!Number.isInteger(item.qty) || item.qty <= 0) throw new Error('qty')
if (!Number.isInteger(item.taxRatePercent) || item.taxRatePercent < 0) throw new Error('taxRatePercent')
}
function calcTaxFromTaxable(taxableYen: number, ratePercent: number, rounding: RoundingMode): number {
const numerator = taxableYen * ratePercent // 例: 500*10=5000
const div = Math.floor(numerator / 100)
const rem = numerator % 100
if (rounding === 'floor') return div
if (rounding === 'ceil') return rem === 0 ? div : div + 1
// halfUp(四捨五入): 0.50円以上を切り上げ
return rem >= 50 ? div + 1 : div
}
export function calcCafeBill(items: LineItem[], opts: { rounding: RoundingMode }): CafeBill {
const rounding = opts.rounding
let subtotal = 0
const taxableByRate: Record<string, number> = {}
for (const item of items) {
assertValidItem(item)
const line = item.unitPriceYen * item.qty
subtotal += line
const key = String(item.taxRatePercent)
taxableByRate[key] = (taxableByRate[key] ?? 0) + line
}
const taxByRate: Record<string, number> = {}
let tax = 0
for (const [rate, taxable] of Object.entries(taxableByRate)) {
const rateNum = Number(rate)
const t = calcTaxFromTaxable(taxable, rateNum, rounding)
taxByRate[rate] = t
tax += t
}
return {
subtotalYen: subtotal,
taxYen: tax,
totalYen: subtotal + tax,
taxByRate,
}
}
✅ チェック:
- 税が
10%でちゃんと50円になってる?💴 taxByRateが 税率別になってる?🧾
ステップ3:数量(qty)を増やす🍰
it('ケーキ 420円×2(10%・切り捨て)=> 小計840 税84 合計924', () => {
const result = calcCafeBill(
[{ name: 'cake', unitPriceYen: 420, qty: 2, taxRatePercent: 10 }],
{ rounding: 'floor' }
)
expect(result).toMatchObject({
subtotalYen: 840,
taxYen: 84,
totalYen: 924,
taxByRate: { '10': 84 },
})
})
✅ チェック:テスト名が「仕様」になってる?📝✨
ステップ4:複数商品を合算🍩🥤
it('複数商品 => 小計を合算して税計算', () => {
const result = calcCafeBill(
[
{ name: 'coffee', unitPriceYen: 500, qty: 1, taxRatePercent: 10 },
{ name: 'donut', unitPriceYen: 180, qty: 2, taxRatePercent: 10 },
],
{ rounding: 'floor' }
)
expect(result.subtotalYen).toBe(860)
expect(result.taxYen).toBe(86)
expect(result.totalYen).toBe(946)
})
ステップ5:税率ごとにまとめて「1回だけ」丸める📌(重要!)
日本のインボイス制度では、端数処理は 税率ごとにまとめて1回という考え方が案内されてるよ(運用としても多い)🧾✨ (Stripe)
違いが出る “わざと” の例👇
- 99円を8%で税計算すると 7.92円 → 切り捨てで7円
- 行ごと丸め:7円 + 7円 = 14円
- 税率ごとに合算して丸め:198円×8% = 15.84円 → 切り捨てで15円(違う!😳)
it('税は税率ごとに合算してから丸める(行ごと丸めない)', () => {
const result = calcCafeBill(
[
{ name: 'snackA', unitPriceYen: 99, qty: 1, taxRatePercent: 8 },
{ name: 'snackB', unitPriceYen: 99, qty: 1, taxRatePercent: 8 },
],
{ rounding: 'floor' }
)
expect(result.subtotalYen).toBe(198)
expect(result.taxByRate).toEqual({ '8': 15 })
expect(result.taxYen).toBe(15)
expect(result.totalYen).toBe(213)
})
✅ チェック:
- “行ごと丸め” してない?(税率別にまとめてから丸めてる?)👀✨
ステップ6:端数処理モードをテストで固定🧷
同じ入力で、丸めだけ変えるテストを作るよ〜💡
it('端数処理:floor(切り捨て)', () => {
const r = calcCafeBill([{ name: 'x', unitPriceYen: 99, qty: 1, taxRatePercent: 8 }], { rounding: 'floor' })
// 99*8%=7.92 => 7
expect(r.taxYen).toBe(7)
})
it('端数処理:ceil(切り上げ)', () => {
const r = calcCafeBill([{ name: 'x', unitPriceYen: 99, qty: 1, taxRatePercent: 8 }], { rounding: 'ceil' })
// 7.92 => 8
expect(r.taxYen).toBe(8)
})
it('端数処理:halfUp(四捨五入)', () => {
const r = calcCafeBill([{ name: 'x', unitPriceYen: 125, qty: 1, taxRatePercent: 8 }], { rounding: 'halfUp' })
// 125*8%=10.00 => 10(ちょうど)
expect(r.taxYen).toBe(10)
})
✅ チェック:
- 端数処理が「なんとなく」じゃなく、テストでガチガチに固定できた?🔒💕
ステップ7:異常系(入力ミス)も仕様にする🚫
「負の値」「数量0」みたいなミスは、ちゃんとエラーにして守るよ🛡️
it('異常:qtyが0以下ならエラー', () => {
expect(() =>
calcCafeBill([{ name: 'coffee', unitPriceYen: 500, qty: 0, taxRatePercent: 10 }], { rounding: 'floor' })
).toThrow()
})
it('異常:unitPriceYenが負ならエラー', () => {
expect(() =>
calcCafeBill([{ name: 'coffee', unitPriceYen: -1, qty: 1, taxRatePercent: 10 }], { rounding: 'floor' })
).toThrow()
})
仕上げのリファクタ(小さくね🧹✨)
ここは「大工事しない」ルールでいくよ😊
- ✅ 関数名を “会計っぽい言葉” に(
taxableByRateとか) - ✅ テストの重複を 消しすぎない(読み物として残す📘)
- ✅ 端数処理は
calcTaxFromTaxableに閉じ込める(責務がキレイ✨)
🤖AIの使い方(この章で強い!)
1) テストケース増やし(抜け探し)🔍
コピペして使ってOK👇
「カフェ会計」のTDDをしています。
仕様:小計(円)→税(税率ごとに合算して1回丸め)→合計。
端数処理は floor/ceil/halfUp。
不足していそうなテスト観点を、正常/境界/異常に分けて10個出して。
ただし“実装の詳細”に依存しない観点だけにして。
2) テスト名の改善📝
このテスト名を「仕様が読める名前」に直して。Given/When/Thenの雰囲気で3案。
(短く、でも誤解がない名前に)
最終チェック✅(合格ライン🎉)
- ✅ テストが 仕様書みたいに読める📘✨
- ✅ 正常→境界→異常が揃ってる🧪
- ✅ 税の丸めが「税率ごとに1回」になってる🧾 (Stripe)
- ✅ 端数処理モードがテストで固定されてる🔒
- ✅ お金の計算で小数を直接扱ってない💴✨
ちょい豆知識(2026時点の“今”)🗓️✨
- Vitest は v4.0.17 が現行の latest(最近も更新されてるよ🧪)(NPM)
- TypeScript は npm上の最新が 5.9.3(5.9系)だよ🧠(NPM)
- Node.js は v24 が Active LTS(LTS表で確認できるよ)🟢(Node.js)
次はこの会計を土台にして、もうちょい条件が増える「ミニ演習②(割引・クーポン)」に繋げると超気持ちいいよ〜🎟️🧾💕 必要なら、この章を「コミット手順(例:test→feat→refactor)」まで書いた“提出用フォーマット”に整えて渡すね😊✨