背景
静的サイトをいくつか自動更新で回していて、そのうち2つが毎日テストで赤くなっていました。
落ちるのはどちらも「生成物が data と一致していない」という同じ趣旨の1件だけ。
昨日いちど再ビルドして直したつもりだったのに、今朝また同じところが赤い…
データは1件も触っていないのに、です。
原因が分かってみると当たり前の話だったので、備忘録として置いておきます。
何を検査していたか
この2サイトは、data/*.json を入力にして public/ 以下の HTML を吐く、素朴な静的サイトジェネレータです。
data を更新したのにビルドを忘れて公開し忘れる、という事故を止めたくて、テストにこういう検査を1つ入れていました。
// public/index.html が、いまの data から生成される内容と一致しているか
const rendered = portal.build(); // data/*.json を読んで HTML 文字列を組み立てる
const onDisk = fs.readFileSync("public/index.html", "utf8");
assert.equal(rendered.html, onDisk, "public/index.html が古い");
portal.build() は data を読んで HTML 文字列を返すだけの関数で、ディスクには書きません。
書き込みは別の関数に分けてあるので、テストからは「いま組み立て直したらどうなるか」だけを取れる、という作りです。
狙いとしては、data を更新して build を忘れたらここで落ちる、というつもりでした。
実際に落ちていた差分
赤くなっている状態で差分を取ってみたら、こうなっていました。
$ git diff -U0 public/index.html | grep -o 'card-fresh">[^<]*' | sort | uniq -c
15 card-fresh">きょう
16 card-fresh">きのう
6 card-fresh">3日前
2 card-fresh">4日前
24行の差分が出ていて、全部がこれでした。
記事の見出しも件数も出典も、1文字も変わっていない。
card-fresh というのは、記事カードに出している「きょう / きのう / N日前」のラベルです。
生成時に、記事の日付とその日の日付の差から計算しています。
function freshness(iso, today) {
const d = Math.round((Date.parse(iso) - Date.parse(today)) / 86400000) * -1;
if (d <= 0) return "きょう";
if (d === 1) return "きのう";
return d + "日前";
}
iso は記事の日付(2026-09-11 のような文字列)、today はビルドを走らせた日です。
つまり data が不変でも、日付が変われば出力が変わる。
何を測っていたことになるか
ここが今日の学びでした。
この検査の名前は「生成物が data と一致しているか」ですが、実際に測っていたのは 「今日ビルドしたか」 です。
data が変わっていなくても、日付が変われば必ず食い違う。
だから、ビルドを伴う通常のデプロイ経路では build 直後に検査するので必ず通るのに、ビルドを伴わない監査(全サイトを横断で検査するバッチ)では毎日必ず落ちる。
そして厄介なのが、この赤に対する「再ビルドすれば直る」という対処が24時間だけ有効なことです。
昨日それをやって「解消しました」と記録したのに、今朝また赤くなっていた。
再ビルドは対処になっていなかった、というのが実測でした。
直し方
書き込みの判定はそのままにして、比較のときだけ揮発する部分を落とすようにしました。
function stripVolatile(html) {
return String(html).replace(/<span class="card-fresh">[^<]*<\/span>/g, "");
}
そしてテスト側はこう。
assert.equal(
portal.stripVolatile(rendered.html),
portal.stripVolatile(onDisk),
"public/index.html が古い",
);
書き込み側(build して public/ に吐くほう)は素のバイト比較のままにしてあります。
そうしないと、本番の「きょう」が「きのう」に更新されなくなってしまうので。
検査だけを緩める、書き込みは緩めない、という分け方です。
中身の差し替えではなく span ごと消した理由
最初は span の中身を @@FRESH@@ のようなプレースホルダに差し替える実装にしたのですが、片方のサイトで3行だけ赤が残りました。
原因は、freshness() が31日を越えると "" を返す設計になっていて、そのとき span 自体がテンプレートから消えることでした。
`${fresh ? `<span class="nc-fresh">${esc(fresh)}</span>` : ""}`
中身をそろえても「span がある / ない」の差が残る、というわけです。
なので span ごと削除する形に変えました。
この境界はテストで固定してあります。
const has = '<span class="nc-fresh">30日前</span>';
const none = '';
assert.equal(stripVolatile(has), stripVolatile(none), "span の有無も差にしない");
検査が骨抜きになっていないかの確認
マスクを入れると「何も検出しない検査」になっていないかが気になるので、実際に本文を1か所書き換えて確かめました。
$ # public/index.html の出典名を1つだけ書き換えてからテスト
$ node --test test/
not ok 96 - 生成物: public/ が data/entries.json と一致している
# pass 250
# fail 1
data 由来のずれはちゃんと落ちます。
逆に、相対日付だけがずれている今日の状態では通る。
$ node tools/portal.js --check
$ echo $?
0
<time datetime="2026-09-11"> のような絶対日付はマスクしていないので、日付そのものの間違いは従来どおり検出できます。
結果はこうなりました。
| before | after | |
|---|---|---|
| サイトA | 180 pass / 1 fail | 182 pass / 0 fail |
| サイトB | 249 pass / 1 fail | 251 pass / 0 fail |
対象のサイトのひとつはこれです。
https://kokoro.autoarticles.net
一般化するとこうなる
生成物を丸ごと比較する検査を書くときは、出力に実行時刻由来のものが混ざっていないかを先に確認したほうがいい。
相対日付、ビルド時刻、乱数、UUID(生成のたびに違う値になる識別子)、Date.now() から作った ID。
どれも「data は同じなのに出力が違う」を作ります。
そして混ざっていたら、比較の前にそこを落とす。
落とす範囲は狭いほどいいけれど、存在/非存在が切り替わる要素は要素ごと落とす必要がある、というのが今日の追加の学びでした。
毎日赤い検査は、そのうち誰も見なくなる。
だから「毎日落ちている」を見たら、直す前にこの検査は何を測っているのかをもう一度読んだほうが早いです。
本記事はAI補助で執筆した、個人開発の紹介記事です。