配列を空行で挟む実装は空配列で2連続、endsWithでは検出できない
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
任意ブロックを `"", ...block, ""` のように空行で挟むと、block が空配列になった瞬間、前後の空行だけが生き残って2つ連続する。空行はブロックを挟むのではなく、ブロックの有無と一緒に出し入れする。
結論
任意ブロックの前後を固定の空行で挟む作りは、そのブロックが空配列になった瞬間、前後の空行だけが生き残って2つ連続する。 空行は「挟む」のではなく、ブロックの有無と一緒に出し入れしないと崩れる。さらに今回は、この崩れを検出するはずの回帰テストにも2つの穴があった。ひとつは対象の分岐がそもそも網に乗っていなかったこと、もうひとつは締めの文言を endsWith だけで検査していたために、空行の有無そのものを区別できていなかったことだった。
症状
顧客向けの更新案内メール(掲載期間満了が近づいた契約への自動送信)で、本文を配列で組み立ててから join("\n") している箇所がある。契約内容や金額を案内する「任意ブロック」は、プランも金額も設定されていない契約(無償掲載・試験掲載など)では空になる。
// 空配列になりうる(プランも金額も無い契約)。前後を "" で挟まず、
// 使う側で「前の空行ごと」スプレッドすること(空行が2つ続く欠陥の原因)
const condLines = [
c.plan ? `ご契約内容: ${c.plan}` : "",
c.amount != null
? `現在の掲載料金: ${formatYen(c.amount)}(税別)※同条件で継続いただけます`
: "",
].filter(Boolean);
修正前はこの condLines を本文配列に差し込むとき、前後を固定の ""(空行)で挟んでいた。
...effLines,
"",
...condLines,
"",
...continueLines,
condLines に要素があるとき(プランや金額がある契約)は、これで「空行→内容→空行」という意図した体裁になる。問題は condLines が空配列になったときで、...condLines は何も展開しないため、残るのは前後の "" 2つだけになる。結果として本文に空行が2行連続し、段落の区切りとして不自然な見た目になっていた。
原因
原因は2つある。ひとつはテンプレート側の作り、もうひとつはそれを捕まえるはずの回帰テストの穴。
1. 空行を「挟む」実装そのもの
"", ...block, "" という書き方は、block に1件以上の要素があることを前提にしている。block が空配列になりうる設計(今回は「プランも金額も無い契約」が実在した)なら、この前提は崩れる。空行は本来「ブロックの内容を前後の段落から区切るための装飾」であって、ブロックが無いなら空行も要らない。ところがコードの形は「空行」と「ブロック」を別々の要素として並べていたため、ブロックの有無と空行の有無が独立に扱われてしまっていた。
2. 回帰テストの穴がこの分岐を拾えなかった
このコードベースには、顧客向けメール本文の体裁を検査する共通関数がある。
/**
* 顧客向けメール本文の体裁の不変条件(2026-07-24 の棚卸しで見つかった欠陥の回帰網)。
*
* 見つかった欠陥は2種類とも「空行の扱い」だった:
* ① `.filter(Boolean)` が意図した空行まで落として、本文が段落の無い一続きの塊になっていた。
* ② 省略されうる行の外側に空行を置いていたため、その行が全部消えると空行が2つ続いた
*/
export function expectMailFormat(text: string): void {
expect(text).toContain("\n\n");
expect(text).not.toMatch(/\n{3}/);
expect(text.startsWith("\n")).toBe(false);
expect(text.endsWith("\n")).toBe(false);
expect(text.endsWith("\n" + MAIL_SIGNATURE_LINES.join("\n"))).toBe(true);
expect(text).not.toContain("送信専用");
}
expect(text).not.toMatch(/\n{3}/) は、空行が2つ連続していれば本来検出できるアサーションになっている。実際、このコードベースでは請求書や領収証、未署名リマインドのテンプレートで同種の欠陥が先に見つかり、修正済みだった。ところが更新案内のテンプレートだけは、この expectMailFormat を通す回帰テストの対象に「プランも金額も無い契約」というケースが用意されていなかった。アサーション自体は正しくても、その入力(空配列になる契約データ)を一度も作っていなければ、テストは何も検出できない。
3. さらに別の穴: endsWith が空行の有無を区別できていなかった
expectMailFormat には、本文の締め(署名)が単一ソースと一致するかを検査する行もある。
export const MAIL_SIGNATURE_LINES = [
"",
"※ このメールは自動送信です。ご返信いただけます(担当者に届きます)。ご不明点は rebounder@googlegroups.com までご連絡ください。",
];
MAIL_SIGNATURE_LINES の先頭要素は空文字列であり、.join("\n") すると結果の先頭に改行が1つつく。つまり MAIL_SIGNATURE_LINES.join("\n") は "\n※ ..." という、すでに改行から始まる文字列になる。
修正前の検査はこうだった。
expect(text.endsWith(MAIL_SIGNATURE_LINES.join("\n"))).toBe(true);
text.endsWith("\n※ ...") が真であるためには、text の末尾が「改行+※の行」であればよい。だが、この「改行」は MAIL_SIGNATURE_LINES 自身が内包しているものであり、本文側に締めの前の空行が実際にあるかどうかとは無関係に成立してしまう。たとえば締めの直前の空行が何らかの理由で落ちていても、その直前にあるのが本文の1行(末尾に改行はあるがその行自体は空行ではない行)であれば、"\n" + "※ ..." という並びはやはり成立し、endsWith は true を返す。つまりこのアサーションは「締めの文言が正しいか」は検査できていても、「締めの前に空行があるか」という、まさに今回の欠陥①の一種を検出する力を持っていなかった。
直す
空行を「挟む」のではなく、ブロックの有無と一緒に出し入れする形に統一した。
...effLines,
...(condLines.length ? ["", ...condLines] : []),
"",
...continueLines,
condLines に要素があるときだけ、その直前に空行つきでまとめて展開する。condLines が空配列なら、このスプレッド全体が何も生まないため、前後に空行が残ることはない。更新案内の6分岐すべて(自動更新あり/なし × 満了7日前/60日前/再送)で同じ形に直し、配列側のコメントにも「空配列になりうる」意図を明記した。
回帰テストには、プランも金額も無い契約を自動更新/手動更新の両分岐で通すケースを追加し、expectMailFormat に通した。この分岐を欠陥のある実装に戻すとテストが2本とも落ちることを確認済みにした。
endsWith の検査も、空行の有無まで区別できる形に変えた。
expect(text.endsWith("\n" + MAIL_SIGNATURE_LINES.join("\n"))).toBe(true);
比較対象の文字列の先頭にもう1つ "\n" を足すことで、MAIL_SIGNATURE_LINES 自身が持つ改行では満たせない、締め前の空行ぶんの改行を明示的に要求するようにした。これで締めの直前の空行が落ちていれば、このアサーションも落ちるようになる。
再発しない形にする
可変長の配列を固定の空行区切りで本文に差し込む作りは、要素ゼロのケースを明示的にテストしない限り、同じ形の空行崩れをいつでも埋め込める。直すときは、「空行を挟む」という発想自体をやめて、「空行はブロックの存在に従属する」という形に書き直すのが筋がよい。arr.length ? ["", ...arr] : [] という1パターンに揃えておけば、以後似た任意ブロックを増やすときも同じ形を使い回せる。
もう一段深い教訓は、アサーションの正しさと、そのアサーションが実際に検査できている範囲は別物だということ。text.endsWith(X) は X の内部構造(先頭が改行かどうか)によって、見た目どおりの意味を持たないことがある。join 済みの定数を endsWith/startsWith で比較するときは、その定数自身が区切り文字から始まったり終わったりしていないかを先に確認し、必要なら比較対象の側に区切り文字を明示的に足してから比較するほうが安全になる。
よくある質問
Q1配列を空行で挟んで本文に差し込むと何が起きますか?
`"", ...block, ""` のように任意ブロックの前後を固定の空行で挟むと、block が空配列になった時に残るのはその前後の空行だけになり、本文に空行が2つ連続して出てしまいます。直すには空行を『挟む』のではなく、`block.length ? ["", ...block] : []` のように、空行をブロックの存在と一緒に出し入れする形にします。
Q2endsWithで末尾の空行の有無を正しく検出できないのはなぜですか?
締めの文言配列自身が空文字列から始まり、joinすると先頭に改行がつくためです。text.endsWith(SIGNATURE_LINES.join("\n")) は『締めの直前に改行が1つあるか』しか見ておらず、その改行は締めの前の空行ではなく本文側の直前の行が供給していても成立してしまいます。空行の有無まで区別するには text.endsWith("\n" + SIGNATURE_LINES.join("\n")) のように、もう1つ余分な改行の有無を固定して検査する必要があります。
Q3この問題は他のテンプレート生成コードにも当てはまりますか?
はい。可変長の配列を固定の空行区切りで差し込む作り全般に当てはまります。配列がゼロ件になるケースを明示的にテストしていない限り、同種の空行崩れは埋め込まれたまま気づかれません。また join済みの文字列を endsWith で検査するときは、締めの定数自身が改行で始まっていないかを確認し、必要なら比較対象の文字列に区切り文字をあらかじめ足してから比較すると、見た目は通っているが実際は区別できていないテストを避けられます。
確認した環境
- TypeScript / Vitest
- 2026-07-24 に修正(npm test 952 green)
この記事の根拠
- TypeScriptファイル 241〜261行目コミット 042fb84
- TypeScriptファイル 291〜312行目コミット 042fb84
- TypeScriptファイル 15〜29行目コミット 042fb84
- TypeScriptファイル 20〜23行目コミット 042fb84
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。