QA How-To
Test ICU Message Pluralization Localization (2026)
Learn to test ICU message pluralization localization with runnable Node.js tests for CLDR categories, exact values, offsets, ordinals, and locale output.
22 min read | 2,552 words
TL;DR
Build a data-driven suite that maps representative values to CLDR plural categories, then assert the final ICU-formatted output for every supported locale. Add dedicated checks for exact-value selectors, offsets, ordinals, decimals, invalid inputs, and catalog syntax.
Key Takeaways
- Derive plural test values from each locale's CLDR categories instead of translating English singular and plural cases.
- Test exact selectors such as =0 separately because they take precedence over named categories such as zero or one.
- Verify rendered strings as well as category selection so grammar and locale-specific number formatting both receive coverage.
- Exercise offset rules with original and adjusted counts because matching and # substitution use different values.
- Keep cardinal and ordinal suites separate since the same number can select different categories in each rule type.
- Parse every catalog entry in CI and fail on missing keys, unsupported variables, or a missing other branch.
- Use production locale identifiers and full ICU data to keep local, CI, and browser results aligned.
To test ICU message pluralization localization, do more than check 1 and 2 in English. Build a locale-specific matrix from CLDR cardinal and ordinal categories, assert the final rendered message, and add separate cases for exact selectors, offsets, decimals, missing variables, and invalid patterns.
ICU MessageFormat moves grammar into the translation, while CLDR decides which category a number selects. That combination solves real localization problems, but it also creates defects that ordinary string assertions miss. A Russian message may pass at 1 and fail at 11. An Arabic translation may never exercise its two branch. A correct noun can still appear next to a number formatted for the wrong locale.
This tutorial builds a paste-ready Node.js test project around intl-messageformat. If you need a broader foundation before automating the examples, read the localization testing basics guide.
TL;DR
Use two layers of checks. First, test Intl.PluralRules with boundary values to prove that your runtime selects the expected CLDR category. Second, format the real ICU messages and compare complete user-visible strings. The second layer catches translation, interpolation, punctuation, and number-formatting defects that category tests cannot see.
| Risk | Smallest useful test | Why it matters |
|---|---|---|
| Exact selector precedence | =0 and the locale's zero category |
=0 matches the original number before a category branch |
| Cardinal grammar | One value per supported category plus boundaries | Languages can use zero, one, two, few, many, and other |
| Offset behavior | Values below, at, and above the offset | Branch selection and # substitution use adjusted values |
| Ordinal grammar | 1, 2, 3, 4, 11, 12, 13, and 21 in English | Ordinal rules differ from cardinal rules |
| Number presentation | A grouped value such as 1234567 | # uses locale-aware number formatting |
| Catalog integrity | Parse every message and compare keys | Broken braces or a missing other branch should stop delivery |
What You Will Build
You will create a compact localization test project that can run locally or in CI. It has no browser dependency, so failures point directly to message behavior rather than page timing or selectors.
By the end, the project will:
- Format the same cart concept in English, French, Russian, and Arabic.
- Assert complete output for exact matches and named plural categories.
- Probe the CLDR categories exposed by the JavaScript runtime.
- Verify ICU
offset:semantics and the special#token. - Test English ordinal endings independently from cardinal forms.
- Reject malformed translations, missing catalog keys, unsupported locales, and nonnumeric counts.
These examples use hand-reviewed expected strings. In a product team, ask a linguist or translator to approve those strings before treating them as the oracle. Automation can prove that the selected output matches the oracle, but it cannot decide whether an unfamiliar sentence is idiomatic.
Prerequisites
Use Node.js 24.19.0 LTS and intl-messageformat 11.2.12. Node 24 includes full ICU data in official binaries, which provides Intl.NumberFormat, Intl.DateTimeFormat, and Intl.PluralRules. The code uses the stable node:test and node:assert/strict modules, so no separate test framework is required.
You need a terminal, a UTF-8 editor, and permission to install one npm dependency. Confirm that the shell is using the intended runtime:
node --version
npm --version
node -p "process.versions.icu"
The first command should print v24.19.0. The ICU command must print a version rather than undefined. If your organization standardizes on another supported Node LTS patch, pin that patch in development and CI, then regenerate only the locale expectations that genuinely differ. Do not silently accept different runtimes because ICU and CLDR data can change between releases.
Step 1: Set Up a Project to Test ICU Message Pluralization Localization
Create an isolated project and pin the formatter. Exact dependency versions make a failed assertion reproducible on a laptop and a build agent.
mkdir icu-plural-tests
cd icu-plural-tests
npm init -y
npm pkg set type=module scripts.test="node --test"
npm pkg set private=true --json
npm install --save-exact intl-messageformat@11.2.12
mkdir src test
After those commands, the important parts of package.json should look like this:
{
"name": "icu-plural-tests",
"private": true,
"type": "module",
"scripts": {
"test": "node --test"
},
"dependencies": {
"intl-messageformat": "11.2.12"
}
}
Setting type to module allows the import statements used below. --save-exact prevents npm from writing a caret range, so a later install cannot pull a new minor formatter release into an unchanged branch. Commit both package.json and package-lock.json in a real test repository.
Verify Step 1: run the following command. It should report intl-messageformat@11.2.12 beneath the project name.
npm ls intl-messageformat
Step 2: Define Locale Messages and One Formatting Boundary
Create src/messages.js. Keep message lookup, input validation, locale selection, and ICU formatting behind one function. Tests then call the same boundary that application code can call.
import IntlMessageFormat from 'intl-messageformat';
export const messages = Object.freeze({
en: Object.freeze({
cart: '{count, plural, =0 {Your cart is empty.} one {Your cart has # item.} other {Your cart has # items.}}'
}),
fr: Object.freeze({
cart: '{count, plural, =0 {Votre panier est vide.} one {Votre panier contient # article.} other {Votre panier contient # articles.}}'
}),
ru: Object.freeze({
cart: '{count, plural, =0 {Корзина пуста.} one {В корзине # товар.} few {В корзине # товара.} many {В корзине # товаров.} other {В корзине # товара.}}'
}),
ar: Object.freeze({
cart: '{count, plural, =0 {سلتك فارغة.} one {في سلتك عنصر واحد.} two {في سلتك عنصران.} few {في سلتك # عناصر.} many {في سلتك # عنصرًا.} other {في سلتك # عنصر.}}'
})
});
export function formatMessage(locale, id, values) {
const pattern = messages[locale]?.[id];
if (!pattern) {
throw new RangeError(`Missing message: ${locale}.${id}`);
}
if (!Number.isFinite(values?.count)) {
throw new TypeError('count must be a finite number');
}
return String(new IntlMessageFormat(pattern, locale).format(values));
}
The other branch is mandatory in ICU plural arguments, even when your sampled values never reach it. The explicit =0 selector provides product-specific empty-cart copy. It is not interchangeable with the zero category: an exact selector matches the numeric value 0 regardless of the locale category assigned to 0.
Notice that each translation owns the complete sentence. Do not assemble localized output from prefix + count + noun; word order and inflection can depend on the branch.
Verify Step 2: format one value directly. The expected line is Your cart has 2 items.
node -e "import('./src/messages.js').then(({formatMessage}) => console.log(formatMessage('en', 'cart', {count: 2})))"
Step 3: Test ICU Message Pluralization Localization Outputs
Create test/cart-plurals.test.js. This table chooses values that reach every branch present in the four translations. It asserts the entire string because checking only the selected category would miss incorrect words, punctuation, or interpolation.
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatMessage } from '../src/messages.js';
const arabicThree = new Intl.NumberFormat('ar').format(3);
const arabicEleven = new Intl.NumberFormat('ar').format(11);
const cases = [
['en', 0, 'Your cart is empty.'],
['en', 1, 'Your cart has 1 item.'],
['en', 2, 'Your cart has 2 items.'],
['fr', 0, 'Votre panier est vide.'],
['fr', 1, 'Votre panier contient 1 article.'],
['fr', 2, 'Votre panier contient 2 articles.'],
['ru', 1, 'В корзине 1 товар.'],
['ru', 2, 'В корзине 2 товара.'],
['ru', 5, 'В корзине 5 товаров.'],
['ru', 21, 'В корзине 21 товар.'],
['ar', 1, 'في سلتك عنصر واحد.'],
['ar', 2, 'في سلتك عنصران.'],
['ar', 3, `في سلتك ${arabicThree} عناصر.`],
['ar', 11, `في سلتك ${arabicEleven} عنصرًا.`]
];
for (const [locale, count, expected] of cases) {
test(`${locale} cart count ${count}`, () => {
assert.equal(formatMessage(locale, 'cart', { count }), expected);
});
}
The Arabic expectations calculate the displayed digits with Intl.NumberFormat instead of assuming Western digits. The test still fixes the surrounding Arabic phrase, so it will catch the wrong few or many branch. Russian values 1, 2, 5, and 21 demonstrate why a universal singular/plural pair is inadequate.
Add boundary neighbors when a rule changes category, not arbitrary large lists. For Russian, 11 and 21 are more informative than 6 and 7 because the last digits create different outcomes. Use positive and negative test design to balance representative success paths with deliberate failures.
Verify Step 3: run only this file. Node should report 14 passing tests and zero failures.
node --test test/cart-plurals.test.js
Step 4: Map CLDR Categories Before Expanding the Catalog
A translation branch is reachable only if the locale's rule can select it. Test the runtime's category mapping separately with Intl.PluralRules, a standard JavaScript API. Create test/plural-categories.test.js:
import test from 'node:test';
import assert from 'node:assert/strict';
const categoryCases = [
['en', 0, 'other'], ['en', 1, 'one'], ['en', 2, 'other'],
['fr', 0, 'one'], ['fr', 1, 'one'], ['fr', 2, 'other'],
['ru', 0, 'many'], ['ru', 1, 'one'], ['ru', 2, 'few'],
['ru', 5, 'many'], ['ru', 11, 'many'], ['ru', 21, 'one'],
['ru', 22, 'few'], ['ru', 25, 'many'], ['ru', 1.5, 'other'],
['ar', 0, 'zero'], ['ar', 1, 'one'], ['ar', 2, 'two'],
['ar', 3, 'few'], ['ar', 11, 'many'], ['ar', 100, 'other'],
['ja', 0, 'other'], ['ja', 1, 'other'], ['ja', 2, 'other']
];
for (const [locale, value, expectedCategory] of categoryCases) {
test(`${locale} maps ${value} to ${expectedCategory}`, () => {
const rule = new Intl.PluralRules(locale, { type: 'cardinal' });
assert.equal(rule.select(value), expectedCategory);
});
}
This layer explains failures without duplicating the formatter implementation. If a category assertion changes after a deliberate runtime upgrade, inspect the bundled ICU and CLDR release notes before updating expected strings. If the category passes but the rendered sentence fails, the defect is probably in the message pattern or translation.
Do not require every locale to contain all six named categories. Japanese cardinal rules select only other; Arabic uses all six; English commonly needs one and other. Test categories that the locale owns, plus exact product states such as =0.
Verify Step 4: the following command should finish with 24 passes.
node --test test/plural-categories.test.js
Step 5: Verify Exact Selectors and ICU Plural Offsets
Offsets are useful for phrases where one participant is handled outside the displayed remainder. They are also easy to test incorrectly. Exact selectors compare against the original value, while category selection and # use the value after subtracting the offset.
Create src/advanced-messages.js:
import IntlMessageFormat from 'intl-messageformat';
export const inviteSummary = '{count, plural, offset:1 =0 {Nobody joined.} =1 {You joined.} one {You and one other person joined.} other {You and # other people joined.}}';
export const rankMessage = 'You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}.';
export const fileCountMessage = '{count, plural, one {# file} other {# files}}';
export function formatPattern(locale, pattern, values) {
return String(new IntlMessageFormat(pattern, locale).format(values));
}
Now create test/advanced-plurals.test.js:
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatPattern, inviteSummary } from '../src/advanced-messages.js';
const offsetCases = [
[0, 'Nobody joined.'],
[1, 'You joined.'],
[2, 'You and one other person joined.'],
[5, 'You and 4 other people joined.']
];
for (const [count, expected] of offsetCases) {
test(`offset plural with original count ${count}`, () => {
assert.equal(formatPattern('en', inviteSummary, { count }), expected);
});
}
At count 1, =1 wins before the offset can make the value 0. At count 2, the adjusted value is 1, so the one category wins. At count 5, # renders 4. A test that expects # to repeat the original count would encode the wrong contract and could pressure a developer to break correct behavior.
Keep exact selector cases adjacent to offset boundary cases during review. The edge case testing guide helps identify transitions such as offset minus one, offset, and offset plus one.
Verify Step 5: expect four passing offset tests.
node --test test/advanced-plurals.test.js
Step 6: Separate Ordinal Rules From Number Formatting
Cardinal rules answer how many. Ordinal rules express position, such as 1st or 22nd. Reusing cardinal expectations for selectordinal produces gaps around 11, 12, and 13. Append these tests to test/advanced-plurals.test.js:
import { fileCountMessage, rankMessage } from '../src/advanced-messages.js';
const ordinalCases = [
[1, 'You finished 1st.'], [2, 'You finished 2nd.'],
[3, 'You finished 3rd.'], [4, 'You finished 4th.'],
[11, 'You finished 11th.'], [12, 'You finished 12th.'],
[13, 'You finished 13th.'], [21, 'You finished 21st.']
];
for (const [place, expected] of ordinalCases) {
test(`English ordinal ${place}`, () => {
assert.equal(formatPattern('en', rankMessage, { place }), expected);
});
}
for (const locale of ['en-US', 'de-DE', 'hi-IN', 'ar-EG']) {
test(`${locale} formats the # token`, () => {
const expectedNumber = new Intl.NumberFormat(locale).format(1234567);
const actual = formatPattern(locale, fileCountMessage, { count: 1234567 });
assert.equal(actual, `${expectedNumber} files`);
});
}
Because the file already imports formatPattern, add only the new named imports to its existing import statement instead of leaving two imports from the same module if your linter forbids that style. The example isolates numeric rendering, so the English word files is intentional. Production messages must translate the noun and may need different plural branches.
The hi-IN case exposes Indian digit grouping, while de-DE exposes different grouping punctuation and ar-EG may expose another numbering system. Avoid normalizing spaces or digits before comparison. Those characters are part of the visible localized result.
Verify Step 6: rerun the combined file. It should now contain 16 passes: four offset, eight ordinal, and four number-format cases.
node --test test/advanced-plurals.test.js
Step 7: Automate Test ICU Message Pluralization Localization Catalog Checks
Rendered examples cannot cover every translation on every commit. Add a fast catalog gate that compares keys and parses each pattern. Create src/catalog-validator.js:
import IntlMessageFormat from 'intl-messageformat';
export function validateCatalog(catalog, sourceLocale = 'en') {
const source = catalog[sourceLocale];
if (!source) return [`Missing source locale: ${sourceLocale}`];
const issues = [];
const sourceKeys = Object.keys(source);
for (const [locale, bundle] of Object.entries(catalog)) {
for (const key of sourceKeys) {
if (!(key in bundle)) issues.push(`${locale}: missing key ${key}`);
}
for (const [key, pattern] of Object.entries(bundle)) {
if (!(key in source)) issues.push(`${locale}: unexpected key ${key}`);
try {
new IntlMessageFormat(pattern, locale);
} catch (error) {
issues.push(`${locale}.${key}: ${error.message}`);
}
}
}
return issues;
}
Create test/catalog-validation.test.js:
import test from 'node:test';
import assert from 'node:assert/strict';
import { messages, formatMessage } from '../src/messages.js';
import { validateCatalog } from '../src/catalog-validator.js';
test('the production catalog is complete and parseable', () => {
assert.deepEqual(validateCatalog(messages), []);
});
test('a plural without other is rejected', () => {
const broken = {
en: messages.en,
fr: { cart: '{count, plural, one {Un article.}}' }
};
const issues = validateCatalog(broken);
assert.equal(issues.length, 1);
assert.match(issues[0], /^fr\.cart:/);
});
test('a locale missing a source key is reported', () => {
const catalog = {
en: { ...messages.en, checkout: 'Checkout' },
fr: messages.fr
};
assert.ok(validateCatalog(catalog).includes('fr: missing key checkout'));
});
test('invalid runtime inputs fail clearly', () => {
assert.throws(() => formatMessage('en', 'cart', { count: NaN }), TypeError);
assert.throws(() => formatMessage('es', 'cart', { count: 2 }), RangeError);
});
Parsing detects malformed braces, unsupported syntax, and the required other omission. Key comparison catches an incomplete locale before the application falls back or displays an identifier. Runtime guards give invalid data a recognizable failure instead of a misleading localization symptom.
For a large catalog, run this structural gate on every pull request and keep a smaller reviewed output matrix for high-risk messages. Prioritize payment totals, destructive confirmations, quotas, trial limits, and accessibility announcements. The test case prioritization guide gives a risk-based way to choose that matrix.
Verify Step 7: run the entire project. All test files should pass, and any nonzero exit code should fail CI.
npm test
Troubleshooting
Problem: MISSING_OTHER_CLAUSE or a similar parse error appears -> add an other {...} branch inside every plural, selectordinal, or select argument. other is required even if explicit selectors appear to cover current values. Check brace balance around nested arguments before changing the test.
Problem: Arabic digits differ between a laptop and CI -> compare node --version, process.versions.icu, the resolved locale, and the formatter lockfile on both machines. Use official full-ICU Node builds. Do not convert Arabic digits to ASCII merely to make an assertion pass because the transformation can hide a user-visible regression.
Problem: French zero selects one, but the empty-state test expects separate text -> keep =0 in the message. CLDR categories express grammar, while exact selectors express application meaning. Test new Intl.PluralRules('fr').select(0) and the formatted =0 output as two separate contracts.
Problem: Russian works for 1 and 2 but fails for 11 or 25 -> expand the data table around digit boundaries. Include 0, 1, 2, 5, 11, 21, 22, 25, and a decimal. Never derive Russian expectations from English category names without consulting the approved translation.
Problem: an offset test prints one fewer item than the input -> confirm whether # is inside a plural with offset:n. ICU subtracts the offset for # and category selection, but exact =value selectors still inspect the original argument. Document the product meaning so future reviewers do not remove the offset as an apparent arithmetic bug.
Problem: the formatter throws about a missing variable -> compare the placeholders in the translated pattern with the values object. A catalog parser proves syntax, not that a call site supplies count, place, or nested rich-text handlers. Add a rendered smoke case for each message signature or extract variable names during build-time validation.
When a failure is hard to classify, reduce it to one locale, one message, and one input number. Inspect Intl.PluralRules(locale).select(value), then format the actual pattern. This separates runtime category behavior from translation content in two commands.
Interview Questions and Answers
Q: Why is testing only 1 and 2 insufficient for ICU plurals?
English makes that pair look complete, but CLDR languages can have up to six cardinal categories. Values such as Russian 11 and Arabic 2 exercise rules that 1 and 2 in English cannot represent. A strong matrix comes from the target locale's category boundaries.
Q: What is the difference between =0 and zero?
=0 is an exact numeric selector. zero is a locale-dependent CLDR category that may include values defined by that locale's rule. ICU checks the exact match first, so teams often use =0 for special empty-state copy.
Q: What does offset:1 change?
It subtracts one before category selection and before replacing #. Exact selectors still compare with the original count. Therefore a suite needs explicit matches plus values on both sides of the adjusted category boundary.
Q: Should tests assert plural categories or final strings?
Use both for different diagnostic value. Category assertions reveal runtime or locale-data changes, while final-string assertions detect wrong words, punctuation, interpolation, and number formatting. A category-only suite cannot validate what the user reads.
Q: How do you test translated text without speaking every language?
Have a qualified reviewer approve expected fixtures, then automate exact comparison against those fixtures. QA can independently validate branch reachability, placeholders, directionality, and formatting. Linguistic correctness should not be guessed from machine translation.
Q: Why separate cardinal and ordinal tests?
They use different CLDR rule sets. In English, 2 is cardinal other but ordinal two, which produces 2nd; 12 returns to ordinal other. Mixing them leaves suffix and grammar defects undetected.
The JSON interviewQnA field below contains additional concise model answers for interview practice. Review them alongside the manual testing interview questions when preparing examples from your own projects.
Best Practices
- Build test values from
resolvedOptions().pluralCategoriesand known boundary numbers, then keep human-readable expected tables in source control. - Preserve whitespace, nonbreaking separators, Unicode digits, punctuation, and bidirectional text in final-output assertions. Visual equality in a terminal is not always code-point equality.
- Keep each message as a complete sentence so translators can change word order and grammar inside a branch.
- Pin Node and formatter versions in CI. Treat intentional ICU or CLDR upgrades as test-data migrations that require review.
- Require
other, compare source and target keys, and reject syntax errors before packaging catalogs. - Pair automation with linguistic review and a small device-level smoke test for truncation, reading order, and assistive technology output. The accessibility testing checklist covers those presentation risks.
- Review failures as product behavior, not snapshots to update automatically. The test case review checklist helps reviewers challenge weak or duplicated expectations.
Where To Go Next
Move the example catalog into the same loading path your application uses, but preserve the pure formatting boundary. Run structural validation on every locale in CI and execute the high-risk output matrix on each pull request. Add browser checks only for concerns that Node cannot prove, including text clipping, bidirectional layout, fallback fonts, and screen-reader announcements.
Next, extend the data table with your actual supported locales and translator-approved strings. If the team uses Vitest, adapt the same table-driven assertions with the Vitest setup for QA guide; the locale values and ICU expectations should remain unchanged. Then test one production page using the exact locale negotiation logic shipped to users.
Conclusion
Reliable ICU localization coverage combines rule-level and output-level evidence. Map the categories, exercise exact selectors and offsets, isolate ordinals, preserve localized numbers, and fail malformed catalogs early.
Start with one revenue-sensitive or frequently viewed message, get its expected translations approved, and run the complete matrix in CI. That focused test provides more confidence than hundreds of English-only snapshots.
Interview Questions and Answers
How would you design a test strategy for ICU pluralization across many locales?
I would split coverage into catalog structure, CLDR category mapping, and translator-approved rendered output. Structural checks run across every key and locale, while a risk-based matrix exercises boundary values for critical messages. I would pin the runtime so CLDR changes arrive through an intentional upgrade.
Why can an English plural test suite give false confidence?
English cardinal grammar commonly exposes only `one` and `other`. It does not force the suite to handle dual, paucal, many, decimal, or locale-specific zero behavior. I include representative languages and production locales that exercise the categories the product actually ships.
How does ICU resolve an exact selector in a plural argument?
An `=n` branch compares directly with the original numeric argument and wins over a named category when it matches. If no exact selector matches, ICU applies any offset and evaluates the locale's plural rule. That precedence deserves an explicit test because `=0` and `zero` express different intent.
What is the most useful assertion for a localized plural message?
For a business-facing message, I assert the complete rendered string against an approved fixture. I also keep a lower-level category assertion when diagnosing CLDR behavior matters. Together they distinguish rule-selection failures from translation or formatting defects.
How would you investigate a pluralization test that changed after a Node upgrade?
I would compare Node and ICU versions, print the resolved locale, and call `Intl.PluralRules.select` for the failing value. Next I would format the smallest failing pattern and review the relevant CLDR change before modifying fixtures. I would never mass-update expected strings without linguistic review.
What negative cases belong in an ICU message test suite?
I test missing `other`, unbalanced braces, absent variables, unsupported locale or key lookup, nonfinite numeric inputs, and missing target-locale keys. I also include decimals and boundary values that are valid but often mishandled. Each failure should identify the locale and message key.
How are selectordinal and plural different?
`plural` uses cardinal rules for quantities, while `selectordinal` uses ordinal rules for positions. The category labels may look identical, but their numeric mappings differ. I maintain separate tables, especially for English values 11, 12, 13, and 21.
What should remain manual after pluralization tests are automated?
Linguistic approval, layout judgment, bidirectional reading checks, font suitability, and assistive-technology experience still need skilled review. Automation is excellent at repeating category and string contracts. It does not establish whether a sentence sounds natural to a native speaker in its product context.
Frequently Asked Questions
How do I test ICU message pluralization localization?
Create a locale-specific table of boundary values, expected CLDR categories, and approved final strings. Run category checks with Intl.PluralRules, then format the real ICU pattern and compare the full output, including localized digits and punctuation.
Which numbers should an ICU plural test include?
Choose at least one value for every category supported by the locale and add values around rule transitions. A practical Russian set includes 0, 1, 2, 5, 11, 21, 22, 25, and a decimal, while Arabic also needs values that reach zero, two, few, many, and other.
Is the ICU zero category the same as =0?
No. `zero` is selected by a locale's CLDR rule, while `=0` matches the numeric value exactly and takes precedence. Use an exact selector when the product needs special text such as an empty-cart message.
How should I test ICU plural offsets?
Test exact values plus counts just above the offset. Confirm that exact selectors inspect the original number, while named category selection and the `#` replacement use the number after subtracting the offset.
Can snapshot tests validate localized plural messages?
Snapshots can preserve final output, but they become risky when reviewers approve large updates without inspecting linguistic changes. Prefer focused table assertions for branch-critical messages and use snapshots only when Unicode differences remain visible in review.
Why do plural tests pass locally and fail in CI?
The environments may use different Node, ICU, CLDR, locale, time-zone, or dependency versions. Pin the runtime and formatter, confirm `process.versions.icu`, and compare resolved locale options before changing an expectation.
Who should approve expected translated strings in localization tests?
A qualified translator, linguist, or language owner should approve the oracle. QA engineers should own reachability, boundaries, placeholders, runtime consistency, and regression coverage without pretending automation can judge idiomatic language.
Should message catalog validation run in CI?
Yes. Parse every pattern, compare target keys with the source locale, and reject missing `other` branches before deployment. Keep a smaller rendered-output suite for messages whose grammar or business impact deserves exact verification.