Author:
    Creation:2026-09-02Last update:2026-09-02

    How to test translations without writing brittle tests

    Most i18n test suites fail in one of two ways. Either they assert on literal copy, so every wording change breaks fifty tests and the team deletes them. Or they render everything in the default locale, so they prove nothing about the other seventeen. Both end in the same place: a suite nobody trusts.

    Table of Contents

    The patterns are library-agnostic

    Every pattern below works on any i18n stack. Swap the provider for I18nextProvider, NextIntlClientProvider or IntlProvider and the tests are otherwise identical, because they assert on rendered output rather than on a library API.

    The coverage tooling ports too: with the Sync JSON plugin pointed at your existing catalogs, or a compat adapter aliasing your current imports, the coverage assertion runs against the JSON you already have.

    Decide what you are actually testing

    Translation quality is not a test. No assertion tells you whether the German is idiomatic, and pretending otherwise is how you get a suite full of hardcoded strings.

    What is worth testing is mechanical:

    Worth testing Not worth testing
    Every required locale has a value Whether the wording is good
    The right locale reaches the component Exact copy of every label
    Plurals resolve for each category That a translator did their job
    RTL locales set direction and mirror Every string in every locale
    Formatted dates and numbers use the locale Third-party Intl correctness

    Coverage belongs in one data-driven test, not in your component tests. That is covered in finding missing translations; this post is about the rest.

    Render under a provider, assert on role

    The core pattern is to mount the component inside a locale provider and query by role or test id rather than by copy.

    CartSummary.test.tsx
    import { render, screen } from "@testing-library/react";
    import { IntlayerProvider } from "react-intlayer/client";
    import { CartSummary } from "./CartSummary";
    
    test("renders the summary heading in French", () => {
      render(
        <IntlayerProvider locale="fr-FR">
          <CartSummary />
        </IntlayerProvider>
      );
    
      expect(screen.getByRole("heading")).toBeInTheDocument();
    });
    

    Querying getByRole("heading") survives a copy change. getByText("Récapitulatif") does not. Use the literal only when the string itself is the thing under test, which is rare.

    For attributes such as aria-label you need the raw string rather than a renderable node. In React, useIntlayer entries expose a .value field for that.

    Parameterize across locales

    One test body, every locale, is worth more than one test per locale.

    direction.test.tsx
    import { getHTMLTextDir } from "intlayer";
    import { render } from "@testing-library/react";
    import { IntlayerProvider } from "react-intlayer/client";
    
    describe.each(["en", "fr", "ja", "ar"])("locale %s", (locale) => {
      it("renders without falling back to the key", () => {
        const { container } = render(
          <IntlayerProvider locale={locale}>
            <CartSummary />
          </IntlayerProvider>
        );
    
        // A rendered key means the lookup failed.
        expect(container.textContent).not.toMatch(/^[a-z]+(\.[a-z]+)+$/);
      });
    
      it("sets the correct text direction", () => {
        expect(getHTMLTextDir(locale)).toBe(locale === "ar" ? "rtl" : "ltr");
      });
    });
    

    The first assertion is the cheap generic win: if a lookup fails and your library renders the key, the DOM contains something shaped like cart.summary.title. That catches a whole class of bugs without naming a single string.

    Pseudolocalization finds what catalogs cannot

    Add a fake locale that transforms every string, for example Checkout becoming [!!! Çĥéçķöũţ !!!]. Then render a page in it.

    Anything still in plain English is hardcoded, and no catalog-based check can see that, because as far as the tooling is concerned the string does not exist. The brackets do a second job: they expand the text by roughly 30 percent, which surfaces layout that breaks in German before German does.

    This is worth running as a visual or end-to-end pass rather than a unit test, since the failure is something you look at.

    Plurals need a test per category, not per language

    Plural bugs hide because English has two forms and most developers only ever exercise those. Polish has four, Arabic six.

    plural.test.ts
    // Arabic exercises zero, one, two, few, many, other.
    describe.each([0, 1, 2, 3, 11, 100])("count %i", (count) => {
      it("produces a non-empty string in Arabic", () => {
        expect(formatItems(count, "ar")).not.toBe("");
      });
    });
    

    Pick counts that hit each CLDR category for your worst-case language rather than testing 1 and 2 everywhere. Intl.PluralRules tells you which category a number falls into, so you can derive the sample set instead of guessing. More on the categories in the ICU message format post.

    The snapshot trap

    Snapshots and i18n are a bad match. A snapshot of a localized component encodes every string in it, so a translator fixing a typo in Portuguese turns a green suite red, in a file no reviewer can evaluate. After the third time, someone runs -u without reading the diff, and the snapshots stop meaning anything.

    If you want snapshots, take them in one locale only, and treat that as a structural check rather than a content check. Everything locale-specific belongs in explicit assertions.

    Test the negotiation, not just the rendering

    The most common production i18n bug is not a missing string. It is the wrong locale being selected: a URL says /fr/, the client reads navigator.language, and they disagree.

    Test the resolution order directly, as a pure function, separate from any component:

    locale-resolution.test.ts
    it("prefers the URL over the stored preference", () => {
      expect(resolveLocale({ url: "/fr/about", stored: "de", header: "ja" })).toBe(
        "fr"
      );
    });
    
    it("falls back to the header when the URL has no prefix", () => {
      expect(resolveLocale({ url: "/about", stored: null, header: "ja" })).toBe(
        "ja"
      );
    });
    

    This is the single highest-value i18n test most codebases are missing, and it needs no DOM.

    What to run where

    • Unit: locale negotiation, formatters, plural categories. Fast, no DOM.
    • Component: one provider-based render per locale, asserting on roles and on the absence of raw keys.
    • Coverage: one data-driven test asserting no missing required locales.
    • Visual or end-to-end: pseudolocale pass and one RTL page, because those failures are visual.

    Keep the first three in the pipeline on every commit. The last one is cheap to run nightly and expensive to run on every push.

    Common mistakes

    • Asserting on literal copy everywhere. Guarantees the suite is deleted within a quarter.
    • Snapshotting localized components. Translators break your build, reviewers rubber-stamp the update.
    • Testing only the default locale. The one locale that cannot be missing.
    • Testing 1 and 2 for plurals. Misses every category English does not have.
    • Mocking the i18n library away. You are then testing that your mock returns strings.
    • Never testing negotiation. The most common real-world failure, and the easiest to test.

    Going further

    Comments

    No comments yet. Be the first to share your thoughts.

    Related Posts

    Last Posts