Skip to main content

Turn CSV Acceptance Criteria into Executable Tests

Updated 4 min readMarkdown

By Scopefolio Support. Prepared with AI assistance.

“Export these records as CSV” leaves several decisions unresolved. Does an empty export contain a header? Can a note contain a comma? What happens when a record has fewer fields than the header? A screenshot of a successful download answers none of those questions.

For a small client project, a useful next step is to turn each decision into an input and an exact expected result. The exercise below builds a bounded, in-memory exporter. It is an independent teaching example, with fictional data.

Write the contract before the function

This export has five acceptance criteria:

  1. Always include the supplied header, even when there are no data rows
  2. End every record with carriage return plus line feed, written as \r\n
  3. Quote fields containing a comma, double quote, carriage return, or line feed; double every embedded quote
  4. Preserve empty strings and spaces; reject missing cells and non-string values
  5. Reject oversized input instead of silently truncating it

The quoting and record-separator choices follow the conventions described in RFC 4180. That document is informational, and CSV consumers vary. These criteria describe this exporter’s explicit contract rather than every application that opens CSV.

Notice that these rules apply to headers too. A header containing a comma needs the same escaping as a data field. Keeping one encoding path prevents the two from drifting apart.

Run the implementation and its examples

Save this complete file as csv.test.mjs, then run node csv.test.mjs. It uses Node’s built-in strict assertions, so no package installation is needed.

import assert from 'node:assert/strict';

export function toCsv(headers, rows) {
  if (!Array.isArray(headers) || headers.length < 1 || headers.length > 20)
    throw new TypeError('Expected 1–20 headers');
  if (!Array.isArray(rows) || rows.length > 1000)
    throw new TypeError('Expected at most 1000 rows');
  let result = '';
  for (const row of [headers, ...rows]) {
    if (!Array.isArray(row) || row.length !== headers.length)
      throw new TypeError('Wrong column count');
    const cells = Array.from(row, value => {
      if (typeof value !== 'string' || value.length > 10000)
        throw new TypeError('Expected a short string');
      return /[",\r\n]/.test(value)
        ? `"${value.replaceAll('"', '""')}"` : value;
    });
    result += cells.join(',') + '\r\n';
    if (result.length > 1000000) throw new RangeError('Export too large');
  }
  return result;
}

assert.equal(toCsv(['name', 'note'], []), 'name,note\r\n');
assert.equal(toCsv(['name', 'note'], [['Mina', 'ready']]),
  'name,note\r\nMina,ready\r\n');
for (const [input, encoded] of [
  ['a,b', '"a,b"'], ['a"b', '"a""b"'],
  ['a\nb', '"a\nb"'], ['a\rb', '"a\rb"'],
  ['a\r\nb', '"a\r\nb"'], ['', ''], [' x ', ' x '],
]) assert.equal(toCsv(['value'], [[input]]), `value\r\n${encoded}\r\n`);
assert.equal(toCsv(['a,b'], [['ok']]), '"a,b"\r\nok\r\n');
for (const rows of [[['too', 'wide']], [[null]], [Array(1)]])
  assert.throws(() => toCsv(['value'], rows), TypeError);
assert.throws(() => toCsv([], []), TypeError);
assert.throws(() => toCsv(['v'], Array(1001).fill(['x'])), TypeError);
assert.throws(() => toCsv(['v'], [['x'.repeat(10001)]]), TypeError);
assert.throws(() => toCsv(['v'], Array(101).fill(['x'.repeat(10000)])), RangeError);
assert.equal(toCsv(['value'], [['=1+1']]), 'value\r\n=1+1\r\n');
console.log('CSV acceptance checks passed');

The file was run with Node.js v24.19.0 and printed CSV acceptance checks passed. That result covers these fixtures; it does not establish compatibility with every spreadsheet or downstream importer.

Make failures explain the requirement

The expected strings are written independently of the encoder. Constructing expected values by calling the same escaping helper would allow a shared mistake to pass. The quote fixture distinguishes doubling an embedded quote from merely wrapping the cell. Separate carriage-return and line-feed fixtures catch a common incomplete check that only looks for \n.

An export containing the right number of lines can still corrupt a quoted newline, so compare complete records rather than splitting on newlines.

The sparse-array case is deliberate. JavaScript arrays can have a length without a value at each position. Array.from visits those positions and passes undefined to validation, preventing an accidental empty-field conversion.

The limits are teaching defaults: 20 columns, 1,000 data rows, 10,000 UTF-16 code units per cell, and 1,000,000 code units in the output. They are not UTF-8 byte limits or measured capacity recommendations. This function holds the output in memory; a large production export needs a streaming design and separately tested backpressure and cancellation behavior.

Keep format correctness separate from product decisions

The last assertion preserves =1+1 exactly. That is important evidence about the contract: CSV quoting alone does not prevent spreadsheet formula interpretation. If users will open untrusted values in a spreadsheet, agree on a separate safety policy and test it against the actual consumer. OWASP’s CSV injection guidance describes this risk and cautions that mitigations vary across spreadsheet workflows.

Also decide how your application selects records, checks export permissions, formats dates, encodes the returned string, and names the download. Those belong in additional tests at their own boundaries. This example deliberately accepts already formatted strings and performs no file or network writes.

Acceptance criteria become useful when a reader can point to a failing example and understand which promise was broken. Start with that mapping, then expand the test set whenever a real input exposes another decision.

2 views

No comments yet. Be the first to comment.