Testing React-Intl Translations with Jest

Testing React-Intl Translations with Jest

react-intl is the standard i18n library for React applications built on the FormatJS ecosystem. Testing it well means more than verifying that text appears—it means asserting that the correct translation appears, that pluralization rules are respected, and that date and number formatting matches locale expectations. This guide covers the full testing setup for react-intl with Jest and React Testing Library.

Installing the Test Dependencies

npm install --save-dev @testing-library/react @testing-library/jest-dom jest-environment-jsdom

react-intl itself is a runtime dependency:

npm install react-intl

Creating a Reusable Render Helper

The single most important thing you can do for react-intl testing is create a shared renderWithIntl helper. Without it, every test file will import IntlProvider, look up translations, and wrap components—a lot of boilerplate that will diverge over time.

// src/test-utils/renderWithIntl.jsx
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';

// Import all locale message files
import enMessages from '../locales/en.json';
import deMessages from '../locales/de.json';
import frMessages from '../locales/fr.json';
import esMessages from '../locales/es.json';

const messageMap = {
  en: enMessages,
  de: deMessages,
  fr: frMessages,
  es: esMessages,
};

export function renderWithIntl(ui, options = {}) {
  const {
    locale = 'en',
    messages,
    onError = (err) => {
      // Suppress missing translation warnings in tests unless explicitly watching for them
      if (!err.message.includes('Missing message')) throw err;
    },
    ...renderOptions
  } = options;

  const resolvedMessages = messages ?? messageMap[locale] ?? enMessages;

  return render(
    <IntlProvider locale={locale} messages={resolvedMessages} onError={onError}>
      {ui}
    </IntlProvider>,
    renderOptions
  );
}

// Re-export everything from Testing Library for convenience
export * from '@testing-library/react';

Now every test imports from test-utils instead of @testing-library/react:

import { renderWithIntl, screen } from '../test-utils/renderWithIntl';

Testing Message Key Rendering

The most basic test: given a key, does the correct string appear?

// src/components/WelcomeBanner/WelcomeBanner.jsx
import { FormattedMessage } from 'react-intl';

export function WelcomeBanner({ name }) {
  return (
    <div>
      <h1>
        <FormattedMessage id="welcome.heading" values={{ name }} />
      </h1>
      <p>
        <FormattedMessage id="welcome.subheading" />
      </p>
    </div>
  );
}
// locales/en.json
{
  "welcome.heading": "Welcome, {name}!",
  "welcome.subheading": "You have successfully logged in."
}
// src/components/WelcomeBanner/WelcomeBanner.test.jsx
import { renderWithIntl, screen } from '../../test-utils/renderWithIntl';
import { WelcomeBanner } from './WelcomeBanner';

describe('WelcomeBanner', () => {
  test('renders localized heading with name interpolation', () => {
    renderWithIntl(<WelcomeBanner name="Alice" />);
    expect(screen.getByText('Welcome, Alice!')).toBeInTheDocument();
  });

  test('renders localized subheading', () => {
    renderWithIntl(<WelcomeBanner name="Alice" />);
    expect(screen.getByText('You have successfully logged in.')).toBeInTheDocument();
  });

  test('renders German heading', () => {
    renderWithIntl(<WelcomeBanner name="Alice" />, { locale: 'de' });
    expect(screen.getByText('Willkommen, Alice!')).toBeInTheDocument();
  });
});

Testing Pluralization

Pluralization is where most i18n bugs live. react-intl uses ICU message format, which handles plural rules correctly for each locale. English has "one" and "other"; Arabic has six forms.

// locales/en.json
{
  "cart.itemCount": "{count, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}"
}
// src/components/CartBadge/CartBadge.jsx
import { useIntl } from 'react-intl';

export function CartBadge({ count }) {
  const intl = useIntl();
  const label = intl.formatMessage({ id: 'cart.itemCount' }, { count });
  return <span aria-label={label}>{count}</span>;
}
// src/components/CartBadge/CartBadge.test.jsx
import { renderWithIntl, screen } from '../../test-utils/renderWithIntl';
import { CartBadge } from './CartBadge';

describe('CartBadge pluralization', () => {
  test.each([
    [0, 'Your cart is empty'],
    [1, '1 item in cart'],
    [2, '2 items in cart'],
    [10, '10 items in cart'],
    [100, '100 items in cart'],
  ])('count=%i renders "%s"', (count, expected) => {
    renderWithIntl(<CartBadge count={count} />);
    expect(screen.getByLabelText(expected)).toBeInTheDocument();
  });
});

For a component using German pluralization, run the same test cases with { locale: 'de' } and German expected values.

Testing Date and Number Formatting

react-intl wraps the browser's Intl API. In a Jest/jsdom environment, Intl support is provided by Node.js, which includes full ICU data when using the full-icu package.

npm install --save-dev full-icu

In jest.config.js:

module.exports = {
  testEnvironment: 'jsdom',
  globals: {
    'NODE_ICU_DATA': 'node_modules/full-icu',
  },
};

Or set the environment variable in your test script:

"test": "NODE_ICU_DATA=node_modules/full-icu jest"

Now date and number formatting works correctly in tests:

// src/components/PriceDisplay/PriceDisplay.jsx
import { FormattedNumber } from 'react-intl';

export function PriceDisplay({ amount, currency }) {
  return (
    <FormattedNumber
      value={amount}
      style="currency"
      currency={currency}
    />
  );
}
// src/components/PriceDisplay/PriceDisplay.test.jsx
import { renderWithIntl, screen } from '../../test-utils/renderWithIntl';
import { PriceDisplay } from './PriceDisplay';

describe('PriceDisplay', () => {
  test('formats USD in en-US locale', () => {
    renderWithIntl(<PriceDisplay amount={1234.5} currency="USD" />, {
      locale: 'en',
    });
    expect(screen.getByText('$1,234.50')).toBeInTheDocument();
  });

  test('formats EUR in de-DE locale', () => {
    renderWithIntl(<PriceDisplay amount={1234.5} currency="EUR" />, {
      locale: 'de',
    });
    // German format: 1.234,50 €
    expect(screen.getByText(/1\.234,50/)).toBeInTheDocument();
  });
});

Testing Locale Switching

When users switch languages at runtime, the component tree must re-render with new translations. Test that state updates propagate:

// src/components/LanguageSwitcher/LanguageSwitcher.test.jsx
import React, { useState } from 'react';
import { renderWithIntl, screen, fireEvent } from '../../test-utils/renderWithIntl';
import { FormattedMessage } from 'react-intl';
import enMessages from '../../locales/en.json';
import deMessages from '../../locales/de.json';

function AppWithLocaleSwitch() {
  const [locale, setLocale] = useState('en');
  const messages = locale === 'en' ? enMessages : deMessages;

  return (
    <IntlProvider locale={locale} messages={messages}>
      <button onClick={() => setLocale(locale === 'en' ? 'de' : 'en')}>
        Switch
      </button>
      <FormattedMessage id="nav.home" />
    </IntlProvider>
  );
}

test('switches locale on button click', () => {
  render(<AppWithLocaleSwitch />);
  expect(screen.getByText('Home')).toBeInTheDocument();
  fireEvent.click(screen.getByText('Switch'));
  expect(screen.getByText('Startseite')).toBeInTheDocument();
});

Testing Missing Key Behavior

You should explicitly test what happens when a key is missing. The default react-intl behavior is to log a warning and render the key itself. Make sure that behavior is acceptable for your app, or override it:

test('renders fallback when key is missing', () => {
  const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

  renderWithIntl(<FormattedMessage id="nonexistent.key" defaultMessage="Fallback text" />);
  expect(screen.getByText('Fallback text')).toBeInTheDocument();

  consoleSpy.mockRestore();
});

Always provide a defaultMessage prop in production components so that missing keys degrade gracefully rather than exposing raw key strings to users.

Translation Key Coverage Tests

Separate from component tests, run a dedicated coverage check that compares translation files:

// src/__tests__/i18n-coverage.test.js
import enMessages from '../locales/en.json';
import deMessages from '../locales/de.json';
import frMessages from '../locales/fr.json';

function flattenKeys(obj, prefix = '') {
  return Object.entries(obj).flatMap(([key, value]) => {
    const full = prefix ? `${prefix}.${key}` : key;
    return typeof value === 'object' ? flattenKeys(value, full) : [full];
  });
}

const locales = {
  de: deMessages,
  fr: frMessages,
};

const enKeys = flattenKeys(enMessages);

describe('Translation coverage', () => {
  Object.entries(locales).forEach(([locale, messages]) => {
    const localeKeys = flattenKeys(messages);

    test(`${locale} contains all English keys`, () => {
      const missing = enKeys.filter(k => !localeKeys.includes(k));
      if (missing.length > 0) {
        throw new Error(`Missing in ${locale}:\n${missing.join('\n')}`);
      }
      expect(missing).toHaveLength(0);
    });

    test(`${locale} has no extra keys not in English`, () => {
      const extra = localeKeys.filter(k => !enKeys.includes(k));
      if (extra.length > 0) {
        throw new Error(`Extra keys in ${locale}:\n${extra.join('\n')}`);
      }
      expect(extra).toHaveLength(0);
    });
  });
});

This runs in milliseconds and catches translation drift before it ships.

Common Pitfalls

Snapshot testing translated strings: Snapshots fail every time you update a translation. Use explicit getByText assertions instead.

Assuming English in assertions: expect(screen.getByText('Submit')).toBeInTheDocument() will fail in a German locale. Either always specify the locale or use role-based selectors: screen.getByRole('button', { name: /submit/i }).

Not testing zero states: Most pluralization bugs are in the zero case. Always include count=0 in test cases.

Missing ICU data: If your date or number formatting tests return unexpected output, check that full-icu is loaded. Without it, Node.js only includes partial ICU data and some locales fall back to en-US format.

Summary

React-Intl testing has three layers: the shared renderWithIntl helper that removes boilerplate, component-level tests that assert message content and pluralization, and file-level key coverage tests that prevent translation drift. With this structure in place, adding a new locale is a matter of providing a new translation file—the tests tell you immediately when it's complete.

Read more