Testing Vue i18n with Vitest
Vue applications using vue-i18n face a common testing problem: the i18n plugin must be configured and installed before any component that uses $t() or useI18n() can render. Without the right setup, tests fail with "inject() can only be used inside setup()" or "Cannot read properties of undefined" errors. This guide gives you a clean, reusable setup for testing Vue i18n with Vitest and Vue Test Utils.
Installing Dependencies
npm install vue-i18n@9
npm install --save-dev vitest @vue/test-utils jsdomVitest configuration in vite.config.ts:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
});Setting Up the i18n Plugin for Tests
The key to clean vue-i18n tests is a factory function that creates a fresh i18n instance for each test, preventing locale state from leaking between tests.
// src/test-utils/i18n.ts
import { createI18n } from 'vue-i18n';
import type { I18nOptions } from 'vue-i18n';
import enMessages from '../locales/en.json';
import deMessages from '../locales/de.json';
import frMessages from '../locales/fr.json';
const messages = {
en: enMessages,
de: deMessages,
fr: frMessages,
};
export function createTestI18n(options: Partial<I18nOptions> = {}) {
return createI18n({
legacy: false, // Use Composition API mode
locale: 'en',
fallbackLocale: 'en',
messages,
...options,
});
}A mount helper that includes the i18n plugin:
// src/test-utils/mountWithI18n.ts
import { mount, type MountingOptions } from '@vue/test-utils';
import { createTestI18n } from './i18n';
import type { Component } from 'vue';
export function mountWithI18n(
component: Component,
options: MountingOptions<any> = {},
i18nOptions: { locale?: string } = {}
) {
const i18n = createTestI18n({ locale: i18nOptions.locale ?? 'en' });
return mount(component, {
...options,
global: {
...options.global,
plugins: [i18n, ...(options.global?.plugins ?? [])],
},
});
}
export { createTestI18n };Basic Component Tests
Given a simple component using the Composition API:
<!-- src/components/Greeting.vue -->
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({ name: String });
const { t } = useI18n();
</script>
<template>
<div>
<h1>{{ t('greeting.title', { name }) }}</h1>
<p>{{ t('greeting.subtitle') }}</p>
</div>
</template>// src/locales/en.json
{
"greeting": {
"title": "Hello, {name}!",
"subtitle": "Welcome to our platform."
}
}// src/components/Greeting.test.ts
import { describe, it, expect } from 'vitest';
import { mountWithI18n } from '../test-utils/mountWithI18n';
import Greeting from './Greeting.vue';
describe('Greeting', () => {
it('renders translated title with name', () => {
const wrapper = mountWithI18n(Greeting, {
props: { name: 'Alice' },
});
expect(wrapper.find('h1').text()).toBe('Hello, Alice!');
});
it('renders translated subtitle', () => {
const wrapper = mountWithI18n(Greeting, {
props: { name: 'Alice' },
});
expect(wrapper.find('p').text()).toBe('Welcome to our platform.');
});
it('renders in German locale', () => {
const wrapper = mountWithI18n(
Greeting,
{ props: { name: 'Alice' } },
{ locale: 'de' }
);
expect(wrapper.find('h1').text()).toBe('Hallo, Alice!');
});
});Testing Pluralization
vue-i18n uses named plural forms with {count} interpolation:
// src/locales/en.json
{
"notifications": {
"count": "No notifications | One notification | {count} notifications"
}
}The pipe-separated format maps to: zero | one | other.
<!-- src/components/NotificationBadge.vue -->
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({ count: Number });
const { t } = useI18n();
</script>
<template>
<span :aria-label="t('notifications.count', props.count, { count: props.count })">
{{ count }}
</span>
</template>// src/components/NotificationBadge.test.ts
import { describe, it, expect } from 'vitest';
import { mountWithI18n } from '../test-utils/mountWithI18n';
import NotificationBadge from './NotificationBadge.vue';
describe('NotificationBadge pluralization', () => {
it.each([
[0, 'No notifications'],
[1, 'One notification'],
[2, '2 notifications'],
[99, '99 notifications'],
])('count=%i renders "%s"', (count, expected) => {
const wrapper = mountWithI18n(NotificationBadge, { props: { count } });
expect(wrapper.find('span').attributes('aria-label')).toBe(expected);
});
});Snapshot Testing
Snapshot tests in vue-i18n can be useful for catching unintended template changes, but they should snapshot the rendered text, not the full DOM tree:
// src/components/ProductCard.test.ts
import { describe, it, expect } from 'vitest';
import { mountWithI18n } from '../test-utils/mountWithI18n';
import ProductCard from './ProductCard.vue';
describe('ProductCard snapshots', () => {
it('matches English snapshot', () => {
const wrapper = mountWithI18n(ProductCard, {
props: { name: 'Widget', price: 29.99, currency: 'USD' },
});
expect(wrapper.text()).toMatchSnapshot();
});
it('matches German snapshot', () => {
const wrapper = mountWithI18n(
ProductCard,
{ props: { name: 'Widget', price: 29.99, currency: 'EUR' } },
{ locale: 'de' }
);
expect(wrapper.text()).toMatchSnapshot();
});
});Snapshotting wrapper.text() instead of wrapper.html() keeps snapshots readable and insulates them from styling changes.
Testing Locale Switching at Runtime
When using vue-i18n in global mode, locale switching updates all components simultaneously. Test this with a wrapper that exposes the locale:
// src/components/LocaleSwitcher.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestI18n } from '../test-utils/i18n';
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
const TestApp = defineComponent({
setup() {
const { t, locale } = useI18n();
return { t, locale };
},
template: `
<div>
<button @click="locale = 'de'">Switch to DE</button>
<p>{{ t('nav.home') }}</p>
</div>
`,
});
describe('locale switching', () => {
it('updates translation when locale changes', async () => {
const i18n = createTestI18n({ locale: 'en' });
const wrapper = mount(TestApp, {
global: { plugins: [i18n] },
});
expect(wrapper.find('p').text()).toBe('Home');
await wrapper.find('button').trigger('click');
expect(wrapper.find('p').text()).toBe('Startseite');
});
});Testing the useI18n Composable Directly
For utility functions that wrap useI18n, test the composable output directly:
// src/composables/useFormattedDate.ts
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
export function useFormattedDate(date: Date) {
const { d } = useI18n();
return computed(() => d(date, 'long'));
}// src/composables/useFormattedDate.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { createTestI18n } from '../test-utils/i18n';
import { useFormattedDate } from './useFormattedDate';
// Wrap composable in a component to provide the i18n context
function createWrapper(locale: string) {
const date = new Date('2026-03-15T00:00:00Z');
const TestComp = defineComponent({
setup() {
return { formatted: useFormattedDate(date) };
},
template: '<span>{{ formatted }}</span>',
});
const i18n = createTestI18n({ locale });
return mount(TestComp, { global: { plugins: [i18n] } });
}
describe('useFormattedDate', () => {
it('formats in English long format', () => {
const wrapper = createWrapper('en');
expect(wrapper.text()).toContain('March');
expect(wrapper.text()).toContain('2026');
});
it('formats in German long format', () => {
const wrapper = createWrapper('de');
expect(wrapper.text()).toContain('März');
expect(wrapper.text()).toContain('2026');
});
});Translation File Coverage Test
// src/__tests__/i18n-coverage.test.ts
import { describe, it, expect } from 'vitest';
import enMessages from '../locales/en.json';
import deMessages from '../locales/de.json';
import frMessages from '../locales/fr.json';
function flattenKeys(obj: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const full = prefix ? `${prefix}.${key}` : key;
return typeof value === 'object' && value !== null
? flattenKeys(value as Record<string, unknown>, full)
: [full];
});
}
const enKeys = flattenKeys(enMessages);
describe.each([
['de', deMessages],
['fr', frMessages],
])('%s locale coverage', (locale, messages) => {
const localeKeys = flattenKeys(messages as Record<string, unknown>);
it('has all English keys', () => {
const missing = enKeys.filter(k => !localeKeys.includes(k));
expect(missing, `Missing keys in ${locale}: ${missing.join(', ')}`).toHaveLength(0);
});
});Common Issues
"inject() can only be used inside setup()": This happens when useI18n() is called outside a component setup context. Make sure you're mounting via a test component, not calling the composable directly in the test body.
Locale not persisting: If you use vi.mock or module-level i18n instances, locale changes can leak between tests. Always create a fresh i18n instance per test using the factory pattern.
Async message loading: If you use dynamic imports for message files (() => import('./en.json')), the i18n instance won't have messages available synchronously. Use the messages option with static imports in tests to avoid await complexity.
Plural form index errors: vue-i18n's pipe-separated plural syntax (zero | one | other) must have the right number of forms for each locale. Russian requires four forms; providing three will produce incorrect output without a clear error.
Summary
Testing vue-i18n comes down to two things: a reusable factory function that creates fresh i18n instances per test, and a mount helper that installs the plugin with the right locale. From there, standard Vue Test Utils patterns apply—query by text content, trigger events, assert what changes. Add translation key coverage tests to catch drift early, and you have a complete i18n test suite.