QA How-To
Test CSV Export Locale Formatting (2026)
Learn to test CSV export locale formatting for dates, decimals, currency, delimiters, Unicode, BOM, quoting, and spreadsheet-safe data with Node.js LTS.
22 min read | 3,306 words
TL;DR
Pin locale, time zone, currency, delimiter, and runtime. Assert parsed cells for localized dates and numbers, then test CSV quoting, UTF-8 BOM bytes, CRLF endings, formula-like text, and disk round trips separately.
Key Takeaways
- Treat locale, time zone, currency, and delimiter as separate contract inputs.
- Parse exported CSV before asserting logical cells, then inspect raw bytes for transport rules.
- Pin the Node.js runtime when exact Intl punctuation and spacing are user-visible requirements.
- Test no-break spaces, quoting, multiline text, CRLF records, and UTF-8 BOM bytes explicitly.
- Protect formula-like user text without converting trusted numeric output into strings.
- Run locale contracts in CI and review ICU-driven differences during runtime upgrades.
CSV files look plain, but locale rules can change every field a spreadsheet user sees. To test CSV export locale formatting, control the locale, time zone, currency, delimiter, encoding, quoting, and line endings, then compare parsed cells instead of scanning raw text. This tutorial builds a dependency-free Node.js suite that proves those contracts for US English, German, French, and Japanese exports.
The central testing decision is to separate semantic values from presentation. An order instant stays 2026-07-15T12:05:00.000Z; the exporter renders it differently for New York, Berlin, Paris, and Tokyo. An amount stays 1234.5; Intl.NumberFormat supplies the approved grouping, decimal, spacing, and currency layout.
You will also verify the details that produce real customer defects: commas inside fields, doubled quotes, multiline names, UTF-8 BOM bytes, CRLF record endings, formula-like user input, unsupported locales, and file round trips. For wider market coverage, pair this focused export suite with the localization testing basics guide.
What You Will Build
You will create a small export module, four focused suites, and one parser helper. By the end, the project will:
- Generate deterministic CSV for
en-US,de-DE,fr-FR, andja-JPprofiles. - Format one UTC instant in four named IANA time zones.
- Assert currency and percent strings, including U+00A0 and U+202F spaces.
- Parse quoted commas, semicolons, doubled quotes, and embedded newlines.
- Reject invalid amounts, dates, and locale profile names.
- Confirm the UTF-8 BOM and complete byte-level disk round trip.
The code uses only stable Node.js APIs: Intl.NumberFormat, Intl.DateTimeFormat, node:test, node:assert/strict, and node:fs/promises. The same test logic can sit behind a browser download test later, after the UI has produced the file.
Prerequisites
Use Node.js 24.18.0 LTS and npm 11.16.0, the npm version bundled with that Node release. Pinning the runtime matters because Node ships ICU locale data, and a future ICU update can legitimately alter punctuation or spacing. The Node.js 24.18.0 release notes provide the runtime reference.
You need a terminal and a text editor. No global test package, CSV library, database, or browser is required. Confirm the two versions before creating files:
node --version
npm --version
Expected output:
v24.18.0
11.16.0
If your product already uses a different supported Node line, keep that version and generate approved fixtures on it. Do not copy exact locale strings from this tutorial while running another ICU build, because that turns runtime drift into unexplained test noise.
Step 1: Define the Test CSV Export Locale Formatting Contract
Start with an explicit contract. A locale tag alone does not decide a user's time zone, currency, or spreadsheet list separator. Treat those as independent inputs owned by the product requirement.
This tutorial uses a profile for each export choice:
| Profile | Delimiter | Time zone | Decimal example | Date and time example |
|---|---|---|---|---|
en-US |
comma | America/New_York |
1,234.50 |
07/15/2026, 08:05 |
de-DE |
semicolon | Europe/Berlin |
1.234,50 |
15.07.2026, 14:05 |
fr-FR |
semicolon | Europe/Paris |
1 234,50 with U+202F |
15/07/2026 14:05 |
ja-JP |
comma | Asia/Tokyo |
1,234.50 |
2026/07/15 21:05 |
The delimiter is a product policy, not a universal truth about a country. Desktop spreadsheet settings may use an operating-system list separator, while an API may always promise commas. This sample chooses semicolons for decimal-comma profiles so common amounts need fewer quotes.
Write the oracle in four layers. The semantic layer defines 1234.5, 0.075, and one UTC instant. The presentation layer defines currency, percent, and date text. The serialization layer covers delimiter, quoting, and record endings. The transport layer covers filename, MIME type, encoding, and download delivery. A single golden file can detect change, but these layers tell the team which contract moved.
Decide whether users can choose a locale independently from their account region. If they can, add cross-combinations such as German language with New York time and USD currency. Also state the behavior for missing values: blank cell, zero, literal null, or rejected export. This tutorial rejects invalid typed values and never silently converts them to empty text.
Create the directories and package.json:
mkdir -p src test .github/workflows
npm init -y
Replace the generated package.json with:
{
"name": "csv-locale-contract-tests",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": "24.18.0"
},
"scripts": {
"test": "node --test test/csv-structure.test.js test/csv-locales.test.js test/csv-boundaries.test.js test/csv-roundtrip.test.js"
}
}
The type field enables ECMAScript module imports. The test script names the four suites explicitly, so Node does not count the parser helper as a test file.
Verify Step 1
Run a capability check before writing exporter code:
node --input-type=module -e "console.log(Intl.DateTimeFormat.supportedLocalesOf(['en-US','de-DE','fr-FR','ja-JP']))"
Expect an array containing all four locale tags. A missing tag means the runtime lacks required ICU data, so fix the environment instead of weakening assertions.
Step 2: Implement a Deterministic Locale-Aware Exporter
Create src/csv-export.js. The exporter receives raw values, selects a known profile, formats fields with explicit options, escapes cells, adds a UTF-8 BOM, and ends every record with CRLF.
const HEADERS = ['Order ID', 'Customer', 'Ordered at', 'Subtotal', 'Tax rate'];
export const PROFILES = Object.freeze({
'en-US': { locale: 'en-US', delimiter: ',', timeZone: 'America/New_York' },
'de-DE': { locale: 'de-DE', delimiter: ';', timeZone: 'Europe/Berlin' },
'fr-FR': { locale: 'fr-FR', delimiter: ';', timeZone: 'Europe/Paris' },
'ja-JP': { locale: 'ja-JP', delimiter: ',', timeZone: 'Asia/Tokyo' },
});
function requireFiniteNumber(value, field) {
if (!Number.isFinite(value)) {
throw new TypeError(`${field} must be a finite number`);
}
}
function formatMoney(value, currency, locale) {
requireFiniteNumber(value, 'subtotal');
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
currencyDisplay: 'code',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
function formatPercent(value, locale) {
requireFiniteNumber(value, 'taxRate');
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
function formatInstant(isoInstant, locale, timeZone) {
const date = new Date(isoInstant);
if (Number.isNaN(date.getTime())) {
throw new TypeError('orderedAt must be a valid ISO instant');
}
return new Intl.DateTimeFormat(locale, {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(date);
}
export function escapeCsv(value, delimiter, { protectFormula = false } = {}) {
const raw = String(value ?? '').replace(/\r\n?/gu, '\n');
const formulaLike = /^[\u0000-\u0020]*[=+\-@]/u.test(raw);
const safe = protectFormula && formulaLike ? `'${raw}` : raw;
const escaped = safe.replaceAll('"', '""');
const needsQuotes = safe.includes(delimiter) || /["\r\n]/u.test(safe);
return needsQuotes ? `"${escaped}"` : escaped;
}
export function exportOrders(orders, profileName) {
const profile = PROFILES[profileName];
if (!profile) {
throw new RangeError(`Unsupported export profile: ${profileName}`);
}
const lines = [HEADERS.map((header) => escapeCsv(header, profile.delimiter)).join(profile.delimiter)];
for (const order of orders) {
const cells = [
escapeCsv(order.id, profile.delimiter),
escapeCsv(order.customer, profile.delimiter, { protectFormula: true }),
escapeCsv(formatInstant(order.orderedAt, profile.locale, profile.timeZone), profile.delimiter),
escapeCsv(formatMoney(order.subtotal, order.currency, profile.locale), profile.delimiter),
escapeCsv(formatPercent(order.taxRate, profile.locale), profile.delimiter),
];
lines.push(cells.join(profile.delimiter));
}
return `\uFEFF${lines.join('\r\n')}\r\n`;
}
The formatter names every date component and uses hourCycle: 'h23', so the test does not inherit a machine's 12-hour preference. currencyDisplay: 'code' avoids an ambiguous $ when the same symbol can represent several currencies. MDN documents the locale-sensitive behavior of Intl.
Only the customer field receives formula protection. Applying that rule to formatted numeric cells could turn a legitimate negative amount into text. The escape function also normalizes embedded record-style newlines to LF inside a quoted field while preserving CRLF between records.
Order the transformations carefully: validate the raw type, localize the semantic value, protect untrusted text, escape embedded quotes, and finally join cells with the selected delimiter. Escaping before formatting can miss a delimiter introduced by Intl; formatting after escaping can invalidate quotes. Keeping one escapeCsv call at the final cell boundary makes this sequence reviewable.
The two fraction digits are a business rule in this sample, not a universal currency rule. JPY often displays zero fraction digits, while some domain values need three or more. If your exporter follows each currency's default minor units, remove the explicit digit options and add fixtures for currencies with different rules.
Verify Step 2
Import the actual module and print an empty export:
node --input-type=module -e "import { exportOrders } from './src/csv-export.js'; console.log(JSON.stringify(exportOrders([], 'en-US')))"
Expect \uFEFFOrder ID,Customer,Ordered at,Subtotal,Tax rate\r\n inside the JSON-escaped output. The visible \uFEFF proves the string begins with a BOM character.
Step 3: Add a Small CSV Reader for Assertions
Raw substring checks are useful for bytes and line endings, but they are poor oracles for cells. A comma may be a delimiter, a decimal separator, or quoted content. Create test/csv-reader.js so the suite can compare logical rows after applying CSV quoting rules.
export function parseCsv(csv, delimiter) {
const source = csv.charCodeAt(0) === 0xfeff ? csv.slice(1) : csv;
const rows = [];
let row = [];
let field = '';
let quoted = false;
const finishField = () => {
row.push(field);
field = '';
};
const finishRow = () => {
finishField();
rows.push(row);
row = [];
};
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
if (quoted) {
if (char === '"' && source[index + 1] === '"') {
field += '"';
index += 1;
} else if (char === '"') {
quoted = false;
} else {
field += char;
}
continue;
}
if (char === '"') {
if (field.length > 0) throw new Error('Unexpected quote in unquoted field');
quoted = true;
} else if (char === delimiter) {
finishField();
} else if (char === '\r' && source[index + 1] === '\n') {
finishRow();
index += 1;
} else if (char === '\n') {
finishRow();
} else {
field += char;
}
}
if (quoted) throw new Error('Unclosed quoted field');
if (field.length > 0 || row.length > 0) finishRow();
return rows;
}
This reader is intentionally narrow. It handles the RFC-style features the exporter emits, but it is not positioned as a general package for arbitrary vendor files. Keeping the test reader separate from production avoids validating an algorithm with the same algorithm.
Verify Step 3
Exercise a doubled quote and embedded comma directly:
node --input-type=module -e "import { parseCsv } from './test/csv-reader.js'; console.log(parseCsv('A,B\r\n1,\"Mia, \"\"QA\"\"\"\r\n', ','))"
The second data cell should print as Mia, "QA". If it becomes two cells, the reader is not respecting quoted delimiters.
Step 4: Test CSV Structure, Quoting, and Record Boundaries
Create test/csv-structure.test.js. These checks focus on file grammar, not regional formatting, so a date punctuation change cannot hide a broken quote.
import assert from 'node:assert/strict';
import test from 'node:test';
import { exportOrders } from '../src/csv-export.js';
import { parseCsv } from './csv-reader.js';
const orders = [
{
id: 'A-100',
customer: 'Mia, "QA"\r\nTeam',
orderedAt: '2026-07-15T12:05:00.000Z',
subtotal: 1234.5,
currency: 'USD',
taxRate: 0.075,
},
{
id: 'A-101',
customer: '=WEBSERVICE("https://invalid.example")',
orderedAt: '2026-07-15T12:05:00.000Z',
subtotal: -12.5,
currency: 'USD',
taxRate: 0,
},
];
test('writes a BOM, CRLF records, and one terminal record ending', () => {
const csv = exportOrders(orders, 'en-US');
assert.equal(csv.charCodeAt(0), 0xfeff);
assert.equal(csv.endsWith('\r\n'), true);
assert.equal(csv.match(/\r\n/gu)?.length, 3);
});
test('quotes delimiters and restores doubled quotes and multiline text', () => {
const csv = exportOrders(orders, 'en-US');
const rows = parseCsv(csv, ',');
assert.deepEqual(rows[0], ['Order ID', 'Customer', 'Ordered at', 'Subtotal', 'Tax rate']);
assert.equal(rows.length, 3);
assert.equal(rows[1][1], 'Mia, "QA"\nTeam');
assert.equal(csv.includes('""QA""'), true);
});
test('protects formula-like customer text without changing a negative amount', () => {
const rows = parseCsv(exportOrders(orders, 'en-US'), ',');
assert.equal(rows[2][1], '\'=WEBSERVICE("https://invalid.example")');
assert.equal(rows[2][3], '-USD\u00a012.50');
});
The first record count is three because the file contains a header and two orders. The customer line break is an LF inside a quoted cell, so it must not be counted as a CRLF record boundary. The last test protects user-controlled text but still permits the formatter's legitimate negative currency representation.
Avoid asserting a count from csv.split('\r\n') when multiline cells are allowed. A record-aware parser is the correct boundary detector. Conversely, do not rely only on parsed rows because a tolerant parser may accept LF records even when the producer contract promises CRLF. The paired assertions cover strict output and usable content without confusing the two.
Verify Step 4
Run only this file while debugging its grammar:
node --test test/csv-structure.test.js
Expect three passing subtests. A failure should identify structure, quoting, or formula handling without mixing in the wider locale matrix.
Step 5: Run the Test CSV Export Locale Formatting Matrix
Now pin the exact strings produced by Node.js 24.18.0. Create test/csv-locales.test.js and represent invisible spaces with Unicode escapes. U+00A0 is a no-break space; U+202F is a narrow no-break space. Replacing either with ordinary U+0020 can change how a spreadsheet wraps or compares the cell.
import assert from 'node:assert/strict';
import test from 'node:test';
import { exportOrders, PROFILES } from '../src/csv-export.js';
import { parseCsv } from './csv-reader.js';
const order = {
id: 'A-100',
customer: 'Ada',
orderedAt: '2026-07-15T12:05:00.000Z',
subtotal: 1234.5,
currency: 'USD',
taxRate: 0.075,
};
const cases = [
{
profile: 'en-US',
delimiter: ',',
orderedAt: '07/15/2026, 08:05',
subtotal: 'USD\u00a01,234.50',
taxRate: '7.50%',
},
{
profile: 'de-DE',
delimiter: ';',
orderedAt: '15.07.2026, 14:05',
subtotal: '1.234,50\u00a0USD',
taxRate: '7,50\u00a0%',
},
{
profile: 'fr-FR',
delimiter: ';',
orderedAt: '15/07/2026 14:05',
subtotal: '1\u202f234,50\u00a0USD',
taxRate: '7,50\u00a0%',
},
{
profile: 'ja-JP',
delimiter: ',',
orderedAt: '2026/07/15 21:05',
subtotal: 'USD\u00a01,234.50',
taxRate: '7.50%',
},
];
for (const expected of cases) {
test(`${expected.profile} renders approved date, money, and percent cells`, () => {
const csv = exportOrders([order], expected.profile);
const rows = parseCsv(csv, expected.delimiter);
assert.deepEqual(rows[1], [
order.id,
order.customer,
expected.orderedAt,
expected.subtotal,
expected.taxRate,
]);
assert.equal(PROFILES[expected.profile].delimiter, expected.delimiter);
});
}
The time assertions prove that the exporter uses the named zone instead of the CI host's zone. At the chosen July instant, New York is UTC-4, Berlin and Paris are UTC+2, and Tokyo is UTC+9. This test uses one currency code across all locales because locale controls presentation, while the order controls which currency the amount represents.
Exact display tests are appropriate when the file is a user-visible contract. They should fail after an ICU upgrade until the team inspects and accepts the new output. If your requirement allows several equivalent layouts, assert formatToParts() categories or business invariants instead of one literal string.
Percent input deserves an explicit semantic decision. Intl.NumberFormat treats 0.075 as 7.50%; an upstream API that already sends 7.5 would produce 750.00%. Add a contract test at the API or mapping boundary so the export suite receives ratios consistently. Currency needs the same separation: the locale chooses layout, but the row's currency property chooses USD, EUR, or another ISO code.
Use a small, risk-based locale set for every pull request and rotate a larger matrix nightly if the product supports many markets. Choose representatives by decimal symbol, grouping mark, script, calendar, direction, and time-zone behavior. Language count alone misses the technical differences that usually break CSV output.
Verify Step 5
Execute the four-row matrix:
node --test test/csv-locales.test.js
Expect four passes. If only French fails, inspect code points rather than retyping the space you see in a terminal.
Step 6: Cover Unicode, Formula Injection, and Invalid Values
Locale tests need hostile and boundary data too. Create test/csv-boundaries.test.js to exercise text that begins like a spreadsheet formula, multilingual graphemes, missing profiles, non-finite numbers, and invalid instants.
import assert from 'node:assert/strict';
import test from 'node:test';
import { exportOrders } from '../src/csv-export.js';
import { parseCsv } from './csv-reader.js';
const baseOrder = {
id: 'A-200',
customer: 'Zoë 東京 👩🏽💻',
orderedAt: '2026-07-15T12:05:00.000Z',
subtotal: 0,
currency: 'EUR',
taxRate: 1,
};
test('preserves multilingual customer text and percent boundaries', () => {
const rows = parseCsv(exportOrders([baseOrder], 'fr-FR'), ';');
assert.equal(rows[1][1], baseOrder.customer);
assert.equal(rows[1][3], '0,00\u00a0EUR');
assert.equal(rows[1][4], '100,00\u00a0%');
});
for (const customer of ['=1+1', '+cmd', '-2+3', '@SUM(A1:A2)', '\t=1+1']) {
test(`prefixes dangerous customer cell ${JSON.stringify(customer)}`, () => {
const rows = parseCsv(exportOrders([{ ...baseOrder, customer }], 'en-US'), ',');
assert.equal(rows[1][1], `'${customer}`);
});
}
test('does not modify harmless leading text', () => {
const customer = ' safe customer';
const rows = parseCsv(exportOrders([{ ...baseOrder, customer }], 'en-US'), ',');
assert.equal(rows[1][1], customer);
});
test('rejects values that cannot meet the export contract', () => {
assert.throws(() => exportOrders([baseOrder], 'es-MX'), /Unsupported export profile/u);
assert.throws(
() => exportOrders([{ ...baseOrder, subtotal: Number.NaN }], 'en-US'),
/subtotal must be a finite number/u,
);
assert.throws(
() => exportOrders([{ ...baseOrder, orderedAt: '15/07/2026' }], 'en-US'),
/orderedAt must be a valid ISO instant/u,
);
});
Spreadsheet formula injection is a consumer-side risk, not a CSV quoting defect. Quoting =1+1 may still let a spreadsheet evaluate it. This example prefixes an apostrophe for user-controlled customer cells that begin with formula trigger characters, including leading control characters. Align the exact mitigation with the spreadsheet applications your organization supports.
Test the neutralized file in supported consumers because Excel, LibreOffice, Google Sheets, and data pipelines do not interpret every prefix identically. If a CSV also feeds an automated importer, consider offering a machine-oriented export with invariant numbers and ISO timestamps instead of making one file serve incompatible human and system needs. Record that choice in the API contract.
The invalid date uses a regional-looking string deliberately. Production input should carry an ISO instant or another unambiguous machine value, then format it at the export boundary. Use boundary value analysis examples to expand zero, negative, maximum, precision, and date-edge coverage without choosing random cases.
Verify Step 6
Run the boundary suite:
node --test test/csv-boundaries.test.js
Expect eight passes: one Unicode test, five protected inputs, one harmless input, and one grouped rejection test.
Step 7: Verify UTF-8 Bytes and a Disk Round Trip
A JavaScript string can look correct before file creation and still be written with the wrong encoding. Create test/csv-roundtrip.test.js to write actual bytes into a temporary directory, read them back, inspect the BOM, decode UTF-8, and parse every profile.
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { exportOrders, PROFILES } from '../src/csv-export.js';
import { parseCsv } from './csv-reader.js';
const order = {
id: 'A-300',
customer: 'Renée, 東京',
orderedAt: '2026-07-15T12:05:00.000Z',
subtotal: 9876.54,
currency: 'EUR',
taxRate: 0.2,
};
for (const [profileName, profile] of Object.entries(PROFILES)) {
test(`${profileName} survives a UTF-8 file round trip`, async () => {
const directory = await mkdtemp(join(tmpdir(), 'csv-locale-'));
const file = join(directory, `orders-${profileName}.csv`);
try {
await writeFile(file, exportOrders([order], profileName), 'utf8');
const bytes = await readFile(file);
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
const decoded = bytes.toString('utf8');
const rows = parseCsv(decoded, profile.delimiter);
assert.equal(rows.length, 2);
assert.equal(rows[1][0], order.id);
assert.equal(rows[1][1], order.customer);
assert.match(rows[1][3], /EUR/u);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
}
The byte assertion distinguishes a real UTF-8 BOM (EF BB BF) from merely checking a JavaScript code unit. Parsing the decoded file proves the exported Unicode content and field boundaries survive storage. Temporary cleanup runs inside finally, so a failed assertion does not leave artifacts behind.
For a browser-generated Blob, inspect the downloaded bytes rather than the in-memory source string. The browser may receive a server payload, create the file client-side, or pass through a proxy that changes headers. Byte verification belongs at the last boundary the user downloads, while formatter tests stay close to the module that owns locale rules.
Verify Step 7
Run the file-system tests independently:
node --test test/csv-roundtrip.test.js
Expect four passing subtests. A byte failure points to the file-writing boundary; a parsed-cell failure points to encoding, delimiter, or quoting behavior.
Step 8: Run the Contract in Continuous Integration
Locale checks become unreliable when CI silently changes the runtime. Pin Node in .github/workflows/csv-locale.yml and run the complete suite on each pull request.
name: CSV locale contract
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24.18.0
- run: npm test
No install step is needed because this project has no dependencies. In an application with packages, commit the lockfile and use npm ci before npm test. Do not set the process TZ and assume it controls the exporter; the production formatter already passes a named timeZone, which is the stronger contract.
When updating Node, open a dedicated pull request. Review locale diffs as product output changes, especially spaces and date punctuation, instead of approving snapshots blindly. The test case review checklist helps reviewers challenge weak oracles and missing risks.
Verify Step 8
Run the same command that CI will execute:
npm test
The completed suite should report 19 passing tests across four files. Check the GitHub Actions log after pushing to confirm it used Node 24.18.0 rather than the runner default.
Troubleshooting
Problem: French currency appears to match visually, but the assertion fails -> Print the code points with [...value].map((character) => character.codePointAt(0).toString(16)). French grouping uses U+202F and the gap before USD uses U+00A0 in the pinned output. Do not call .replaceAll(/\s/gu, ' ') unless the product contract declares those spaces equivalent.
Problem: Dates differ by several hours in CI -> Confirm formatInstant passes the profile's named IANA zone to Intl.DateTimeFormat. Avoid constructing a date from 2026-07-15 or 07/15/2026; supply a complete ISO instant with Z or an explicit offset. A host-level TZ variable should not be the oracle for a multi-zone export.
Problem: A German amount becomes extra columns -> Parse the file using the profile delimiter and inspect whether the exporter selected semicolon. If your contract requires comma-delimited German CSV, the amount must be quoted because it contains a comma decimal separator. Never split production CSV with line.split(',').
Problem: Excel shows corrupted accented or Japanese characters -> Read the first three bytes and confirm EF BB BF, then verify the entire file was written as UTF-8. Some spreadsheet import paths require the user to select UTF-8 explicitly even when a BOM exists, so test the exact supported application and import workflow.
Problem: Formula protection changes legitimate data -> Apply protection only to user-controlled textual columns. Amounts and percentages should be validated as numbers and formatted by trusted code. Document whether leading apostrophes are expected to remain visible in downstream non-spreadsheet consumers.
Problem: Tests change after a Node upgrade -> Compare the old and new process.versions.icu, review each changed cell with a locale owner, and update expected strings only after approval. A library upgrade is not evidence that either output is automatically correct for the product.
Interview Questions and Answers
Q: Why should CSV locale tests parse cells instead of comparing only the raw file?
A parser distinguishes structural delimiters from punctuation inside quoted values. It lets me assert that an amount, customer name, and date each occupy one logical cell. I still use raw-byte assertions for BOM and record endings, because parsing intentionally hides those transport details.
Q: Which inputs must be fixed for a deterministic localized date assertion?
I fix the source instant, BCP 47 locale, named IANA time zone, calendar-related options, component widths, and hour cycle. I also pin the runtime when exact punctuation is part of the contract. Leaving the host zone or default locale implicit creates environment-dependent results.
Q: How do delimiter and decimal separator interact?
They are separate rules that may use the same character. A comma-delimited file can contain a decimal comma if that field is quoted correctly. Some products choose semicolons for decimal-comma audiences to improve spreadsheet import behavior, but QA should verify the documented export profile rather than infer it from language.
Q: Why is quoting not sufficient protection against spreadsheet formulas?
CSV quotes define a cell boundary; they do not force a spreadsheet to treat the cell as plain text. A quoted value beginning with =, +, -, or @ can still be interpreted by some consumers. I validate typed numeric fields and apply an agreed text-cell mitigation to untrusted content.
Q: What is the value of checking a UTF-8 BOM at byte level?
The JavaScript string contains U+FEFF, but the saved artifact must contain its UTF-8 encoding, EF BB BF. Reading bytes proves the file writer did not change encoding or omit the prefix. A separate decode-and-parse check confirms the rest of the Unicode payload survived.
Q: Should exact locale strings be snapshot tested?
Use exact expectations when punctuation, spacing, and ordering are user-visible requirements. Prefer named fixtures or explicit strings over opaque snapshots so reviewers can see the contract. If the requirement accepts multiple forms, test semantic parts and invariants instead of freezing one ICU representation.
Q: How would you extend this suite to a browser download?
I would trigger the export, wait for the download, save it to a temporary path, and pass its bytes into the same parser and contract assertions. The browser test should cover wiring, filename, headers, and UI state, while the module tests retain the larger data matrix. The Playwright download handling guide shows the browser-side workflow.
Best Practices
- Store semantic numbers and ISO instants in fixtures; localize only at the export boundary.
- Name locale, time zone, currency, and delimiter in each case so failure output is diagnosable.
- Assert cells after parsing, then add targeted raw checks for BOM, CRLF, and escaping.
- Use explicit Unicode escapes for invisible separators and explain each code point in the test.
- Keep formula mitigation column-aware; typed numeric data and untrusted text have different risks.
- Include zero, negatives, precision limits, midnight, daylight-saving transitions, and year boundaries based on product risk.
- Test the actual spreadsheet applications named in support requirements, since import behavior is not identical across consumers.
- Review ICU-driven diffs during runtime upgrades instead of normalizing away meaningful changes.
For a systematic input model, use equivalence partitioning with examples to select representative locale, delimiter, currency, and time-zone classes. Add exploratory cases after the deterministic contract is stable, not as a substitute for it.
Where To Go Next
Connect the module suite to the real export surface. If the application downloads through a browser, reuse the byte and parser assertions after following Playwright download handling. That split keeps a fast locale matrix in Node while one end-to-end test proves the button returns the right artifact.
Expand the test data deliberately. The boundary value analysis guide helps select amount, percentage, timestamp, and length edges. The equivalence partitioning tutorial helps reduce a large market list into defensible representative groups.
Then review the broader experience through localization testing basics, including input, storage, search, UI, emails, and downstream reports. Use the test case review checklist before release to check traceability and expected results. If files feed performance or data-driven tests, the JMeter CSV Data Set Config guide covers a different CSV consumer with its own parsing contract.
Conclusion
Reliable CSV locale coverage needs more than four happy-path strings. Pin every formatting input, parse logical cells, inspect transport bytes, challenge unsafe text, and run the same contract in CI. With those layers, a failing test identifies whether the defect belongs to localization, CSV grammar, encoding, security, or file delivery.
Start with the four-profile suite, then replace its policy choices with your product's approved locales, zones, currencies, delimiters, and spreadsheet applications.
Interview Questions and Answers
What dimensions belong in a CSV locale test matrix?
I include locale, named time zone, currency, delimiter, source value class, quoting condition, encoding, and target spreadsheet. I separate presentation assertions from raw file checks so failures point to a specific layer. I also record the runtime because ICU data can affect exact output.
How do you make localized date exports deterministic?
I use an unambiguous ISO instant, pass a BCP 47 locale and IANA time zone explicitly, define all required components, and pin the runtime for exact-output contracts. I never use the CI host default as the expected time zone.
How would you test decimal commas in a comma-delimited CSV?
I export a representative decimal-comma value and parse the file with a standards-aware reader. The formatted amount must remain one cell, which usually requires quoting when the field contains the delimiter. I also check a negative and a precision boundary.
What CSV details require raw-byte assertions?
Encoding markers and exact line endings are clearest at the byte or raw-string level. I check the UTF-8 BOM, CRLF record endings, terminal newline policy, and sometimes escaped quote sequences, then use parsed assertions for cell meaning.
How do you address spreadsheet formula injection in exports?
I identify untrusted textual columns, test trigger characters including leading controls, and apply the product-approved neutralization policy. Typed numeric values stay numeric and pass through trusted formatters. I verify behavior in each supported spreadsheet application.
What should happen when an ICU upgrade changes expected output?
The test should fail visibly. I compare code points and formatter parts, ask the locale or product owner whether the new representation is acceptable, and update fixtures only with that approval. I do not normalize the difference away automatically.
How would you combine unit and browser tests for CSV exports?
Unit tests cover the full locale and boundary matrix against the formatter module. A smaller browser test triggers the actual download and reuses the same byte and parser assertions for filename, delivery, and content. This gives broad feedback without multiplying slow UI cases.
Frequently Asked Questions
How do I test CSV export locale formatting?
Fix the source values, locale, time zone, currency, delimiter, formatter options, and runtime. Parse the export and assert logical cells, then add byte-level checks for UTF-8 BOM, line endings, and quoting.
Should CSV use a comma or semicolon for European locales?
There is no universal locale-only answer. Some products use semicolons where decimal commas are common, while others promise comma-separated output everywhere. Test the documented product profile and the supported spreadsheet import path.
Why does French CSV contain invisible spaces?
Locale formatters can emit a narrow no-break space for digit grouping and a no-break space before a currency code or percent sign. Assert their Unicode code points when spacing is part of the file contract.
Does quoting a CSV cell prevent formula injection?
No. Quoting preserves the cell boundary, but some spreadsheets can still evaluate formula-like text. Apply an agreed mitigation to untrusted text columns and validate numeric columns as numbers.
How can I test a CSV file for UTF-8 encoding?
Read the saved artifact as bytes, check the optional required BOM bytes `EF BB BF`, decode it as UTF-8, and compare multilingual cells after parsing. This tests both transport encoding and content preservation.
Why do locale tests pass locally but fail in CI?
The machines may use different Node or ICU versions, default locales, or time zones. Pin the runtime and pass locale and named time zone directly to `Intl` formatters instead of inheriting host defaults.
Should localized CSV output use exact string assertions?
Use exact strings when punctuation, ordering, and spacing are approved user-visible requirements. If several renderings are acceptable, assert semantic parts or product invariants and reserve raw checks for CSV structure.