Skip to content
Comvi

Test components that translate

View .md

With the @comvi/<framework> binding and Vitest installed, test against a real i18n instance built from an inline catalog, the same way the Quick start builds one. No network, no mocks, the production code path: a typo in a key fails the test instead of rendering a placeholder.

src/test/i18n.ts
import { createI18n } from '@comvi/core';
import { icuCompiler } from '@comvi/core/icu';
export function makeI18n(locale = 'en') {
return createI18n({
locale,
fallbackLocale: 'en',
compiler: icuCompiler,
messages: {
en: {
hello: 'Hello, {name}!',
cart: { items: '{count, plural, one {# item} other {# items}}' },
},
de: {
hello: 'Hallo, {name}!',
cart: { items: '{count, plural, one {# Artikel} other {# Artikel}}' },
},
},
});
}

Every call returns an independent instance with no shared state. The nested cart object resolves as cart.items with no extra call. compiler: icuCompiler is there for the plural; without it the message throws at ingestion in development. A default JSON import is the object itself, so messages: { en } works, while a namespace import or a dynamic import() needs .default.

src/Greeting.test.tsx
import { expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import { I18nProvider, useI18n } from '@comvi/react';
import { makeI18n } from './test/i18n';
function Greeting({ name }: { name: string }) {
const { t } = useI18n();
return <h1>{t('hello', { name })}</h1>;
}
test('renders the greeting in the active locale', async () => {
const i18n = makeI18n('de');
await i18n.init();
render(
<I18nProvider i18n={i18n}>
<Greeting name="Alice" />
</I18nProvider>,
);
expect(await screen.findByText('Hallo, Alice!')).toBeTruthy();
});

The component reads the i18n instance from its context, so the test provides one: a provider, the Vue plugin, or a root component that sets the context. Build the instance inside the test when the test mutates it. Assert the translated string, not the key: a missing key renders as itself, so toBe('hello') passes on an empty catalog. On a bare instance with no binding, assert i18n.t() directly. Nuxt has no tab here: its components depend on auto-imports and run under Nuxt’s own test runner.

vitest.setup.ts
import { afterEach, beforeAll, vi } from 'vitest';
import { makeI18n } from './src/test/i18n';
process.env.TZ = 'UTC';
export const i18n = makeI18n();
beforeAll(async () => {
await i18n.init();
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z'), toFake: ['Date'] });
});
afterEach(async () => {
await i18n.setLocale('en');
});

Register the file under test.setupFiles in vitest.config.ts. Reset the locale with setLocale() after each test, not with the locale setter: the setter is fire-and-forget and reports a failure as a loadError event, which in a test is a silent pass. Pin TZ, an explicit locale and fake timers, or snapshots with dates and numbers drift with the runner’s ICU data.

src/LanguageSwitcher.test.tsx
import { expect, test, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { useI18n } from '@comvi/react';
function LanguageSwitcher() {
const { locale, setLocale } = useI18n();
return (
<select aria-label="Language" value={locale} onChange={(e) => void setLocale(e.target.value)}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
);
}
const setLocale = vi.fn();
vi.mock('@comvi/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@comvi/react')>();
return {
...actual,
useI18n: () => ({ locale: 'en', setLocale, t: (key: string) => key }),
};
});
test('switches locale on selection', () => {
render(<LanguageSwitcher />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'de' } });
expect(setLocale).toHaveBeenCalledWith('de');
});

A mock is right when the assertion is about behaviour, such as setLocale being called. It is wrong when the assertion is about rendered copy, because a t that returns its key catches none of the mistakes a real instance does.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { icuCompiler } from '@comvi/core/icu';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
const base = createI18n({ locale: 'en', fallbackLocale: 'en', compiler: icuCompiler });
// import.meta.env.TEST is true under Vitest; other runners read process.env.NODE_ENV.
export const i18n = import.meta.env.TEST
? base
: base.use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));
await i18n.init();

A plugin you do not compose never runs, so the fetch loader never reaches the CDN and the editor never mounts; its module stays out of the graph only when the import is dynamic or behind the same flag. The two branches have different types, so i18n is their union and code that reaches for a loader member does not compile. A component that calls useI18nLoader() throws on the test instance; keep it behind the same flag, or give the test instance .use(attachLoader). A plugin composed after init() does not run until the next init() and logs one development warning.

e2e/i18n.spec.ts
import { expect, test } from '@playwright/test';
test('German homepage', async ({ page }) => {
await page.goto('/de');
await expect(page.getByRole('heading', { level: 1 })).toContainText('Willkommen');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
});
test('the switcher updates the URL', async ({ page }) => {
await page.goto('/en/dashboard');
await page.getByRole('combobox', { name: 'Language' }).selectOption('de');
await expect(page).toHaveURL('/de/dashboard');
});

Navigate to the locale prefix and assert the rendered DOM; nothing is mocked. For a client-only app, seed what the default localeDetector() reads first: page.addInitScript() writing the localStorage key i18n_locale.

@comvi/core ships two builds behind export conditions: development, with the full error texts and the warnings, and the default one, the production build with E_… codes. Vitest resolves the development build unless NODE_ENV is production. Node’s own test runner resolves the default, so an assertion on an error text sees E_USE_NOT_INSTALLER instead of the sentence; run it as node --conditions=development --test to get the development build.

A component under test needs exactly one thing from the i18n instance: a catalog for the locale it renders in. createI18n({ messages }) ingests that catalog when the instance is built, so by the time init() resolves every t() call hits the cache. A fetch never happens because no loader is composed, so there is nothing to stub and nothing to wait for.

The same code path runs in production. The compiler parses the message, the parameters are interpolated, the fallback chain is walked when a key is missing. A test that passes here passes for the same reason the app works, and a test that fails names a real key or a real message. Mocking useI18n() throws that away: the component renders whatever the mock returns, and a renamed key or a missing plural branch goes unnoticed until a user sees it.

Isolation comes from construction rather than teardown. Each createI18n() call owns its locale, its cache and its listeners, so two tests cannot see each other’s setLocale(). The shared instance in vitest.setup.ts trades that isolation for one init() per file, which is why it resets the locale after every test and pins the clock and the time zone: Intl reads both, and a snapshot taken at midnight UTC on a machine in another zone would otherwise differ from CI.