02 テストコードの体験
この章が本編の山場です。同じ題材で 2 回取り組みます。
01-messy-no-test… テストが無い。消費税を変えたら別画面が壊れる。手作業でしか気づけず、見落とす。02-with-test… テストがある。同じ変更でテストが即赤くなり、どこが壊れたかを指してくれる。安心して直せる。
テストアプリの仕様(確定版)
このアプリ仕様は本ハンズオン全体の「正」とします。他章は重複を避け、ここを参照します。
方針
- Hono + サーバーサイド HTML だけ。PHP のように、サーバがHTMLを返し、画面遷移は全ページリロード。
- 操作は
<form>の GET / POST で完結。クライアント側 JavaScript フレームワークは使わない。 - HTML フォームは PUT / DELETE を直接送れないため、PHP と同様に
<input type="hidden" name="_method" value="PUT">のような method override で表現する。 - 認証は
/admin/*のみ。Cookie セッションで、ID / パスワードはハードコードの簡易ログイン。複雑にしない。
顧客向けルート(公開)
| メソッド | パス | 役割 |
|---|---|---|
| GET | / | トップ。人気商品・検索フォーム・カテゴリ一覧 |
| GET | /items | 商品一覧。?keyword= ?category= で絞り込み |
| GET | /items/:id | 商品詳細。「かごに入れる」フォーム |
| POST | /cart | かごに追加 → /cart へリダイレクト |
| GET | /cart | 買い物かご。税込価格を表示・購入ボタン |
| POST | /orders | 購入確定。注文+明細を tax_category 付きで保存 |
管理向けルート(/admin/* は要ログイン)
| メソッド | パス | 役割 |
|---|---|---|
| GET / POST | /admin/login | ログイン画面 / 認証 |
| GET | /admin | ダッシュボード。今月の課税売上・非課税売上・注文数 |
| GET | /admin/items | 商品管理一覧 |
| GET | /admin/items/new | 新規作成フォーム |
| POST | /admin/items | 商品作成 |
| GET | /admin/items/:id | 商品編集(PUT)・削除(DELETE)フォーム |
| GET / POST | /admin/orders | 注文一覧 / 注文作成 |
| GET | /admin/orders/new | 注文の新規作成フォーム |
| GET | /admin/orders/:id | 注文詳細(更新 PUT・削除 DELETE) |
| GET / POST | /admin/orders/:id/details | 明細一覧 / 明細追加 |
| PUT / DELETE | /admin/orders/:id/details/:detailId | 明細の更新・削除 |
注文・明細の PUT / DELETE は、フォーム+
_methodで実装します。
データモデル(SQLite)
items id, name, category, price(税抜), tax_category, tax_rate tax_category : 'STANDARD'(標準10%)| 'REDUCED'(軽減8%)| 'EXEMPT'(非課税)orders id, created_at, totalorder_details id, order_id, item_id, name, price, quantity, tax_category, tax_rateconfig key, value 'standard_tax_rate' = '0.1' をここに置き、UPDATE で '0' に変えるポイント: 注文時には
order_details.tax_category(その取引が課税か非課税か)を保存しています。つまり「課税かどうか」を正しく判定する材料はDBに揃っています。問題は、それを使っていない画面があることです。
バグの仕込み場所
| 層 | 場所 | 旧実装(壊れる) | 正しい実装(02-with-test) |
|---|---|---|---|
| 表示 | 買い物かご toCartLine | tax_rate > 0 ? '税込' : '非課税' | tax_category !== 'EXEMPT' で判定 |
| 集計 | 管理画面 sumSalesByTaxability | tax_rate > 0 で課税/非課税を振り分け | tax_category 列で振り分け |
顧客側(新実装)は tax_category を保存しているのに、管理側(旧実装)は税率で判定している。 これが食い違いの正体です。
物語の手順
Phase A:消費税 10%(平常運転、すべて整合)
/で「小麦粉」を検索する/items?keyword=小麦粉に薄力粉が出る/items/1で薄力粉750g(税抜 200円)を「かごに入れる」/cartで 税込 220円(課税)と表示される。購入ボタンを押す → 注文がtax_category='STANDARD'、total=220で保存される/adminを開く → 未ログインなので/admin/loginにリダイレクトされる/admin/loginでログインする/adminのダッシュボードに「今月の課税売上・非課税売上・注文数」が出る/admin/ordersでこの注文を確認 → 課税売上 220円 ✅
ここまで、顧客画面と管理画面の答えは一致します。旧実装でも tax_rate(10%) > 0 なので正しく「課税」と判定できるからです。だから誰も食い違いに気づきません。
Phase B:DB で税率を 10% → 0% に変更
減税対応として、コードは一切触らず、DB を直接開いて税率を 0% にします。
UPDATE config SET value = '0' WHERE key = 'standard_tax_rate';UPDATE items SET tax_rate = 0 WHERE tax_category = 'STANDARD';そして、もう一度まったく同じ買い物をします。
- 顧客画面:
tax_category='STANDARD'(課税)のまま注文を保存。税率0%なので税込 200円。データ上は正しく「課税」。 - 管理画面
/admin/orders: 旧ロジックがtax_rate(0%) > 0 == falseと判定 → この注文を 非課税売上 200円 として集計してしまう ❌
同じ「課税の薄力粉」なのに、顧客側データは課税・管理側集計は非課税、と社内で食い違う状態になりました。バグは設定変更の瞬間に生まれましたが、画面を突き合わせない限り気づけません。
コラム:なぜ経理上まずいのか(ドメイン知識)
「税込価格の表示が違うだけでしょ?」では済みません。集計の土台が狂います。
効くのは「税額が0円か」ではなく「課税取引か」
消費税の世界では、金額そのものより 区分 が集計の基礎になります。
- 課税売上高 … 課税取引の売上合計
- 課税売上割合 … 課税売上 ÷ 総売上
- これらは 仕入税額控除(払った消費税を差し引く計算)の前提になる
同じ「税額0円」でも、意味はまったく違います。
課税商品 → 税率0% → 税額0円 ← 課税売上に算入する非課税商品 → 税額なし ← 課税売上に算入しないif (taxAmount === 0) { exempt = true } のように書くと、本来の課税売上まで非課税売上に化け、課税売上高・課税売上割合・仕入税額控除がすべてズレ、消費税の申告額が狂います。最悪、修正申告や税務調査のリスクになります。
「税率0%」と「制度廃止」は別物
- 税率だけ0%にする(輸出取引の実質ゼロ税率と同じ考え方): 「課税売上・税率0%」という概念が残る。だから
tax_rate > 0を前提にしたコードを全部洗い出して直す必要がある。 - 消費税制度そのものを廃止する: 課税/非課税/税率という概念自体が不要になる。今度は会計・POS・EC・請求書システムから消費税機能を削除する改修が要る。
「消費税を0%にするとシステム改修が大変」と言われるのは、taxRate = 0 の1行の話ではなく、長年あちこちに埋め込まれた『税率は必ず正の値』『税額0円=非課税』という暗黙の前提を全部洗い出す必要があるからです。古い基幹システムほど影響が広い。
→ だからこそ自動テストが効きます。 前提が埋まった箇所をテストが炙り出し、設定を変えた瞬間に赤で教えてくれます。
01-messy-no-test:テストが無いとどうなるか
テストがありません。バグを見つける手段は 手作業の突き合わせだけです。
- 買い物をする →
/admin/ordersを見る → 顧客側の表示と管理側の集計を目で照合する - 商品が1種類ならまだしも、カテゴリや税区分が増えれば、全パターンを手で確認するのは現実的ではない
- 結果、「ノートPCが非課税売上に化けている」を見落としたまま本番へ
この「怖さ」を、実際に Phase A → B を手で操作して体験してください。
02-with-test:テストがあるとどうなるか
同じアプリに、ドメインロジックのテストが付いています。たとえば管理画面の集計関数に対して:
import { test } from 'node:test'import assert from 'node:assert/strict'import { sumSalesByTaxability } from '../src/domain/sales.js'
test('標準課税の注文は税率0%でも課税売上に集計される', () => { // 準備:税率0%だが税区分は STANDARD(課税) const orders = [{ total: 200, tax_rate: 0, tax_category: 'STANDARD' }]
// 実行 const sales = sumSalesByTaxability(orders)
// 検証:課税側に入るべき。旧実装だと taxable:0 / exempt:200 で赤になる assert.equal(sales.taxable, 200) assert.equal(sales.exempt, 0)})- 税率を 0% にした瞬間(あるいは旧実装のまま)に、このテストが赤になる
- メッセージが「課税売上のはずが非課税に集計されている」と教えてくれる
- 原因は「管理画面が
tax_rateで判定している=旧世代の実装が残っている」と即座に特定できる
修正
集計も表示も、税率ではなく tax_category で課税・非課税を判定するように直します。
// 旧(壊れる) line.tax_rate > 0// 新(正しい) line.tax_category !== 'EXEMPT'直すとテストが緑に戻り、税率を 0% にしても課税の薄力粉はちゃんと課税売上に集計されます。「テストがあると、壊れた瞬間に分かり、安心して直せる」——これがこのハンズオンの核心です。
次へ: 03 自動テストの基本 →