DEV Community

Cover image for Testing with Playwright: Use i18next Translations in Tests, but not `t('key')`
a-dev
a-dev

Posted on

Testing with Playwright: Use i18next Translations in Tests, but not `t('key')`

E2E-testing localized applications can be challenging, translations keys can make test code harder to read and maintain. This article demonstrates how to test i18next translations in React app using Playwright, with a simplified approach that avoids translation keys. The idea can be used in any project with i18next or similar libraries.

This approach builds upon concepts from my previous article about using Playwright fixtures for authorization in RBAC applications (Testing with Playwright: Making Authorization Less Painful and More Readable).

Here's a practical example of how it looks in a test:

const LOCALES = ["en", "es", "zh"];

describe("Author page", () => {
  for (let locale of LOCALES) {
    test(`it has a link to articles. {locale: ${locale}}`, async ({ page, tkey }) => {
      await page.goto("/authors/123");
      const link = await page.getByRole("link").nth(0).textContent();
      expect(link).toBe(tkey("Mis articulos", "menu"));
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

The key concept here is using the actual phrase instead of an i18n key for translation. Traditional approaches often use translation keys, which can be hard to read. For example: expect(link).toBe(t("menu.current_user_articles_link"));. This contradicts the principle of writing easily readable tests.

Implementation: Swapping Translation Keys with Phrases

The implementation revolves around using phrases that correspond to translation keys in tests. Here's an example of a typical translation file:

{
  "en": {
    "menu": {
      "current_user_articles_link": "My articles"
    }
  },
  "es": {
    "menu": {
      "current_user_articles_link": "Mis articulos"
    }
  },
  "zh": {
    "menu": {
      "current_user_articles_link": "我的文章"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We'll transform this into a swapped JSON format where phrases map to their corresponding keys (using Spanish as the primary language in this example):

{
  "Mis articulos": "menu.current_user_articles_link"
}
Enter fullscreen mode Exit fullscreen mode

To achieve this transformation, we'll create a utility function that swaps keys and values in the JSON files. Here's the implementation using TypeScript and Deno:

import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const CONSTANTS = {
  DIRECTORY_PATH: 'app/public/locale/es', // Path to locale language files
  SWAPPED_FILE_PATH: 'e2e/fixtures/i18n/es_swapped.json', // Output path for swapped JSON
  FILE_ENCODING: 'utf8',
} as const;

interface TranslationObject {
  [key: string]: string | TranslationObject;
}
type SwappedTranslations = Record<string, Record<string, string>>;

function swapKeysAndValues(obj: TranslationObject): Record<string, string> {
  const swappedObject: Record<string, string> = {};

  function traverseObj(value: unknown, path = ''): void {
    if (value && typeof value === 'object') {
      for (const [key, val] of Object.entries(value)) {
        traverseObj(val, path ? `${path}.${key}` : key);
      }
    } else if (typeof value === 'string') {
      swappedObject[value] = path;
    }
  }

  traverseObj(obj);
  return swappedObject;
}

const JSON_EXTENSION_REGEX = /\.json$/;

async function buildJsonWithNamespaces(): Promise<void> {
  try {
    const files = await readdir(CONSTANTS.DIRECTORY_PATH);
    const result: SwappedTranslations = {};

    for (const file of files) {
      const filePath = join(CONSTANTS.DIRECTORY_PATH, file);
      const fileContent = await readFile(filePath, CONSTANTS.FILE_ENCODING);
      const parsedFileContent = JSON.parse(fileContent) as TranslationObject;
      const swappedContent = swapKeysAndValues(parsedFileContent);
      const key = file.replace(JSON_EXTENSION_REGEX, '');

      result[key] = swappedContent;
    }

    await writeFile(
      CONSTANTS.SWAPPED_FILE_PATH,
      JSON.stringify(result, null, 2),
      CONSTANTS.FILE_ENCODING,
    );

    console.info('✅ Successfully generated swapped translations');
  } catch (error) {
    console.error(
      '❌ Failed to generate swapped translations:',
      error instanceof Error ? error.message : error,
    );
    process.exit(1);
  }
}

console.info('🔄 Converting locale to swapped JSON...');
buildJsonWithNamespaces();
Enter fullscreen mode Exit fullscreen mode

Run this script using: deno run --allow-read --allow-write path_to_swap.ts

Setting Up i18n for Playwright

Now let's create a fixture for i18n in Playwright. This implementation is inspired by the playwright-i18next-fixture library, but with custom modifications for better control:

import * as fs from "node:fs";
import path from "node:path";
import Backend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import { test as base } from "@playwright/test";
import { createInstance, type i18n, type TFunction } from "i18next";

const CONFIG = {
  TRANSLATIONS_PATH: "translations_path_to_files/or_api_endpoint",
  SWAPPED_TRANSLATIONS_PATH: "e2e/fixtures/i18n/es_swapped.json",
  LOCAL_STORAGE_KEY: "locale",
  SUPPORTED_LANGUAGES: ["es", "en", "zh"] as const,
  DEFAULT_LANGUAGE: "es",
  NAMESPACES: ["shared", "menu"] as const,
  DEFAULT_NS: "shared",
} as const;

const data = fs.existsSync(CONFIG.SWAPPED_TRANSLATIONS_PATH)
  ? JSON.parse(fs.readFileSync(CONFIG.SWAPPED_TRANSLATIONS_PATH, "utf8"))
  : {};

export function findTranslationByKey(
  key: string,
  namespace: string,
): string | undefined {
  const value = data[namespace][key];
  if (value && typeof value === "string") return value;

  throw new Error(
    `🔴 Translation not found for the namespace "${namespace}" and key "${key}"`,
  );
}

type SupportedLanguage = (typeof CONFIG.SUPPORTED_LANGUAGES)[number];
type Namespace = (typeof CONFIG.NAMESPACES)[number];

export const i18nOptions = {
  plugins: [Backend, initReactI18next],
  options: {
    lng: CONFIG.DEFAULT_LANGUAGE,
    load: "languageOnly",
    ns: CONFIG.NAMESPACES,
    defaultNS: CONFIG.DEFAULT_NS,
    supportedLngs: CONFIG.SUPPORTED_LANGUAGES,
    backend: {
      allowMultiLoading: true,
      loadPath: CONFIG.TRANSLATIONS_PATH,
    },
    react: {
      useSuspense: true,
    },
  },
} as const;

let storedI18n: i18n | undefined;

async function initI18n({
  plugins,
  options,
}: typeof i18nOptions): Promise<i18n> {
  if (!storedI18n?.isInitialized) {
    const i18n = plugins.reduce(
      (i18n, plugin) => i18n.use(plugin),
      createInstance(),
    );
    await i18n.init(options);
    storedI18n = i18n;
    return i18n;
  }
  return storedI18n;
}

export type Tkey = (key: string, namespace?: Namespace) => string;

interface I18nFixtures {
  i18n: i18n;
  t: TFunction;
  tkey: Tkey;
}

/*
  Fixture for i18n functionality. 
  It initializes the i18next instance and checks the language setting
  in the storageState created by Playwright. 
  Similar technique was used in my RBAC example.
  For more details, see my article about authorization testing 
  (https://dev.to/a-dev/testing-with-playwright-how-to-make-authorization-less-painful-and-more-readable-3344)
  and the official Playwright documentation
  (https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state).
*/

export const i18nFixture = base.extend<I18nFixtures>({
  i18n: async ({ storageState }, use) => {
    const i18nInstance = await initI18n(i18nOptions);

    if (storageState) {
      try {
        const data = JSON.parse(
          fs.readFileSync(path.resolve(storageState as string), "utf8"),
        );

        // This example uses localStorage to set the language.
        const localStorage = data?.origins?.[0]?.localStorage;
        const language = localStorage?.find(
          (i) => i.name === CONFIG.LOCAL_STORAGE_KEY,
        )?.value as SupportedLanguage | undefined;

        if (!language) {
          throw new Error(
            `No language setting found in localStorage (key: ${CONFIG.LOCAL_STORAGE_KEY})`,
          );
        }

        if (!CONFIG.SUPPORTED_LANGUAGES.includes(language)) {
          throw new Error(
            `Unsupported language: ${language}. Supported languages: ${CONFIG.SUPPORTED_LANGUAGES.join(", ")}`,
          );
        }

        // Change language if different from current
        if (i18nInstance.language !== language) {
          await i18nInstance.changeLanguage(language);
        }
      } catch (error) {
        throw new Error(`Failed to process storage state: ${error.message}`);
      }
    }

    await use(i18nInstance);
  },
  tkey: async ({ t }, use) => {
    await use((str?: string, namespace = "shared"): string => {
      if (!str) return "⭕ Error: no translation";
      const tkey = findTranslationByKey(str, namespace);
      return t(`${namespace}:${tkey}`);
    });
  },
  t: async ({ i18n }, use) => {
  // The same t function for usual translation keys
    await use(i18n.t);
  },
});
Enter fullscreen mode Exit fullscreen mode

Finally, we can use the i18nFixture in our Playwright tests to handle translations and language settings. To learn more about working with fixtures, check out the official Playwright documentation. I recommend creating an index.ts file that exports all fixtures, which can then be imported into your test files.

import { mergeTests } from '@playwright/test';
import { i18nFixture } from './fixtures/i18n';

const test = mergeTests({
  ...i18nFixture,
});

export { test };
Enter fullscreen mode Exit fullscreen mode

Wish you happy testing with Playwright and i18next! 🚀
If you have any questions or suggestions don't hesitate to comment below.

Top comments (0)