先月、ある受託案件で受注一覧の CSV ダウンロードを作りました。検収で戻ってきた指摘は「備考に改行が入っている注文だけ、Excel で開くと行がずれる」。手元のテストは全部緑でした。
『動いた=品質OK ではない』と普段から言っている側としては、かなり恥ずかしい話です。
テストが緑だったのは、テストが嘘をついたからではありません。私が思いついた入力しか試していなかったからなんですよね。そこで同じ関数に、プロパティベーステスト(以下 PBT)を当て直しました。使ったのは fast-check 4.10.2、読み戻しには csv-parse 7.0.3、Node.js は 22.14.0 です。直した順に 4 つの版を作り、生成器を替えながら、seed を 1〜100 で 100 件ずつ回しています。
例示テストは2本とも緑だった
最初の版はこうです。カンマを含む値だけを " で囲む、よく見かける形です。
// 受注一覧の CSV 出力(最初の版)と、手で書いた例示テスト2本
import { test } from 'node:test';
import assert from 'node:assert/strict';
export const toCsv = (rows) =>
rows
.map((cells) => cells.map((v) => (v.includes(',') ? `"${v}"` : v)).join(','))
.join('\n');
test('見出し行と明細行を出す', () => {
assert.equal(
toCsv([['注文番号', '商品名', '数量'], ['A-001', 'りんご', '3']]),
'注文番号,商品名,数量\nA-001,りんご,3',
);
});
test('カンマを含む住所は囲む', () => {
assert.equal(toCsv([['B-002', '東京都,港区']]), 'B-002,"東京都,港区"');
});
// 実行結果: node --test orders-csv.test.mjs
// # tests 2
// # pass 2
// # fail 0
例示テストは「この入力ならこの出力」という意図を固定するものです。仕様の読み手にとってはいちばん分かりやすく、これはこれで要ります。ただ、2 本とも私が想像できた入力で、備考の改行は想像の外でした。
性質を1本だけ書く:書いて読んだら元に戻る
PBT で最初に書く性質は、往復(ラウンドトリップ)がいちばん書きやすいと思っています。CSV の仕様を全部言葉にできなくても、「toCsv で書いたものを、ちゃんとした CSV パーサーで読み戻したら、元の表と一致する」なら言えるからです。読み戻しを自前で書くと同じ勘違いを二重に持ち込むので、ここは既製の csv-parse に任せました。
生成器は、まず fast-check の fc.string() を既定のまま使いました。1 行 1〜5 列、1〜5 行の表を作って toCsv に通します。
結果は、seed 100 個のうち 100 個で失敗。中央値で 2 件目にはもう落ちていました。そして 100 回とも、縮小(shrinking)後の反例は同じ [[""]]、つまり「1 列だけの行で、その値が空」でした。
正直なところ、最初はバグだと思いませんでした。空の値が 1 つだけの行は、CSV にすると空行になります。パーサーはそれを読み飛ばすので、読み戻すと 0 行です。書いた側は 1 行のつもり、読んだ側は 0 行。バグというより、誰も決めていなかった仕様の穴です。1 列だけの CSV は今回の受注一覧には無いのですが、備考だけを書き出す機能は別の画面で作る予定がありました。そこで「空欄は "" で囲む」と決め、v2 として直しています。
想定外その1:縮んだ反例が、別のバグを隠していた
v2 でも、既定の生成器で 100 個中 100 個の seed が落ちました。今度の反例は [["\"",""],[""]] のような形で、値に " が入っています。v1 の時点でも " は普通に生成されていたはずなのに、反例として出てこなかったんですね。
理由は縮小の仕組みにあります。縮小は「もっと小さい入力でも落ちるか」を順に試し、落ちれば採用します。落ちた理由が元と同じかどうかは見ません。" を含む表で落ちても、縮める途中で [[""]] に行き着けば、そこも落ちるので採用されます。生成器の空文字は 1,000 件中 140 件と多く、縮小の行き先としてはいちばん近い所にあります。
実際、v1 のまま生成器を「空でない値だけ」に替えると、反例は [["\" "," "],[" "]](100 個中 31 個)などに変わり、" のバグが顔を出しました。縮小後の反例が 1 種類でも、バグが 1 つとは限りません。1 つ直したら、もう一度回すしかないです。
想定外その2:既定の生成器は改行を1度も出さなかった
" を "" に重ねて囲む v3 にすると、既定の fc.string() で 100 個中 0 個。unit: 'grapheme' に替えても 0 個。全部緑です。
でも、検収で指摘された改行は、まだ何も直していません。
確かめると、fast-check 4 の fc.string() は、既定で印字できる ASCII 1 文字を並べるだけでした。型定義のコメントにも既定値は 'grapheme-ascii' と書いてあるけど、私は読んでいませんでした。それぞれ 2 万件を取り出して数えると、既定の生成器は改行も日本語も 0 件。'grapheme' はひらがなまで出しますが、印字できる文字に限るので、改行はやはり 0 件でした。カンマと " はどちらも出るので、v1 や v2 は落とせても、改行のバグには最初から届かない生成器だったわけです。
そこで、現場の入力に寄せた候補から 1 文字ずつ選ぶ生成器を自作しました。
import fc from 'fast-check';
import { parse } from 'csv-parse/sync';
import assert from 'node:assert/strict';
import { toCsv } from './orders-csv.mjs';
// 現場の入力に寄せた1文字の候補(日本語・半角カナ・空白・カンマ・"・LF・CRLF)
const cellChar = fc.constantFrom('あ', '漢', 'カ', 'A', '1', ' ', ',', '"', '\n', '\r\n');
const cell = fc.string({ unit: cellChar, maxLength: 8 });
const table = fc.array(fc.array(cell, { minLength: 1, maxLength: 5 }), { minLength: 1, maxLength: 5 });
fc.assert(
fc.property(table, (rows) => {
const back = parse(toCsv(rows), { relax_column_count: true });
assert.deepEqual(back, rows); // 書いて読んだら元に戻る(往復)
}),
{ seed: 42 },
);
// 実行結果(toCsv が v3 = 空欄と " まで直した版のとき):
// Error: Property failed after 1 tests
// { seed: 42, path: "0:1:2:3:2:4:4:4:4:4", endOnFailure: true }
// Counterexample: [[["\n",""],[""]]]
// Shrunk 9 time(s)
同じ v3 が、seed 100 個中 100 個で落ちました。中央値で 1 件目です。縮小後の反例は [["\n",""]](19 個)、[["\r\n",""]](16 個)、[["\n"]](14 個)と seed ごとに形が違います。縮小は「これ以上どこを削っても通る」所で止まるだけなので、いちばん短い反例まで縮むとは限らないようです。
4 つの版と生成器の組み合わせを並べると、こうなりました。
| toCsv の版 | 例示テスト2本 | 既定の fc.string() | unit: ‘grapheme’ | 自作(日本語・改行入り) |
|---|---|---|---|---|
| v1 カンマだけ囲む | 2/2 緑 | 100/100 落ちる | 98/100 落ちる | 100/100 落ちる |
| v2 空欄も囲む | 2/2 緑 | 100/100 落ちる | 71/100 落ちる | 100/100 落ちる |
| v3 ” も囲み、"" に重ねる | 2/2 緑 | 0/100(すり抜け) | 0/100(すり抜け) | 100/100 落ちる |
| v4 改行も囲む | 2/2 緑 | 0/100 | 0/100 | 0/100 |
数字は seed 1〜100(各 100 件)のうち、落ちた seed の数です。例示テストの列は、4 つの版すべてで 2/2 のまま一度も動いていません。なお、制御文字まで出す unit: 'binary-ascii' でも v3 は 100/100 で落ちました。ただ日本語は出ないので、今回は自作の生成器を正にしています。
反例は seed と path で再現し、例示テストに昇格させる
落ちたときの出力には seed と path が出ます。この 2 つと endOnFailure: true を fc.assert の第 2 引数に渡すと、縮小済みの同じ反例が 1 件目で再現しました。縮める前の最初の失敗は、3 行・12 個の値に改行や " や漢字が混ざった表で、これが 9 回の縮小で [["\n",""],[""]] になっています。12 個の値を目で追うより、2 行に縮んだ形のほうが原因は一目で分かります。
ここは好みが分かれるところですが、私は CI では seed を固定していません。固定すると毎回同じ 100 件しか試さないからです。そのかわり、落ちたら出力の seed と path をそのままチケットに貼り、見つかった反例は例示テストに書き写すことにしました。最終的な版と、昇格させた例示テストがこちらです。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parse } from 'csv-parse/sync';
// 囲む条件は「空欄・カンマ・"・改行」。中の " は "" に重ねる
const quote = (v) => `"${v.replaceAll('"', '""')}"`;
const needsQuote = (v) => v === '' || /[,"\r\n]/.test(v);
export const toCsv = (rows) =>
rows.map((cells) => cells.map((v) => (needsQuote(v) ? quote(v) : v)).join(',')).join('\n');
// 改善の余地あり: 行の区切りは \n のままで、RFC 4180 の CRLF に合わせていない。
// Excel で開く前提なら、CRLF と BOM の有無は別の受け入れ基準として決める必要がある
// fast-check が縮めた反例を、そのまま例示テストに昇格させた
const found = [[['']], [['"']], [['\n', ''], ['']], [['\r\n', '']]];
for (const rows of found) {
test(`往復: ${JSON.stringify(rows)}`, () => {
assert.deepEqual(parse(toCsv(rows), { relax_column_count: true }), rows);
});
}
// 実行結果: node --test orders-csv-v4.test.mjs
// # tests 4
// # pass 4
// # fail 0
最終版で性質 1 本を 100 件回すのにかかったのは、5 回測って 4.7〜14.6 ミリ秒でした(いちばん遅いのは初回)。seed 100 個ぶん、1 万件回しても 1 秒かかりません。CI の時間を理由に見送る必要はなさそうです。
受け入れ基準に書き直す
テストは目的ではなく手段です。今回いちばん効いたのは、PBT そのものより「往復が成り立つこと」を受け入れ基準として言葉にできたことでした。案件の受け入れ基準には、CSV 出力についてこう書き足しています。
| 観点 | 受け入れ基準 | 確かめ方 |
|---|---|---|
| 往復 | 日本語・半角カナ・空白・カンマ・“・LF・CRLF・空欄を含む表を書いて読み戻すと、行数・列数・値がすべて一致する | fast-check で 100 件。生成器は自作のものを使う |
| 空欄 | 1 列だけの行の空欄も、読み戻すと 1 行として残る | 例示テスト(反例 [[""]] を昇格したもの) |
| 反例 | 一度見つかった反例は、例示テストとして残す | node:test の一覧に載っていること |
| 再現 | 落ちたら seed と path を記録し、同じ反例が 1 件目で出ることを確かめる | fc.assert の出力をチケットに貼る |
| Excel | 行の区切り(CRLF)と BOM の要否は別に決め、実際に開いて確かめる | 手作業。往復の性質の対象外 |
最後の行は、PBT では守れない所をわざと書いています。往復の性質が保証するのは「csv-parse と話が合う」ことまでで、Excel がどう読むかは別の話です。
振り返って
今思えば、検収で戻ってきた改行のバグは、例示テストを 1 本足せば済む話でした。ただ、それだと「次に想像の外から来る入力」には、また検収まで気づけません。PBT にして分かったのは、性質を書くこと自体より、生成器が何を出していて何を出していないかを確かめるほうが大事だということです。既定の生成器のままだったら、私はたぶん v3 で「100 回回して緑」と報告していました。
次は、同じやり方を金額の按分や日付の範囲の重なり判定にも広げたいと思っています。どちらも「足すと元の合計に戻る」「入れ替えても結果が同じ」のような性質が書けるはずなので、まず生成器の中身を数えるところから始めるつもりです。