i18n Testing & Validation

Test Setup

Testing Configuration

// vitest.config.ts or jest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/__tests__/setup.ts'],
    globals: true,
  }
});

Test Setup File

// src/__tests__/setup.ts
import i18n from '../i18n';
import { beforeAll, afterEach } from 'vitest';

beforeAll(async () => {
  // Initialize i18n for tests
  await i18n.init({
    lng: 'en',
    fallbackLng: 'en',
    interpolation: { escapeValue: false }
  });
});

afterEach(() => {
  // Reset to English after each test
  i18n.changeLanguage('en');
});

Importing i18n in Tests

import { useTranslation } from 'react-i18next';
import { renderHook } from '@testing-library/react';
import i18n from '../../i18n';

// Tests can use i18n directly

Testing Translation Keys

Test 1: Verify Key Existence

import { describe, it, expect } from 'vitest';
import i18n from '../../i18n';

describe('Translation Keys Exist', () => {
  it('should have header.title in English', () => {
    const translation = i18n.t('header.title');
    expect(translation).not.toBe('header.title');
    // If key doesn't exist, i18n returns the key itself
  });
  
  it('should have header.title in German', async () => {
    await i18n.changeLanguage('de');
    const translation = i18n.t('header.title');
    expect(translation).not.toBe('header.title');
  });
});

Test 2: Verify Key Consistency

describe('Translation Consistency', () => {
  it('English and German should have same keys', () => {
    const enKeys = Object.keys(flattenObject(enTranslations));
    const deKeys = Object.keys(flattenObject(deTranslations));
    
    const missingInDe = enKeys.filter(k => !deKeys.includes(k));
    const extraInDe = deKeys.filter(k => !enKeys.includes(k));
    
    expect(missingInDe).toHaveLength(0);
    expect(extraInDe).toHaveLength(0);
  });
});

// Helper to flatten nested objects
function flattenObject(obj, prefix = '') {
  let result = {};
  for (let key in obj) {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    if (typeof obj[key] === 'object') {
      result = { ...result, ...flattenObject(obj[key], fullKey) };
    } else {
      result[fullKey] = obj[key];
    }
  }
  return result;
}

Testing Components with Translations

Test 3: Component Rendering with Translation

import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { describe, it, expect } from 'vitest';
import i18n from '../../i18n';
import { Header } from '../../components/Header';

describe('Header Component', () => {
  const renderWithI18n = (component) => {
    return render(
      <I18nextProvider i18n={i18n}>
        {component}
      </I18nextProvider>
    );
  };
  
  it('should render with English translations', () => {
    renderWithI18n(<Header />);
    const title = screen.getByText('Dashboard');  // English
    expect(title).toBeInTheDocument();
  });
  
  it('should render with German translations after language change', async () => {
    renderWithI18n(<Header />);
    
    // Change language
    await i18n.changeLanguage('de');
    
    const title = screen.getByText('Übersicht');  // German
    expect(title).toBeInTheDocument();
  });
});

Test 4: useTranslation Hook

import { renderHook, act } from '@testing-library/react';
import { useTranslation } from 'react-i18next';
import i18n from '../../i18n';

describe('useTranslation Hook', () => {
  it('should return translation function', () => {
    const { result } = renderHook(() => useTranslation());
    const { t } = result.current;
    
    expect(typeof t).toBe('function');
    expect(t('header.title')).toBe('Dashboard');
  });
  
  it('should update translation when language changes', async () => {
    const { result, rerender } = renderHook(() => useTranslation());
    
    expect(result.current.t('header.title')).toBe('Dashboard');
    
    await act(async () => {
      await i18n.changeLanguage('de');
    });
    
    rerender();
    expect(result.current.t('header.title')).toBe('Übersicht');
  });
  
  it('should return i18n instance', () => {
    const { result } = renderHook(() => useTranslation());
    const { i18n: i18nInstance } = result.current;
    
    expect(i18nInstance).toBeDefined();
    expect(i18nInstance.language).toBe('en');
  });
});

Testing Interpolation

Test 5: Variable Interpolation

describe('Translation Interpolation', () => {
  it('should interpolate variables correctly', () => {
    const result = i18n.t('greeting', {
      name: 'John',
      app: 'StockEase'
    });
    
    expect(result).toContain('John');
    expect(result).toContain('StockEase');
  });
  
  it('should handle missing variables gracefully', () => {
    // If variables are missing, they appear in output
    const result = i18n.t('greeting', { name: 'John' });
    
    // Check that partial interpolation happened
    expect(result).toContain('John');
  });
});

Testing Language Switching

Test 6: Language Switcher Component

import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';

describe('Language Switcher', () => {
  it('should change language when button clicked', async () => {
    const { container } = render(
      <I18nextProvider i18n={i18n}>
        <LanguageSwitcher />
      </I18nextProvider>
    );
    
    // Initial language: English
    expect(i18n.language).toBe('en');
    
    // Click German button
    const germanButton = screen.getByText('Deutsch');
    fireEvent.click(germanButton);
    
    // Language should change
    expect(i18n.language).toBe('de');
  });
  
  it('should update component text when language changes', async () => {
    render(
      <I18nextProvider i18n={i18n}>
        <Header />
      </I18nextProvider>
    );
    
    // English version
    expect(screen.getByText('Dashboard')).toBeInTheDocument();
    
    // Change language
    await act(async () => {
      await i18n.changeLanguage('de');
    });
    
    // German version
    expect(screen.getByText('Übersicht')).toBeInTheDocument();
  });
});

Testing localStorage Persistence

Test 7: Language Persistence

import { beforeEach, afterEach } from 'vitest';

describe('Language Persistence', () => {
  beforeEach(() => {
    localStorage.clear();
  });
  
  afterEach(() => {
    localStorage.clear();
  });
  
  it('should save language to localStorage', async () => {
    await i18n.changeLanguage('de');
    
    const saved = localStorage.getItem('i18nextLng');
    expect(saved).toBe('de');
  });
  
  it('should restore language from localStorage on init', async () => {
    // Simulate previous session
    localStorage.setItem('i18nextLng', 'de');
    
    // Reinitialize i18n
    await i18n.init({
      lng: localStorage.getItem('i18nextLng') || 'en'
    });
    
    expect(i18n.language).toBe('de');
  });
});

Testing Namespace Switching

Test 8: Multiple Namespaces

describe('i18n Namespaces', () => {
  it('should access default translation namespace', () => {
    const { result } = renderHook(() => useTranslation());
    const { t } = result.current;
    
    expect(t('header.title')).toBe('Dashboard');
  });
  
  it('should access help namespace', () => {
    const { result } = renderHook(() => useTranslation('help'));
    const { t } = result.current;
    
    const helpText = t('modal.getting_started');
    expect(helpText).toBeDefined();
    expect(helpText).not.toBe('modal.getting_started');
  });
  
  it('should use correct namespace in components', () => {
    const { container } = render(
      <I18nextProvider i18n={i18n}>
        <HelpModal isOpen={true} />
      </I18nextProvider>
    );
    
    // Help content should use 'help' namespace
    const helpTitle = screen.getByText(i18n.t('modal.title', {
      ns: 'help'
    }));
    
    expect(helpTitle).toBeInTheDocument();
  });
});

End-to-End Testing Example

Test 9: Complete User Flow

describe('i18n Complete User Flow', () => {
  it('should handle complete language switch flow', async () => {
    const { container } = render(
      <I18nextProvider i18n={i18n}>
        <App />
      </I18nextProvider>
    );
    
    // Step 1: Verify initial English content
    expect(screen.getByText('Welcome')).toBeInTheDocument();
    
    // Step 2: Click language switcher
    const langButton = screen.getByRole('button', { name: /^🇬🇧/ });
    fireEvent.click(langButton);
    
    // Step 3: Select German
    const germanOption = screen.getByText('Deutsch');
    fireEvent.click(germanOption);
    
    // Step 4: Verify German content
    await waitFor(() => {
      expect(screen.getByText('Willkommen')).toBeInTheDocument();
    });
    
    // Step 5: Verify localStorage persisted
    expect(localStorage.getItem('i18nextLng')).toBe('de');
    
    // Step 6: Refresh and verify language persists
    // (In real e2e test with page reload)
  });
});

Testing Best Practices

✅ DO:

// Test key existence and correctness
it('should have valid translation keys', () => {
  expect(i18n.t('header.title')).not.toBe('header.title');
});

// Test multiple languages
['en', 'de'].forEach(lang => {
  it(`should support ${lang}`, () => {
    i18n.changeLanguage(lang);
    expect(i18n.language).toBe(lang);
  });
});

// Use I18nextProvider in render
const { getByText } = render(
  <I18nextProvider i18n={i18n}>
    <Component />
  </I18nextProvider>
);

// Test interpolation
it('should interpolate variables', () => {
  const result = i18n.t('greeting', { name: 'John' });
  expect(result).toContain('John');
});

❌ DON'T:

// Don't hardcode translations in tests
expect(screen.getByText('Dashboard')).toBeInTheDocument();

// Don't skip language setup
render(<Component />);  // Missing I18nextProvider

// Don't ignore namespace switching
t('key');  // Unclear which namespace

// Don't forget to clean up
// Missing cleanup after each test

Common Test Patterns

Pattern 1: Snapshot Testing

it('should render correctly in English', () => {
  i18n.changeLanguage('en');
  const { container } = render(
    <I18nextProvider i18n={i18n}>
      <Header />
    </I18nextProvider>
  );
  
  expect(container.firstChild).toMatchSnapshot();
});

Pattern 2: Parametrized Testing

describe.each([
  ['en', 'Dashboard', 'Welcome'],
  ['de', 'Übersicht', 'Willkommen']
])('Language: %s', (lang, dashTitle, welcome) => {
  it(`should show correct translations`, async () => {
    await i18n.changeLanguage(lang);
    expect(i18n.t('header.title')).toBe(dashTitle);
    expect(i18n.t('common.welcome')).toBe(welcome);
  });
});

Pattern 3: Mock Translation Responses

vi.mock('react-i18next', () => ({
  useTranslation: () => ({
    t: (key) => `mock-${key}`,
    i18n: { language: 'en', changeLanguage: vi.fn() }
  })
}));

it('should work with mocked translations', () => {
  const result = t('header.title');
  expect(result).toBe('mock-header.title');
});


Last Updated: November 2025