コンテンツにスキップ

02 テストコードの体験

この章が本編の山場です。同じ題材で 2 回取り組みます。

  1. 01-messy-no-test … テストが無い。消費税を変えたら別画面が壊れる。手作業でしか気づけず、見落とす。
  2. 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, total
order_details
id, order_id, item_id, name, price, quantity, tax_category, tax_rate
config
key, value
'standard_tax_rate' = '0.1' をここに置き、UPDATE で '0' に変える

ポイント: 注文時には order_details.tax_category(その取引が課税か非課税か)を保存しています。つまり「課税かどうか」を正しく判定する材料はDBに揃っています。問題は、それを使っていない画面があることです。

バグの仕込み場所

場所旧実装(壊れる)正しい実装(02-with-test
表示買い物かご toCartLinetax_rate > 0 ? '税込' : '非課税'tax_category !== 'EXEMPT' で判定
集計管理画面 sumSalesByTaxabilitytax_rate > 0 で課税/非課税を振り分けtax_category 列で振り分け

顧客側(新実装)は tax_category を保存しているのに、管理側(旧実装)は税率で判定している。 これが食い違いの正体です。


物語の手順

Phase A:消費税 10%(平常運転、すべて整合)

  1. / で「小麦粉」を検索する
  2. /items?keyword=小麦粉 に薄力粉が出る
  3. /items/1 で薄力粉750g(税抜 200円)を「かごに入れる」
  4. /cart税込 220円(課税)と表示される。購入ボタンを押す → 注文が tax_category='STANDARD'total=220 で保存される
  5. /admin を開く → 未ログインなので /admin/login にリダイレクトされる
  6. /admin/login でログインする
  7. /admin のダッシュボードに「今月の課税売上・非課税売上・注文数」が出る
  8. /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 自動テストの基本 →