見つけにくいバグ
この部の 6 / 6 章 ・ 全体で 61 / 76 章 ・ 読了目安 45 分
- テストが通っていても間違うパターンを知っている
- 境界値と意地悪なテストデータを用意できる
- 「自分の環境では動く」の典型原因を切り分けられる
コンパイルは通る。テストも通る。レビューも通った。 それでも間違っているコードがあります。
・1年間、誰も気づかなかった集計のずれ
・特定の名前の利用者だけログインできない
・月末だけ数字が合わない
・macOS では動くのに、CI では落ちる
この章は、そういうバグの図鑑です。 一度見ておけば、コードを書く時に「あ、これは危ないかも」と手が止まります。
数値
浮動小数点 — 誤差は蓄積し、桁は落ちる
ビットとバイトで 0.1 + 0.2 !== 0.3 を扱いました。ここではその先です。
桁落ち: 近い値どうしの引き算で、有効な桁が失われます。
const a = 1.0000001;
const b = 1.0000000;
a - b; // 1.0000000116860974e-7 ← 期待は 1e-7。既に精度が怪しい「差」を求める処理は、元の値が近いほど誤差の割合が大きくなります。
情報落ち: 大きい値に小さい値を足すと、消えます。
1e16 + 1; // 10000000000000002 ← 1 が正しく足せていない
1e16 + 1 === 1e16; // 環境によっては true になりうる累積: 小さい誤差でも、繰り返すと積み上がります。
let sum = 0;
for (let i = 0; i < 10; i++) sum += 0.1;
sum; // 0.9999999999999999
sum === 1; // false□ 金額・数量は「整数(最小単位)」か Decimal で持つ(ビットとバイト)
□ 等値比較をしない。誤差を許容して比較する
□ 合計を求めるなら、順序や方法で誤差が変わることを知っておく
□ 「差」を扱う処理は特に慎重に
// 浮動小数点の比較
const nearlyEqual = (a, b, eps = 1e-9) => Math.abs(a - b) < eps;整数の罠
// JavaScript の安全な整数は 2^53 まで(JavaScript の基礎)
9007199254740993; // 9007199254740992 になる// 整数の除算は切り捨てられる
fmt.Println(7 / 2) // 3 (3.5 ではない)
fmt.Println(7.0 / 2) // 3.5
// 負の数の剰余は、言語によって符号が違う
// Go: -7 % 3 == -1
// Python: -7 % 3 == 2□ 平均を出す時、整数除算で切り捨てていないか
□ ページ数の計算(切り上げが必要)
□ オーバーフロー(int32 の上限、2038年問題。ビットとバイト)
丸め
Math.round(2.5); // 3
Math.round(-2.5); // -2 ← -3 ではない(0 方向ではなく正方向に丸める)
(1.005).toFixed(2); // "1.00" ← 1.005 が内部的に 1.00499... のため会計処理では、丸めの方法を仕様として決めてください。 四捨五入なのか、切り捨てなのか、銀行家丸めなのか。 そして丸めるタイミング(各明細か、合計か)でも結果が変わります。
境界
オフバイワン
□ 「10件表示」が9件、または11件になっていないか
□ 配列の最後の要素にアクセスできているか
□ ページネーションで、最後のページの1件が消えていないか
□ 期間の指定が「以上・以下」か「以上・未満」か(バッチとジョブ)
常にテストすべき境界値
□ 0件(空配列・空文字・null)
□ 1件(複数形の処理、区切り文字の処理)
□ ちょうど上限
□ 上限 + 1
□ 最大値・最小値・負の値
□ 重複した値
const max = Math.max(...[]); // -Infinity
const avg = list.reduce((a, b) => a + b) / list.length; // 空だと例外
const first = list[0].name; // 空だと TypeError「データが必ず1件はある」という前提は、本番で必ず裏切られます。
文字列
文字数は「見た目」と一致しない
'あ'.length; // 1
'👨👩👧'.length; // 8 ← 見た目は1文字(ビットとバイト)
'が'.length; // 1 または 2(濁点が分離されていると2)「20文字まで」の仕様は、何を数えるのかを決めないと実装できません。
大文字小文字の変換は、ロケールに依存する
'I'.toLowerCase(); // 'i'
'I'.toLocaleLowerCase('tr'); // 'ı' ← トルコ語では点の無い i になるサーバーのロケール設定次第で、メールアドレスの正規化が壊れます。 比較や正規化では、ロケール非依存の関数を使ってください。
Unicode 正規化
「が」= U+304C(1文字)
「が」= U+304B + U+3099(か + 濁点、2文字)
見た目は同じでも、バイト列が違うので === は false です。
□ 検索でヒットしない
□ 重複チェックをすり抜ける
□ ファイル名が macOS と Linux で違う形になる
保存・比較の前に正規化(NFC など)を統一してください。
見えない文字
□ 前後の空白(コピペで混入する)
□ 全角スペース
□ ゼロ幅スペース、BOM
□ 改行コードの違い(CRLF と LF)
「見た目は合っているのに一致しない」の原因は、ほぼこれです。
時刻
ビットとバイトとバッチとジョブでも触れましたが、まとめておきます。
□ タイムゾーン(UTC で保存、表示時に変換)
□ 「今日」の定義(どのタイムゾーンの今日か)
□ 月末(1月31日の1ヶ月後は?)
□ うるう年(2月29日生まれの誕生日処理)
□ 夏時間(存在しない時刻・2回来る時刻がある)
□ 日をまたぐ処理
start := time.Now()
doWork()
elapsed := time.Since(start) // Go は単調時計を使うので安全// JS: Date.now() は NTP による時刻補正で「巻き戻る」ことがある
const start = performance.now(); // 単調増加。計測にはこちらを使うシステムの時刻同期が働くと、Date.now() の差が負になることがあります。
「処理時間が -3ms」というログを見たら、これです。
if (isToday(record.date)) { // 内部で now() を呼ぶ
const label = formatDate(new Date()); // ここでも呼ぶ
}23:59:59.999 に実行されると、この2つで日付が変わります。 1リクエストの中では、最初に1回取得した時刻を使い回してください。 テストも書きやすくなります(設計の基礎)。
コレクション
反復中に変更しない
for (const item of list) {
if (item.expired) list.splice(list.indexOf(item), 1); // 要素を飛ばす
}削除しながら回すと、要素が飛びます。 新しい配列を作るか、後ろから回してください。
const alive = list.filter((i) => !i.expired);参照の共有
const template = { tags: [] };
const a = { ...template };
const b = { ...template };
a.tags.push('x');
b.tags; // ['x'] ← 同じ配列を共有している(JavaScript の基礎)// Go のスライスも同じ。append で元の配列を書き換えることがある
a := []int{1, 2, 3}
b := a[:2]
b = append(b, 99)
fmt.Println(a) // [1 2 99] ← a も変わる「コピーしたつもり」が最も危険です。
順序
□ マップ(オブジェクト)の反復順序は保証されない言語がある
□ ソートが「安定」かどうかは実装依存のことがある
□ DB の SELECT は、ORDER BY が無ければ順序を保証しない ← 頻出
ORDER BY の無いクエリでページングすると、
ページ間で同じ行が出たり消えたりします。
並行処理
チェックしてから使うまでの隙間
// 危険: 確認と作成の間に、別のリクエストが同じことをする
if !exists(email) {
create(email) // 2つ同時に来ると、両方が create する
}アプリでのチェックは、競合を防げません。 DB の一意制約で守り、違反エラーを処理してください(データベース)。
二重送信
□ 利用者がボタンを2回押す
□ ネットワークのリトライで、同じリクエストが2回届く(性能と負荷対策)
□ キューの再配信(非同期処理とメッセージング)
冪等キーを使い、同じ操作は1回しか成立しないようにします(API を設計する)。
環境の違い
ファイル名の大文字小文字が最頻出の原因です。
macOS の既定: 大文字小文字を区別しない(UserService.ts と userService.ts が同じ)
Linux: 区別する
import { UserService } from './userservice'; // macOS では通る、Linux では落ちる「自分の環境では動く」(開発環境を作る)の代表例です。
□ 改行コード(CRLF / LF)— git の設定と .gitattributes
□ パス区切り(/ と \)
□ ロケール(数値の小数点が「,」の国、日付書式、ソート順)
□ タイムゾーン(開発機は JST、本番は UTC)
□ CPU アーキテクチャ(Docker を使いこなす)
□ 言語ランタイムのバージョン差
エラー処理
try {
await save(data);
} catch (e) {
// 何もしない ← 最悪
}defer f.Close() // 書き込みの失敗が握り潰される
// 書き込みが必要なファイルは、Close のエラーも確認する□ エラーを握り潰さない
□ 「一部だけ成功した」状態をどうするか決める
□ リトライしてよいエラーかを区別する(性能と負荷対策)
□ ログに出すだけで処理を続けていないか
見つける方法
これらは読んでも見つかりません。仕組みで見つけます。
| 手段 | 何が見つかるか |
|---|---|
| 境界値のテスト | 0件・1件・上限・上限+1(テストを書く) |
| 本番相当のデータ量 | 性能とメモリ(計算量とデータ構造・性能と負荷対策) |
| 本番相当のデータ内容 | 絵文字・全角・長い名前・古い日付 |
| リンタと静的解析 | 未使用変数、握り潰し、危険な比較 |
-race 検出器 | データ競合(Go) |
| プロパティベーステスト | ランダムな入力で不変条件を検証する |
| タイムゾーンを変えて実行 | 日付処理のバグ |
開発中のテストデータが test001 山田太郎 ばかりだと、
本番で初めて問題が出ます。
□ 絵文字を含む名前
□ 全角スペースを含む住所
□ 1文字の名前、非常に長い名前
□ 濁点付きの文字(正規化違い)
□ 0円、1円、非常に大きな金額
□ 空文字、null、前後に空白
□ 過去・未来の極端な日付
この一覧をテストデータに入れておくだけで、 本番で出るバグの多くが開発中に出ます。
ユーザー名の重複を防ぐため、登録前に `SELECT` で存在確認してから `INSERT` しています。まれに重複が発生します。原因は?
実務の落とし穴まとめ
- 金額を浮動小数点で扱う — 誤差が蓄積する。整数か Decimal
- 浮動小数点を
===で比較 — 誤差を許容した比較にする - 整数除算の切り捨てに気づかない — 平均・ページ数
- 空・1件・上限をテストしない — 本番で必ず来る
- 文字数を
lengthで数える — 絵文字・結合文字でずれる - ロケール依存の大文字小文字変換 — トルコ語で
iが変わる - Unicode 正規化をしない — 見た目が同じでも一致しない
ORDER BYの無いページング — 行が重複・欠落する- 反復中にコレクションを変更 — 要素が飛ぶ
- コピーしたつもりで参照を共有 — JavaScript の基礎
now()を複数回呼ぶ — 日付境界でずれる- 処理時間を壁時計で測る — 時刻補正で負になる
- チェックしてから作成 — 一意制約で守る
- ファイル名の大文字小文字 — macOS で通り、Linux で落ちる
- エラーを握り潰す — 原因が永久に分からなくなる
まとめ
- 通っているテストは、書いた人が想像した範囲しか守っていない
- 数値は誤差・桁落ち・切り捨て・丸め。金額は整数か Decimal
- 境界は 0件・1件・上限・上限+1 を必ず試す
- 文字列は文字数・ロケール・正規化・不可視文字
- 時刻はタイムゾーン・月末・うるう年・
now()の複数呼び出し。 計測には単調時計を使う - コレクションは反復中の変更・参照の共有・順序の非保証
- 並行処理では**「チェックしてから使う」が壊れる**。DB の制約で守る
- 環境差ではファイル名の大文字小文字が最頻出
- 見つける手段は、境界値テスト・本番相当のデータ・静的解析・
-race。 テストデータに意地悪な値を混ぜておくのが最も費用対効果が高い
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| The Floating-Point Guide | https://floating-point-gui.de/ |
| Unicode 正規化(UAX #15) | https://unicode.org/reports/tr15/ |
| MDN: Intl | https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl |
| Go: time パッケージ | https://pkg.go.dev/time |
章末問題
ローカル(macOS)では全テストが通るのに、CI(Linux)だけ import エラーで落ちます。最も可能性が高い原因は?
1件あたり数円の手数料を計算し、月末に合計する処理で、経理の数字と数十円ずれます。最も可能性が高い原因は?
第6部はここまでです。次の第7部からは、安全性をまとめて扱います。