プログラマのための IT 教科書

見つけにくいバグ

この部の 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」というログを見たら、これです。

`now()` を複数回呼ばない
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 では動くのに CI で落ちる

ファイル名の大文字小文字が最頻出の原因です。

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` しています。まれに重複が発生します。原因は?

実務の落とし穴まとめ

  1. 金額を浮動小数点で扱う — 誤差が蓄積する。整数か Decimal
  2. 浮動小数点を === で比較 — 誤差を許容した比較にする
  3. 整数除算の切り捨てに気づかない — 平均・ページ数
  4. 空・1件・上限をテストしない — 本番で必ず来る
  5. 文字数を length で数える — 絵文字・結合文字でずれる
  6. ロケール依存の大文字小文字変換 — トルコ語で i が変わる
  7. Unicode 正規化をしない — 見た目が同じでも一致しない
  8. ORDER BY の無いページング — 行が重複・欠落する
  9. 反復中にコレクションを変更 — 要素が飛ぶ
  10. コピーしたつもりで参照を共有 — JavaScript の基礎
  11. now() を複数回呼ぶ — 日付境界でずれる
  12. 処理時間を壁時計で測る — 時刻補正で負になる
  13. チェックしてから作成 — 一意制約で守る
  14. ファイル名の大文字小文字 — macOS で通り、Linux で落ちる
  15. エラーを握り潰す — 原因が永久に分からなくなる

まとめ

  • 通っているテストは、書いた人が想像した範囲しか守っていない
  • 数値は誤差・桁落ち・切り捨て・丸め。金額は整数か Decimal
  • 境界は 0件・1件・上限・上限+1 を必ず試す
  • 文字列は文字数・ロケール・正規化・不可視文字
  • 時刻はタイムゾーン・月末・うるう年・now() の複数呼び出し。 計測には単調時計を使う
  • コレクションは反復中の変更・参照の共有・順序の非保証
  • 並行処理では**「チェックしてから使う」が壊れる**。DB の制約で守る
  • 環境差ではファイル名の大文字小文字が最頻出
  • 見つける手段は、境界値テスト・本番相当のデータ・静的解析・-race。 テストデータに意地悪な値を混ぜておくのが最も費用対効果が高い

公式ドキュメント

迷ったら一次情報に戻ってください。

章末問題

ローカル(macOS)では全テストが通るのに、CI(Linux)だけ import エラーで落ちます。最も可能性が高い原因は?

1件あたり数円の手数料を計算し、月末に合計する処理で、経理の数字と数十円ずれます。最も可能性が高い原因は?

第6部はここまでです。次の第7部からは、安全性をまとめて扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。