Test components that translate
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.
The fixture
Section titled “The fixture”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.
An instance per test
Section titled “An instance per test”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();});<script setup lang="ts">import { useI18n } from '@comvi/vue';
defineProps<{ name: string }>();const { t } = useI18n();</script>
<template> <h1>{{ t('hello', { name }) }}</h1></template>import { expect, test } from 'vitest';import { mount } from '@vue/test-utils';import { createI18n } from '@comvi/vue';import Greeting from './Greeting.vue';
test('renders the greeting in the active locale', async () => { const i18n = createI18n({ locale: 'de', messages: { de: { hello: 'Hallo, {name}!' } } }); await i18n.init();
const wrapper = mount(Greeting, { props: { name: 'Alice' }, global: { plugins: [i18n] } });
expect(wrapper.text()).toBe('Hallo, Alice!');});<script lang="ts"> import { useI18n } from '@comvi/svelte';
const { name }: { name: string } = $props(); const { t } = useI18n();</script>
<h1>{$t('hello', { name })}</h1><script lang="ts"> import { setI18nContext, type I18n } from '@comvi/svelte'; import Greeting from './Greeting.svelte';
const { i18n, name }: { i18n: I18n; name: string } = $props();
setI18nContext(i18n, { autoInit: false });</script>
<Greeting {name} />import { expect, test } from 'vitest';import { render, screen } from '@testing-library/svelte';import { createI18n } from '@comvi/svelte';import Root from './Root.svelte';
test('renders the greeting in the active locale', async () => { const i18n = createI18n({ locale: 'de', messages: { de: { hello: 'Hallo, {name}!' } } }); await i18n.init();
render(Root, { i18n, name: 'Alice' });
expect(await screen.findByText('Hallo, Alice!')).toBeTruthy();});import { expect, test } from 'vitest';import { render } from 'solid-js/web';import { createI18n, I18nProvider, useI18n } from '@comvi/solid';
function Greeting(props: { name: string }) { const { t } = useI18n(); return <h1>{t('hello', { name: props.name })}</h1>;}
test('renders the greeting in the active locale', async () => { const i18n = createI18n({ locale: 'de', messages: { de: { hello: 'Hallo, {name}!' } } }); await i18n.init();
const container = document.createElement('div'); render( () => ( <I18nProvider i18n={i18n} autoInit={false}> <Greeting name="Alice" /> </I18nProvider> ), container, );
expect(container.textContent).toBe('Hallo, Alice!');});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.
One instance in a setup file
Section titled “One instance in a setup file”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.
Mock the hook, sparingly
Section titled “Mock the hook, sparingly”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');});import { expect, test, vi } from 'vitest';import { mount } from '@vue/test-utils';import { defineComponent, h } from 'vue';import { useI18n } from '@comvi/vue';
const LanguageSwitcher = defineComponent(() => { const { locale, setLocale } = useI18n(); return () => h('select', { 'aria-label': 'Language', value: locale.value, onChange: (e: Event) => void setLocale((e.target as HTMLSelectElement).value) }, [ h('option', { value: 'en' }, 'English'), h('option', { value: 'de' }, 'Deutsch'), ]);});
const setLocale = vi.fn();
vi.mock('@comvi/vue', async (importOriginal) => { const actual = await importOriginal<typeof import('@comvi/vue')>(); return { ...actual, useI18n: () => ({ locale: { value: 'en' }, setLocale, t: (key: string) => key }), };});
test('switches locale on selection', async () => { const wrapper = mount(LanguageSwitcher); await wrapper.get('select').setValue('de'); expect(setLocale).toHaveBeenCalledWith('de');});<script lang="ts"> import { useI18n } from '@comvi/svelte';
const { locale, setLocale } = useI18n();</script>
<select aria-label="Language" value={$locale} onchange={(e) => void setLocale(e.currentTarget.value)}> <option value="en">English</option> <option value="de">Deutsch</option></select>import { expect, test, vi } from 'vitest';import { fireEvent, render, screen } from '@testing-library/svelte';import { readable } from 'svelte/store';import LanguageSwitcher from './LanguageSwitcher.svelte';
const setLocale = vi.fn();
vi.mock('@comvi/svelte', async (importOriginal) => { const actual = await importOriginal<typeof import('@comvi/svelte')>(); return { ...actual, useI18n: () => ({ locale: readable('en'), setLocale, t: readable((key: string) => key) }), };});
test('switches locale on selection', async () => { render(LanguageSwitcher); await fireEvent.change(screen.getByRole('combobox'), { target: { value: 'de' } }); expect(setLocale).toHaveBeenCalledWith('de');});import { expect, test, vi } from 'vitest';import { render } from 'solid-js/web';import { useI18n } from '@comvi/solid';
function LanguageSwitcher() { const { locale, setLocale } = useI18n(); return ( <select aria-label="Language" value={locale()} onChange={(e) => void setLocale(e.currentTarget.value)}> <option value="en">English</option> <option value="de">Deutsch</option> </select> );}
const setLocale = vi.fn();
vi.mock('@comvi/solid', async (importOriginal) => { const actual = await importOriginal<typeof import('@comvi/solid')>(); return { ...actual, useI18n: () => ({ locale: () => 'en', setLocale, t: (key: string) => key }), };});
test('switches locale on selection', () => { const container = document.createElement('div'); render(() => <LanguageSwitcher />, container); const select = container.querySelector('select')!; select.value = 'de'; select.dispatchEvent(new Event('change', { bubbles: true })); 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.
Keep plugins out of the run
Section titled “Keep plugins out of the run”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.
End to end with Playwright
Section titled “End to end with Playwright”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.
The build a test resolves
Section titled “The build a test resolves”@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.
How it works
Section titled “How it works”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.
Read next
Section titled “Read next”- Type-check translation keys: catch a missing key at compile time.
- Handle missing keys and errors: fail tests loudly on missing keys.
- Load translations on demand: what a composed loader adds.