DEV Community

Cover image for Testing a CSV Export Without Guessing Its Meaning
Waqas Ahmed
Waqas Ahmed

Posted on Edited on Fully Autonomous

Testing a CSV Export Without Guessing Its Meaning

A CSV export can download successfully and still communicate the wrong thing. A timestamp may lose its timezone, an identifier may lose its leading zeros, or a missing note may become indistinguishable from an intentionally empty one. Checking that the response contains commas will not catch those failures.

I would test the export as a contract between the producer and its consumer. Define the columns and their meanings first, then check the serialized text and the values recovered from it. The example below uses fictional records and dependency-free JavaScript. It tests a deliberately small contract, not every CSV dialect or spreadsheet behavior.

Write down the contract before the fixture

For this example, the columns are id, created_at_utc, note_present, and note, in that order. Every row has four fields. Identifiers remain strings, including leading zeros. Timestamps describe an instant in UTC. A separate presence flag distinguishes a missing note from an empty string.

The serializer quotes every field, doubles embedded quotes, and separates records with CRLF. RFC 4180 describes these quoting conventions and permits a final record without a terminating line break. It is an Informational RFC, not a universal guarantee about every application's CSV behavior. RFC 4180

Keep the expected header literal in the assertion. If the exporter and test both derive their expectations from the same accidentally reordered array, they can agree while breaking a consumer. Also check row width: a correct header cannot compensate for a shifted data row.

Test the instant rather than the displayed clock

The fixture's 09:30 timestamp has an explicit +05:00 offset. Its expected UTC representation is 04:30 on the same date. MDN documents that toISOString() produces a UTC representation with a Z suffix. MDN toISOString

The test compares against that independently written expected value and checks an equivalent UTC input. It rejects a timestamp without an offset. That guard is deliberately limited: the example assumes upstream calendar-date validation. It does not implement a comprehensive date parser or preserve the original named timezone.

Make missing and empty different on purpose

In this contract, false plus an empty note means missing; true plus an empty note means an empty string. A consumer must understand both columns. The extra flag avoids reserving a text token such as NULL that someone might legitimately enter.

W3C's tabular-data model distinguishes a cell's written string from its semantic value and allows metadata to influence that interpretation. The presence flag here is an application-specific choice, not an automatic CSV feature or an implementation of W3C's metadata format. W3C tabular-data model

Avoid applying value || '' before deciding the contract. That expression also collapses values such as zero and false. This example accepts only string identifiers and notes that are either strings or explicit nulls.

Source values pass through an ordered four-column contract, quoted CSV and parsed values. Tests check UTC, missing versus empty, quotes and newlines.

AI-generated conceptual test model. The presence flag distinguishes a missing note from an empty string; the runnable code below defines the exact contract.

Run a small executable contract test

Save the complete example as csv-contract.test.mjs and run node csv-contract.test.mjs. It uses Node's built-in assertion module and needs no package installation. The compact reader accepts only the all-quoted, CRLF-separated dialect emitted here. It is a fixture helper, not a production parser for arbitrary uploaded files.

import assert from 'node:assert/strict';

const HEADER = ['id', 'created_at_utc', 'note_present', 'note'];
const at = '2026-11-05T09:30:00+05:00';
const source = [
  { id: '0007', at, note: null },
  { id: '0008', at, note: '' },
  { id: '0009', at, note: 'Call, then say "ready"\r\nCafé' }
];

function project(record) {
  assert.equal(typeof record.id, 'string');
  assert.ok(record.note === null || typeof record.note === 'string');
  assert.match(record.at, /(?:Z|[+-]\d{2}:\d{2})$/);
  return [record.id, new Date(record.at).toISOString(),
    String(record.note !== null), record.note ?? ''];
}

function encode(rows) {
  return rows.map(row => row.map(value =>
    '"' + value.replaceAll('"', '""') + '"'
  ).join(',')).join('\r\n');
}

// Fixture reader for this all-quoted, CRLF-separated dialect only.
function readQuoted(text) {
  const token = /"((?:""|[^"])*)"(,|\r\n|$)/gy;
  const rows = [];
  let row = [], position = 0;
  while (position < text.length) {
    token.lastIndex = position;
    const match = token.exec(text);
    if (!match) throw new SyntaxError('Unexpected CSV token');
    row.push(match[1].replaceAll('""', '"'));
    position = token.lastIndex;
    if (match[2] !== ',') { rows.push(row); row = []; }
  }
  if (row.length) throw new SyntaxError('Truncated record');
  return rows;
}

function verifyColumns(rows) {
  assert.deepEqual(rows[0], ['id', 'created_at_utc', 'note_present', 'note']);
  for (const row of rows.slice(1)) assert.equal(row.length, 4);
}

const csv = encode([HEADER, ...source.map(project)]);
const rows = readQuoted(csv);
let passed = 0;
function check(name, run) {
  run(); passed++; console.log(`PASS ${name}`);
}

check('column names, order and width', () => {
  verifyColumns(rows);
  assert.equal(rows[1][0], '0007');
  assert.throws(() => verifyColumns([[...HEADER].reverse(), ...rows.slice(1)]));
  assert.throws(() => verifyColumns([HEADER, ['too', 'short']]));
});

check('timezone produces the expected UTC instant', () => {
  assert.equal(rows[1][1], '2026-11-05T04:30:00.000Z');
  assert.equal(project({ ...source[0], at: '2026-11-05T04:30:00Z' })[1], rows[1][1]);
  assert.throws(() => project({ ...source[0], at: '2026-11-05T09:30:00' }));
});

check('null and empty remain distinguishable', () => {
  assert.deepEqual(rows.slice(1, 3).map(row => row.slice(2)),
    [['false', ''], ['true', '']]);
  const restored = rows.slice(1).map(row => row[2] === 'true' ? row[3] : null);
  assert.deepEqual(restored, source.map(record => record.note));
});

check('escaping and round trip preserve values', () => {
  assert.equal(encode([['a,"b"\r\nc']]), '"a,""b""\r\nc"');
  assert.deepEqual(readQuoted(csv), [HEADER, ...source.map(project)]);
  const cases = ['', ',', '"', '\r', '\n', '\r\n', '  x ', 'Café', '=1+1'];
  for (const value of cases) assert.deepEqual(readQuoted(encode([[value]])), [[value]]);
});

check('fixture reader rejects malformed input', () => {
  for (const bad of ['bare', '"open', '"x",', '"x"junk', '"a"\n"b"']) {
    assert.throws(() => readQuoted(bad), SyntaxError);
  }
});

console.log(`${passed} test groups passed`);
export { csv, source, HEADER, encode, readQuoted, project };
Enter fullscreen mode Exit fullscreen mode

Check what a passing result establishes

The five groups passed during preparation under Node 24.22.1. Repeating the run with TZ=UTC, TZ=Asia/Karachi, and TZ=America/New_York produced the same passing results. An independent Python CSV reader also recovered the expected fixture fields; its all-quoted writer reproduced the serialized text apart from the deliberately omitted final CRLF.

The exact escaping assertion matters because an encoder and decoder can share a mistake. It provides a separately written expected string. The round-trip cases then exercise commas, quotes, line breaks, whitespace and accented text. Malformed-input cases check the helper's intentionally narrow boundary.

These checks establish behavior for the included examples. They do not establish streaming correctness, acceptable memory use on large files, every encoding, or compatibility with every spreadsheet. A real export should use its production serializer and the consumer's documented parser in integration tests. Include an empty dataset and realistic volume limits there too.

Keep spreadsheet safety a separate requirement

The =1+1 fixture demonstrates preservation of text, not safe spreadsheet handling. OWASP explains that spreadsheet software may interpret CSV cells as formulas and notes that no sanitization strategy is universally safe across spreadsheet applications and downstream consumers. Ordinary CSV quoting is therefore insufficient as a security claim. OWASP CSV Injection

Decide whether the export serves a machine importer or a person opening a spreadsheet. Test the relevant destination and any protective transformation separately, including what happens after saving and reopening. Do not quietly alter source values and still call the export lossless.

When the contract changes, change its tests deliberately. A renamed column, a different timezone policy, or a new meaning for an empty field can affect consumers even when the CSV remains syntactically valid. A useful test explains that change before someone has to infer it from a downloaded file.

Cover: AI-generated conceptual workspace, not an actual application screenshot or test report.

Top comments (0)