dry run が本番と違う答えを返す — 副作用を止めた瞬間に消える判定

バッチ処理に --dry-run を付けるのは、副作用を止めて「何が起きるか」を先に見るためです。ところが、判定そのものが副作用の結果に依存していると、副作用を止めた瞬間にその判定も消えます。本番は正しく動くのに、dry の出力だけが間違っている、という状態になります。2026年8月16日に実際に踏んだ例をもとに、原因・直し方・回帰テストの書き方を整理します。

この記事の実測について: 以下の出力は、2026年8月16日に筆者が Node.js v22 系で実際に実行して得たものです。コードは記事内に全文を載せてあり、そのまま再現できます。

何が起きたか

題材は、過去の日付が付いたファイルを当日の日付へ改名してから処理するという、よくあるバッチです。改名後の名前は「当日の日付 + 元の名前の日付以降の部分」で決まります。

2026-08-09-report-blogger.md  →  2026-08-16-report-blogger.md
2026-08-11-report-blogger.md  →  2026-08-16-report-blogger.md   // 同じ名前になる

日付以外が同じファイルが2つあると、改名先が衝突します。 この実装には衝突を防ぐガードが入っていました。改名の直前に fs.existsSync で行き先を確認し、既にあればスキップする、というものです。

const to = path.join(dir, promotedName(base, today));
// 行き先が既にあればスキップ
if (fs.existsSync(to)) { warn(`skip: ${to}`); continue; }
if (!DRY) fs.renameSync(from, to);
log(`promote${DRY ? ' [dry]' : ''}: ${base} -> ${path.basename(to)}`);

本番実行ではこのガードは正しく効きます。1件目を renameSync した瞬間に行き先のファイルが実在するようになるので、2件目の existsSync が真になってスキップされます。データは失われません。

問題は dry のほうです。 if (!DRY) によって renameSync が飛ばされるため、行き先のファイルは最後まで作られません。したがって2件目の existsSync も偽のままで、両方とも「改名する」と表示されます

$ node promote.js --dry
promote [dry]: 2026-08-09-report-blogger.md -> 2026-08-16-report-blogger.md
promote [dry]: 2026-08-11-report-blogger.md -> 2026-08-16-report-blogger.md
ここが本題: この出力を読んだ人は、当然「2件が同じ名前になる。片方が上書きで消えるのでは」と考えます。実際には本番のガードが効くので消えません。壊れていたのは処理ではなく出力のほうでした。そして人は出力を見て判断するので、正しく動く機能の使用を見送るという判断が生まれます。実際にそうなりました。バグの重大度としては「データが消える」より軽いのに、運用への影響は同じだけ出たわけです。

原因を一般化する

この形は rename に限りません。「これから作るもの」の重複を、外部の状態を見て判定している処理はすべて同じ穴を持ちます。

判定に使っているものdry で何が消えるか
fs.existsSync(path)直前のループで作ったはずのファイルが無いので、常に未作成に見える
ディレクトリ一覧の再取得同上。作成の反映が無いので同じ名前を何度でも通す
DB への SELECT による重複確認INSERT を止めているので、同じキーの2件目も新規に見える
ポートやロックの取得可否確保していないので、同じポートを複数のプロセスに割り当てる計画が通る
採番済みIDの照会採番していないので、同じIDが複数回払い出される計画になる

共通しているのは、「世界の現在の状態」を判定に使っていることです。本番ではループが進むにつれて世界が更新されるので判定が成立しますが、dry では世界が更新されないので判定が成立しません。

直し方:判定の相手を「計画」に変える

直し方は単純です。これから行う操作の結果を、自分で台帳に積みます。 判定は「外部の状態 または 台帳」の両方を見ます。台帳への追加は dry でも本番でも同じように行うので、両者の結論が一致します。

const claimed = new Set();   // このランで確保済みの行き先

for (const base of targets) {
  const toBase = promotedName(base, today);
  const to = path.join(dir, toBase);
  // 外部の状態と、このランの計画の両方を見る
  if (fs.existsSync(to) || claimed.has(toBase)) {
    warn(`skip: ${toBase} (${base} は元のまま残す)`);
    continue;
  }
  claimed.add(toBase);
  if (!DRY) fs.renameSync(from, to);
  log(`promote${DRY ? ' [dry]' : ''}: ${base} -> ${toBase}`);
}

修正後の dry の出力は次のようになります。本番と同じ結論です。

$ node promote.js --dry
promote [dry]: 2026-08-09-report-blogger.md -> 2026-08-16-report-blogger.md
skip: 2026-08-16-report-blogger.md (2026-08-11-report-blogger.md は元のまま残す)

あわせてスキップしたときのメッセージに「弾かれた側がどうなるか」を書くのも重要です。「スキップしました」だけだと、その1件が消えたのか残ったのかが読み手に分かりません。今回は「元の日付のまま残す」と明示しました。

設計原則として言い直すと: dry run は「副作用を実行しないモード」ではなく、「副作用を台帳の中だけで実行するモード」として作ります。外部への書き込みを止めるのは最後の一歩だけにして、それ以外の状態遷移は dry でも同じように進めます。こうすると、判定・分岐・件数といった出力に出るものはすべて本番と一致します。

回帰テスト:dry と本番を同じ入力で比べる

この種のバグは「片方のモードでしか出ない」ので、両方のモードを1つのテストで回すのが確実です。検査するのは「実行対象の件数が両モードで一致すること」です。

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');

test('日付以外が同名の対象が2件あっても、実行は1件だけ(dry も同じ判定)', () => {
  for (const dry of [true, false]) {
    const dir = makeWorkspace(['2026-08-09-z.md', '2026-08-11-z.md']);
    const args = ['promote.js', '--date', '2026-08-16'];
    if (dry) args.push('--dry');
    const r = spawnSync('node', args, { cwd: dir, encoding: 'utf8' });
    const out = (r.stdout || '') + (r.stderr || '');   // 警告は stderr に出る

    const promoted = out.split('\n').filter((l) => /^promote/.test(l));
    assert.equal(promoted.length, 1, `実行は1件だけのはず(dry=${dry})`);
    assert.match(out, /^skip:/m);

    if (!dry) {
      // 弾かれた側は元の名前のまま残る(黙って消さない)
      const left = fs.readdirSync(dir).filter((f) => /^2026-08-(09|11)-z\.md$/.test(f));
      assert.equal(left.length, 1);
    }
  }
});

ここで一度つまずいた点

最初はテストヘルパに execFileSync を使っていて、非 dry のケースだけ警告メッセージを検出できませんでした。原因は出力先です。execFileSync成功時に stdout しか返しません。警告は console.warn つまり stderr に出ているので、終了コード0の実行では消えてしまいます。dry のケースが通っていたのは、そちらがたまたま非0で終了して例外経路に入り、ヘルパが stdout と stderr を連結していたためでした。

両方の出力を検査したいテストでは spawnSync を使い、stdoutstderr を明示的に連結してから検査します。 「成功したときだけ検査が甘くなる」テストは、まさに今回のような静かな失敗を見逃します。

Tool テキスト差分(textdiff) dry の出力と本番の出力を貼り付けて、行単位の差を確認できます。両者が一致しているかを目視で確かめる用途に。ブラウザ内で完結し、入力は送信されません。

チェックリスト

既存のバッチに dry run があるなら、次の3点を確認してみてください。

  1. ループの中で、直前の反復が作ったものを前提にした判定があるか。 existsSync・一覧の再取得・SELECT による重複確認が典型です。
  2. その判定は dry でも成立するか。 成立しないなら、計画を積む台帳を1つ足します。
  3. dry と本番を同じ入力で流し、実行対象の件数が一致するテストがあるか。 無ければ、上のかたちで1本足します。

dry run は、実行する前に人が判断するための機能です。判断の材料が本番とずれていると、機能そのものが逆に働きます。 「本番は正しいのだから軽微」と扱わず、出力の正確さも同じ重さで守る価値があります。

再現手順

  • 作業ディレクトリに 2026-08-09-z.md2026-08-11-z.md を置く
  • 本文中の修正前コードで --dry と非 --dry をそれぞれ実行し、出力行を比べる(修正前は dry だけ2行、本番は1行 + skip 1行になる)
  • 台帳(claimed)を入れた修正後コードで同じ2通りを実行し、両者の出力が一致することを確認する
  • 実測日: 2026年8月16日 / Node.js v22 系

よくある質問(FAQ)

dry run と本番実行で結果が変わるのは、どういうときですか?

判定そのものが副作用の結果に依存しているときです。たとえばファイルの重複を fs.existsSync で調べる処理は、本番では直前の rename でファイルが増えているので2件目を弾けますが、dry では rename を飛ばすので存在せず、両方とも実行対象に見えます。副作用を止めた結果、その副作用を前提にしていた判定まで消えてしまう形です。

どう直せばよいですか?

判定の対象を「実際の世界の状態」から「計画上の状態」へ移します。これから作る名前を Set などの台帳に積み、外部の状態と台帳の両方を突き合わせて判定します。dry でも本番でも台帳への追加は行うので、両者の結論が一致します。

回帰テストはどう書きますか?

同じ入力を dry と非 dry の両方で流し、実行対象の件数が一致することを検査します。件数だけでなく、非 dry のときに「弾かれた側が元の状態のまま残っている」ことも合わせて確認すると、静かに消える事故まで塞げます。

テストで警告メッセージが拾えないことがあるのはなぜですか?

警告を stderr に出している場合、成功時に stdout だけを取るテストヘルパでは見えないためです。Node.js の execFileSync は成功時に stdout しか返さないので、両方の出力を検査したいときは spawnSync で stdout と stderr を連結してから検査します。

← 技術ブログ一覧へ戻る