@k8ordo/i18n

Testing

Which locale messages and getLocale() read depends on where the test runs. A test under Node sets it with locales.run, as a server would; a test in a browser sets it with the URL.

On this page

Tests under Node

With nothing naming a locale, a message returns the default locale’s text. To check another locale, call it inside locales.run.

messages/nav.test.ts
import { expect, it } from 'vitest';

import { locales } from '../i18n';
import * as nav from './nav';

it('renders in the default locale', () => {
  expect(nav.home()).toBe('ホーム');
});

it('renders in the locale run names', () => {
  expect(locales.run('en', () => nav.home())).toBe('Home');
});

it('keeps the locale across awaits', async () => {
  const text = await locales.run('en', async () => {
    await Promise.resolve();
    return nav.home();
  });
  expect(text).toBe('Home');
});

An async function keeps the locale across its awaits, and run calls running at the same time stay apart.

Test paramsSchema

When paramsSchema accepts a locale, it sets it for the rest of the async flow that called it. The framework gives each pattern a flow of its own; a test calling it directly gets none.

i18n.test.ts
import { expect, it } from 'vitest';

import { locales } from './i18n';

it('accepts only the listed locales', () => {
  const { validate } = locales.paramsSchema['~standard'];
  expect(
    locales.run('ja', () => validate({ locale: 'en' })),
  ).toStrictEqual({ value: { locale: 'en' } });
  expect(validate({ locale: 'fr' })).toMatchObject({
    issues: [{ path: ['locale'] }],
  });
});

So wrap a call that accepts in run. The locale it sets goes away once run returns, and does not leak into the tests after it. A locale outside the list is refused, and sets nothing.

Tests in a browser

In a real browser, such as Vitest’s browser mode, the URL is the locale. Change the pathname with history.replaceState, and put it back afterwards.

messages/nav.browser.test.ts
import { afterEach, expect, it } from 'vitest';

import { locales } from '../i18n';
import * as nav from './nav';

const initial = location.pathname;

afterEach(() => {
  history.replaceState(null, '', initial);
});

it('renders in the locale the URL names', () => {
  history.replaceState(null, '', '/en/cart');
  expect(locales.getLocale()).toBe('en');
  expect(nav.home()).toBe('Home');
});

run throws in a browser. An environment that defines document, such as jsdom or happy-dom, is a browser as far as this package is concerned, so set the locale with the URL there too.

Do not define a set in a test

Call defineLocales in a test, and every message after it reads that set: the last set defined is the one used.

Tests import the app’s locales. If some test defines a set of its own, define the app’s set again in beforeEach.

ts
import { defineLocales } from '@k8ordo/i18n';
import { beforeEach } from 'vitest';

import { locales } from '../i18n';

beforeEach(() => {
  defineLocales(locales.definitions, { default: locales.default });
});

Keep the type guarantees in tests

A message’s argument types, and the type error for a missing locale, can be pinned with type tests.

messages/cart.test.ts
import { message } from '@k8ordo/i18n';
import { expectTypeOf, it } from 'vitest';

import * as cart from './cart';

it('types the arguments of a message', () => {
  expectTypeOf(cart.added).parameters.toEqualTypeOf<
    [name: string]
  >();
});

it('refuses a message without every locale', () => {
  // @ts-expect-error
  message({ ja: '保存' });
});

The declaration under @ts-expect-error passes as long as the missing locale is a type error. Should Register stop applying, so that it compiles, the type check fails.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2