Testing Currency and Number Formatting Across Locales

Testing Currency and Number Formatting Across Locales

Number formatting is one of those areas where bugs are invisible to developers working in their native locale and catastrophic to users everywhere else. The number 1234567.89 is rendered as 1,234,567.89 in the United States, 1.234.567,89 in Germany, 1 234 567,89 in France, and 12,34,567.89 in India. Each of these is correct for its target audience. All of them are wrong for every other audience.

This guide covers the full scope of locale-specific number and currency formatting problems, how to test them systematically, and how to build automated test suites that catch regressions before your users in Frankfurt or Mumbai do.

The Scope of the Problem

Decimal and Thousands Separators

The most common formatting difference. What English speakers call a "comma for thousands" and a "period for decimals" is reversed in most of continental Europe.

Locale Language Number Example
en-US English (US) 1,234,567.89
de-DE German 1.234.567,89
fr-FR French 1 234 567,89
hi-IN Hindi 12,34,567.89
ar-SA Arabic ١٬٢٣٤٬٥٦٧٫٨٩
ch-CH Swiss German 1'234'567.89

Notice that French uses a narrow non-breaking space (\u202F) as the thousands separator — not a regular space. This trips up regular expression-based parsers that strip "spaces" before parsing numbers.

Arabic uses Arabic-Indic numerals (٠١٢٣٤٥٦٧٨٩) by default in some contexts, not Western Arabic numerals (0-9). If your backend receives a number that a user typed in an Arabic locale, you may receive ١٢٣ instead of 123.

The Indian Numbering System

India uses a distinct grouping system where the first group from the right is three digits, and all subsequent groups are two digits:

  • 1,000 → 1,000
  • 10,000 → 10,000
  • 100,000 → 1,00,000
  • 10,000,000 → 1,00,00,000

The term "one lakh" means 100,000 and "one crore" means 10,000,000. These are not just formatting preferences — they reflect how Indians conceptualize large numbers. If your app displays financial figures to Indian users in the Western grouping format, it will feel wrong even if it is technically parseable.

Currency Symbol Placement

Currency symbols do not always go before the number. The position varies by locale, and for currencies that are displayed in multiple countries, the position may differ.

Locale Currency Formatted
en-US USD $1,234.56
de-DE EUR 1.234,56 €
fr-FR EUR 1 234,56 €
en-GB GBP £1,234.56
ja-JP JPY ¥1,235
sv-SE SEK 1 234,56 kr
ar-SA SAR ١٬٢٣٤٫٥٦ ر.س.

Note that Japanese Yen has no decimal places — JPY is a zero-decimal currency. If your payment system passes 1234.56 for a JPY transaction, you will charge 123,456 yen (or cause an error, depending on your payment processor). This is a real class of billing bug that pseudo-localization and number format testing catch.

Negative Number Formatting

Negative numbers have even more variation than positive ones:

Locale Negative format
en-US -$1,234.56
de-DE -1.234,56 €
nl-NL € -1.234,56
tr-TR -₺1.234,56
Accounting (1,234.56)

Parentheses for negative numbers (accounting format) are common in financial software. If your frontend displays -100 but your accountant expects (100), you have a communication bug even if the underlying value is correct.

Testing With the Intl.NumberFormat API

Modern JavaScript provides Intl.NumberFormat for locale-aware number and currency formatting. Your tests should verify that you are using it correctly and that your formatting functions produce the right output for each target locale.

// src/utils/formatters.js
export function formatCurrency(amount, currency, locale) {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(amount);
}

export function formatNumber(number, locale, options = {}) {
  return new Intl.NumberFormat(locale, options).format(number);
}

export function formatPercent(ratio, locale) {
  return new Intl.NumberFormat(locale, {
    style: 'percent',
    minimumFractionDigits: 1,
    maximumFractionDigits: 1,
  }).format(ratio);
}

Jest Tests for Number Formatting

// src/utils/formatters.test.js
import { formatCurrency, formatNumber, formatPercent } from './formatters';

describe('formatCurrency', () => {
  const cases = [
    // [amount, currency, locale, expected]
    [1234.56, 'USD', 'en-US', '$1,234.56'],
    [1234.56, 'EUR', 'de-DE', '1.234,56\u00a0€'],   // non-breaking space before €
    [1234.56, 'EUR', 'fr-FR', '1\u202f234,56\u00a0€'], // narrow nbsp thousands separator
    [1234.56, 'GBP', 'en-GB', '£1,234.56'],
    [1235, 'JPY', 'ja-JP', '¥1,235'],               // JPY rounds to integer
    [0, 'USD', 'en-US', '$0.00'],
    [-1234.56, 'USD', 'en-US', '-$1,234.56'],
    [0.01, 'USD', 'en-US', '$0.01'],
    [999999999.99, 'USD', 'en-US', '$999,999,999.99'],
  ];

  test.each(cases)(
    'formats %d %s in %s as %s',
    (amount, currency, locale, expected) => {
      expect(formatCurrency(amount, currency, locale)).toBe(expected);
    }
  );

  test('throws for invalid currency code', () => {
    expect(() => formatCurrency(100, 'FAKE', 'en-US')).toThrow();
  });
});

describe('formatNumber — Indian grouping system', () => {
  test('formats one lakh correctly', () => {
    const formatted = formatNumber(100000, 'hi-IN');
    // 1,00,000 — note the two-digit groups after the first three
    expect(formatted).toBe('1,00,000');
  });

  test('formats one crore correctly', () => {
    const formatted = formatNumber(10000000, 'hi-IN');
    expect(formatted).toBe('1,00,00,000');
  });
});

describe('formatPercent', () => {
  test('formats 0.1234 as 12.3% in en-US', () => {
    expect(formatPercent(0.1234, 'en-US')).toBe('12.3%');
  });

  test('formats 0.1234 as 12,3% in de-DE', () => {
    expect(formatPercent(0.1234, 'de-DE')).toBe('12,3\u00a0%'); // space before % in German
  });
});

Handling Zero-Decimal Currencies

Some currencies have no minor units (JPY, KRW, VND). Others have three decimal places (KWD, BHD). Your formatting layer should not assume all currencies have two decimal places.

const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'UGX']);
const THREE_DECIMAL_CURRENCIES = new Set(['KWD', 'BHD', 'OMR', 'JOD']);

export function formatCurrencyAware(amount, currency, locale) {
  let fractionDigits = 2;
  if (ZERO_DECIMAL_CURRENCIES.has(currency)) fractionDigits = 0;
  if (THREE_DECIMAL_CURRENCIES.has(currency)) fractionDigits = 3;

  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: fractionDigits,
    maximumFractionDigits: fractionDigits,
  }).format(amount);
}
// Test zero-decimal and three-decimal currencies
describe('formatCurrencyAware', () => {
  test('JPY has no decimal places', () => {
    expect(formatCurrencyAware(1234, 'JPY', 'ja-JP')).toBe('¥1,234');
    // NOT ¥1,234.00
  });

  test('KWD has three decimal places', () => {
    expect(formatCurrencyAware(1.234, 'KWD', 'ar-KW')).toMatch(/1٫234/);
  });

  test('storing 1234.56 for JPY should error or round', () => {
    // This is a business logic test: your payment layer should reject
    // fractional amounts for zero-decimal currencies
    expect(() => validatePaymentAmount(1234.56, 'JPY')).toThrow(
      'JPY does not support decimal amounts'
    );
  });
});

Python Locale Testing

Python's locale module and the babel library both provide locale-aware formatting. babel is generally more reliable because it uses CLDR data rather than the OS locale configuration.

from babel.numbers import format_currency, format_number, format_percent
import pytest


@pytest.mark.parametrize('amount,currency,locale,expected', [
    (1234.56, 'USD', 'en_US', '$1,234.56'),
    (1234.56, 'EUR', 'de_DE', '1.234,56\xa0€'),
    (1234.56, 'EUR', 'fr_FR', '1\u202f234,56\xa0€'),
    (1235, 'JPY', 'ja_JP', '¥1,235'),
    (0, 'USD', 'en_US', '$0.00'),
    (-1234.56, 'USD', 'en_US', '-$1,234.56'),
])
def test_currency_formatting(amount, currency, locale, expected):
    result = format_currency(amount, currency, locale=locale)
    assert result == expected, f'Expected {expected!r}, got {result!r}'


def test_indian_lakh_formatting():
    result = format_number(100000, locale='hi_IN')
    assert result == '1,00,000'


def test_arabic_numerals():
    # In some Arabic locales, numbers render in Arabic-Indic script
    result = format_number(1234, locale='ar_SA')
    # Babel uses Western digits for ar_SA by default,
    # but verify your application's behavior matches user expectations
    assert '1' in result or '١' in result  # accept either form


def test_percent_locale():
    result = format_percent(0.1234, locale='en_US', decimal_quantization=False)
    assert '12.34%' in result or '12.3%' in result

Building a Test Matrix

Rather than writing individual test cases for every locale-currency combination, build a data-driven test matrix. Define your supported locale and currency combinations in a fixture file:

// test/fixtures/currency-matrix.json
{
  "cases": [
    {
      "description": "US Dollar in US English",
      "amount": 1234.56,
      "currency": "USD",
      "locale": "en-US",
      "expected": "$1,234.56",
      "negative_expected": "-$1,234.56"
    },
    {
      "description": "Euro in German",
      "amount": 1234.56,
      "currency": "EUR",
      "locale": "de-DE",
      "expected": "1.234,56\u00a0€",
      "negative_expected": "-1.234,56\u00a0€"
    },
    {
      "description": "Japanese Yen",
      "amount": 1234,
      "currency": "JPY",
      "locale": "ja-JP",
      "expected": "¥1,234",
      "negative_expected": "-¥1,234"
    }
  ]
}

Load this matrix in your test file and drive the assertions from data. When you add support for a new locale, you add a row to the fixture — not a new test function. This keeps the test file maintainable as your supported locale list grows.

Parsing User Input

A related problem that is often overlooked: parsing numbers that users type in their locale's format. If a German user types 1.234,56 and your input field tries to parse it with parseFloat(), you get 1.234 — silently wrong.

export function parseLocalizedNumber(str, locale) {
  const formatter = new Intl.NumberFormat(locale);
  const parts = formatter.formatToParts(1234.5);
  
  const thousandsSep = parts.find(p => p.type === 'group')?.value || ',';
  const decimalSep = parts.find(p => p.type === 'decimal')?.value || '.';
  
  // Remove thousands separators, replace locale decimal with standard period
  const normalized = str
    .replace(new RegExp(`\\${thousandsSep}`, 'g'), '')
    .replace(new RegExp(`\\${decimalSep}`), '.');
  
  const result = parseFloat(normalized);
  if (isNaN(result)) throw new Error(`Cannot parse "${str}" as a number in locale ${locale}`);
  return result;
}
describe('parseLocalizedNumber', () => {
  test('parses German number with period thousands and comma decimal', () => {
    expect(parseLocalizedNumber('1.234,56', 'de-DE')).toBeCloseTo(1234.56);
  });

  test('parses French number with space thousands', () => {
    expect(parseLocalizedNumber('1 234,56', 'fr-FR')).toBeCloseTo(1234.56);
  });

  test('throws on unparseable string', () => {
    expect(() => parseLocalizedNumber('abc', 'en-US')).toThrow();
  });
});

End-to-End Verification Checklist

Before releasing a feature that displays or accepts numbers or currencies:

Display side:

  • All amounts use Intl.NumberFormat or an equivalent CLDR-backed library
  • Currency display adapts to the user's locale, not just the currency code
  • Zero-decimal currencies (JPY, KRW) do not show .00
  • Negative numbers display correctly (with - prefix, not just parentheses)
  • Large numbers (millions, billions) do not overflow their containers in any supported locale
  • Indian numbering system tested if hi-IN or en-IN is in scope
  • Arabic numerals tested if any Arabic locale is in scope

Input side:

  • Number inputs use a locale-aware parser, not parseFloat()
  • Currency input fields handle both comma and period as decimal separators based on locale
  • The backend validates locale-parsed numbers, not raw strings

Backend:

  • Numbers stored in a locale-neutral format (standard decimal, no thousands separators)
  • Currency amounts stored with explicit currency code, not just a number
  • Zero-decimal currency amounts stored and transmitted as integers (or with zero decimal places)

Number formatting is not optional for global software. The good news is that the browser's Intl.NumberFormat API is excellent — the problems almost always come from not using it, or from using it inconsistently. A systematic test matrix catches those inconsistencies before they reach production.

Read more