# Test components that translate

Test components that call t() against a real i18n instance with an inline catalog, keep plugins out of the run, and drive locale routes with Playwright.

Canonical: https://comvi.io/docs/i18n/guides/testing

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](/docs/i18n/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

`src/test/i18n.ts`

```ts

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
**React**

`src/Greeting.test.tsx`

```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>,
    );

    expect(await screen.findByText('Hallo, Alice!')).toBeTruthy();
});
```

**Vue**

`src/Greeting.vue`

```vue
<script setup lang="ts">
import { useI18n } from '@comvi/vue';

defineProps<{ name: string }>();
const { t } = useI18n();
</script>

<template>
    <h1>{{ t('hello', { name }) }}</h1>
</template>
```

`src/Greeting.test.ts`

```ts
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!');
});
```

**Svelte**

`src/Greeting.svelte`

```svelte
<script lang="ts">
    import { useI18n } from '@comvi/svelte';

    const { name }: { name: string } = $props();
    const { t } = useI18n();
</script>

<h1>{$t('hello', { name })}</h1>
```

`src/Root.svelte`

```svelte
<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>

```

`src/Greeting.test.ts`

```ts
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();
});
```

**Solid**

`src/Greeting.test.tsx`

```tsx
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(
      () => (

      ),
      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

`vitest.setup.ts`

```ts

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
**React**

`src/LanguageSwitcher.test.tsx`

```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');
});
```

**Vue**

`src/LanguageSwitcher.test.ts`

```ts
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');
});
```

**Svelte**

`src/LanguageSwitcher.svelte`

```svelte
<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>
```

`src/LanguageSwitcher.test.ts`

```ts
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');
});
```

**Solid**

`src/LanguageSwitcher.test.tsx`

```tsx
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

`src/i18n.ts`

```ts

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

`e2e/i18n.spec.ts`

```ts

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

`@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

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

- [Type-check translation keys](/docs/i18n/type-safety): catch a missing key at compile time.
- [Handle missing keys and errors](/docs/i18n/error-handling): fail tests loudly on missing keys.
- [Load translations on demand](/docs/i18n/loading-translations): what a composed loader adds.
