Testing Locale Switching: Language Changes Without Page Reload

Testing Locale Switching: Language Changes Without Page Reload

Dynamic locale switching — changing the application language without a full page reload — is one of those features that looks simple in a demo and turns out to have a surprisingly large surface area of edge cases. Every visible element must update. User preferences must persist across sessions. Fallback chains must degrade gracefully. RTL languages require layout inversions. Server-side rendered content must stay consistent with client-rendered content.

This guide covers the full test surface for locale switching in single-page applications, with Playwright test code for the important scenarios.

What Must Update When the Locale Changes

Before writing tests, enumerate everything that needs to change:

Category Examples
UI text Button labels, headings, form placeholders, error messages
Number formatting Decimal separator, thousands separator, currency symbol position
Date and time Date format order, 12h vs 24h, month names
Currency display Symbol position, decimal places
Images and icons Flags, culturally specific imagery, directional icons
Layout direction LTR → RTL requires mirroring the entire layout
HTML lang attribute Must update on <html> for accessibility and search engines
dir attribute Must update on <html> for RTL locales
URL If locale is in the URL path or query parameter
<title> Page title in the browser tab
aria-label attributes Screen reader labels must be in the active locale
Locale preference cookie/storage Must be saved for next session

A locale switch that only updates the visible text and misses, say, the aria-label attributes is a partial implementation that will fail accessibility testing.

Testing That All Strings Update Without Stale Translations

The most common locale switching bug is a partial update — some strings update immediately while others retain the old locale text until a page reload. This typically happens when:

  • Some components read from a global store that updates reactively, while others read from a local copy captured at mount time
  • Lazy-loaded components re-fetch their translation keys at switch time but have not been loaded yet
  • Server-rendered HTML contains hardcoded translated text that is not replaced on the client
// tests/locale-switching.spec.js
import { test, expect } from '@playwright/test';

test.describe('locale switching', () => {
  test('all visible text updates when switching from English to German', async ({ page }) => {
    await page.goto('/');
    
    // Confirm we are in English
    await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
    await expect(page.getByRole('navigation')).toContainText('Settings');
    
    // Switch to German
    await page.getByRole('button', { name: 'Language' }).click();
    await page.getByRole('menuitem', { name: 'Deutsch' }).click();
    
    // All strings should update — no English text should remain in static UI elements
    await expect(page.getByRole('button', { name: 'Absenden' })).toBeVisible();
    await expect(page.getByRole('navigation')).toContainText('Einstellungen');
    
    // Verify no stale English text in key areas
    await expect(page.getByRole('button', { name: 'Submit' })).not.toBeVisible();
    await expect(page.getByRole('navigation')).not.toContainText('Settings');
  });

  test('page title updates in the browser tab', async ({ page }) => {
    await page.goto('/dashboard');
    expect(await page.title()).toContain('Dashboard');
    
    await switchLocale(page, 'fr');
    
    // Title should now be in French
    expect(await page.title()).toContain('Tableau de bord');
  });

  test('html lang attribute updates', async ({ page }) => {
    await page.goto('/');
    expect(await page.getAttribute('html', 'lang')).toBe('en');
    
    await switchLocale(page, 'de');
    expect(await page.getAttribute('html', 'lang')).toBe('de');
    
    await switchLocale(page, 'ar');
    expect(await page.getAttribute('html', 'lang')).toBe('ar');
  });

  test('number formatting updates immediately', async ({ page }) => {
    await page.goto('/pricing');
    
    // In English: $1,234.99
    const priceLocator = page.getByTestId('product-price');
    await expect(priceLocator).toContainText('$1,234.99');
    
    // Switch to German
    await switchLocale(page, 'de');
    
    // Should now show: 1.234,99 €
    await expect(priceLocator).toContainText('1.234,99');
    await expect(priceLocator).toContainText('€');
  });
});

async function switchLocale(page, locale) {
  await page.getByTestId('language-picker').click();
  await page.getByTestId(`locale-option-${locale}`).click();
  // Wait for the locale change to propagate
  await expect(page.locator('html')).toHaveAttribute('lang', locale);
}

Testing Locale Persistence

The user's locale preference must survive a page reload and a new browser session. The three common persistence mechanisms each have different test approaches.

localStorage Persistence

test('locale preference persists in localStorage after reload', async ({ page }) => {
  await page.goto('/');
  
  // Switch to French
  await switchLocale(page, 'fr');
  
  // Verify localStorage was updated
  const stored = await page.evaluate(() => localStorage.getItem('locale'));
  expect(stored).toBe('fr');
  
  // Reload and verify locale is restored
  await page.reload();
  expect(await page.getAttribute('html', 'lang')).toBe('fr');
  await expect(page.getByRole('button', { name: 'Soumettre' })).toBeVisible();
});

test('locale preference persists across new browser sessions', async ({ browser }) => {
  // First session: set locale
  const context1 = await browser.newContext();
  const page1 = await context1.newPage();
  await page1.goto('/');
  await switchLocale(page1, 'ja');
  await context1.close();
  
  // Second session: same storage state
  const context2 = await browser.newContext({
    storageState: await context1.storageState(), // reuse storage
  });
  const page2 = await context2.newPage();
  await page2.goto('/');
  
  expect(await page2.getAttribute('html', 'lang')).toBe('ja');
  await context2.close();
});
test('locale stored in cookie and sent to server on next request', async ({ page, context }) => {
  await page.goto('/');
  await switchLocale(page, 'pt-BR');
  
  // Verify the locale cookie was set
  const cookies = await context.cookies();
  const localeCookie = cookies.find(c => c.name === 'locale' || c.name === 'NEXT_LOCALE');
  expect(localeCookie).toBeDefined();
  expect(localeCookie.value).toBe('pt-BR');
  
  // Reload — the server should receive the cookie and render in Portuguese
  await page.reload();
  expect(await page.getAttribute('html', 'lang')).toBe('pt-BR');
});

URL-Based Locale

test('locale in URL path updates on switch', async ({ page }) => {
  await page.goto('/en/about');
  await switchLocale(page, 'es');
  
  // URL should update to Spanish path
  expect(page.url()).toContain('/es/about');
  // Content should be in Spanish
  await expect(page.getByRole('heading', { level: 1 })).toContainText('Acerca de');
});

test('navigating to a localized URL directly sets locale', async ({ page }) => {
  await page.goto('/fr/pricing');
  expect(await page.getAttribute('html', 'lang')).toBe('fr');
  await expect(page.getByRole('heading')).toContainText('Tarifs');
});

Testing Browser Language Detection

When a user visits without a stored preference, the app should detect the browser's preferred language from the Accept-Language header and default to the appropriate locale.

test('detects browser locale from Accept-Language header', async ({ browser }) => {
  // Launch with German browser locale
  const context = await browser.newContext({
    locale: 'de-DE',
  });
  const page = await context.newPage();
  
  await page.goto('/');
  
  // App should auto-detect German
  expect(await page.getAttribute('html', 'lang')).toBe('de');
  await expect(page.getByRole('navigation')).not.toContainText('Settings');
  await expect(page.getByRole('navigation')).toContainText('Einstellungen');
  
  await context.close();
});

test('falls back to English for unsupported browser locale', async ({ browser }) => {
  const context = await browser.newContext({
    locale: 'sw-KE', // Swahili — not supported by the app
  });
  const page = await context.newPage();
  await page.goto('/');
  
  // Should fall back to English (or default locale)
  expect(await page.getAttribute('html', 'lang')).toBe('en');
  
  await context.close();
});

Testing Locale Fallback Chains

A user with a fr-CA (Canadian French) preference should see fr-CA specific translations where they exist, fall back to fr (generic French) for strings not specifically translated for Canada, and fall back to en (English) for any string not in any French variant.

test('fr-CA falls back to fr then en for missing translations', async ({ page }) => {
  await page.goto('/');
  await switchLocale(page, 'fr-CA');
  
  // Canadian French specific string (exists in fr-CA)
  await expect(page.getByTestId('postal-code-label')).toContainText('Code postal');
  
  // Generic French string (exists in fr, not fr-CA) — should still show French
  await expect(page.getByTestId('dashboard-heading')).toContainText('Tableau de bord');
  // NOT "Dashboard" (English fallback) unless fr also lacks this key
  
  // String not in any French variant — falls back to English
  // (This would only happen if a key was added to en.json but not pushed to translators yet)
  // Verify at minimum that a raw key name like "common.new_feature" is not shown to users
  const rawKeyPattern = /^[a-z]+\.[a-z_]+$/;
  const allText = await page.textContent('body');
  expect(allText).not.toMatch(rawKeyPattern);
});

RTL/LTR Switching Tests

Right-to-left languages (Arabic, Hebrew, Persian, Urdu) require the entire layout to mirror horizontally. Text alignment, icon positions, navigation direction, and flex layout all reverse.

test('layout direction changes to RTL when switching to Arabic', async ({ page }) => {
  await page.goto('/');
  
  // English: LTR
  expect(await page.getAttribute('html', 'dir')).toBe('ltr');
  
  const navBefore = await page.getByRole('navigation').boundingBox();
  const logoBefore = await page.getByTestId('logo').boundingBox();
  
  // In LTR, logo is typically left-aligned (small x value)
  expect(logoBefore.x).toBeLessThan(100);
  
  // Switch to Arabic
  await switchLocale(page, 'ar');
  
  expect(await page.getAttribute('html', 'dir')).toBe('rtl');
  expect(await page.getAttribute('html', 'lang')).toBe('ar');
  
  // After RTL switch, logo should be on the right (large x value)
  const logoAfter = await page.getByTestId('logo').boundingBox();
  const viewportWidth = page.viewportSize().width;
  expect(logoAfter.x).toBeGreaterThan(viewportWidth / 2);
});

test('form fields in RTL locale have right-aligned text', async ({ page }) => {
  await switchLocale(page, 'he'); // Hebrew
  await page.goto('/login');
  
  const emailInput = page.getByLabel('אימייל'); // "Email" in Hebrew
  const direction = await emailInput.evaluate(el => 
    window.getComputedStyle(el).direction
  );
  expect(direction).toBe('rtl');
});

test('back/forward navigation icons flip in RTL', async ({ page }) => {
  await page.goto('/');
  
  // LTR: back arrow points left (←)
  const backArrowLTR = await page.getByTestId('back-button').getAttribute('data-direction');
  expect(backArrowLTR).toBe('left');
  
  await switchLocale(page, 'ar');
  
  // RTL: back arrow points right (→) because "back" is to the right in RTL
  const backArrowRTL = await page.getByTestId('back-button').getAttribute('data-direction');
  expect(backArrowRTL).toBe('right');
});

Testing Locale in Server-Side Rendering

SSR adds complexity because the server renders HTML in one locale, and the client hydrates it. A mismatch between the server-rendered locale and the client-detected locale causes hydration errors and flash-of-wrong-language.

test('SSR renders in the correct locale without hydration mismatch', async ({ page }) => {
  // Set cookie before navigating so server knows the locale
  await page.context().addCookies([{
    name: 'NEXT_LOCALE',
    value: 'de',
    domain: 'localhost',
    path: '/',
  }]);
  
  // Listen for console errors (hydration mismatches appear as console errors in React)
  const consoleErrors = [];
  page.on('console', msg => {
    if (msg.type() === 'error') consoleErrors.push(msg.text());
  });
  
  await page.goto('/');
  
  // No hydration errors
  const hydrationErrors = consoleErrors.filter(e => 
    e.includes('Hydration') || e.includes('did not match') || e.includes('Text content')
  );
  expect(hydrationErrors).toHaveLength(0);
  
  // Content is in German from the first render (no flash)
  expect(await page.getAttribute('html', 'lang')).toBe('de');
});

test('no flash of English content before locale hydrates', async ({ page }) => {
  await page.context().addCookies([{
    name: 'locale',
    value: 'fr',
    domain: 'localhost',
    path: '/',
  }]);
  
  // Capture screenshots at page load and after hydration
  const screenshots = [];
  page.on('domcontentloaded', async () => {
    screenshots.push(await page.screenshot());
  });
  
  await page.goto('/');
  await page.waitForLoadState('networkidle');
  
  // The initial render should already be in French — no English flash
  // This is a visual check; you can also check that specific English strings are absent
  await expect(page.getByRole('button', { name: 'Submit' })).not.toBeVisible();
  await expect(page.getByRole('button', { name: 'Soumettre' })).toBeVisible();
});

Testing With Playwright's Built-in Locale Support

Playwright has first-class support for locale testing through browser context configuration. Use this for systematic locale testing across multiple locales:

// playwright.config.js
const LOCALES = ['en-US', 'de-DE', 'fr-FR', 'ja-JP', 'ar-SA', 'he-IL'];

export default {
  projects: LOCALES.map(locale => ({
    name: `locale-${locale}`,
    use: {
      locale,
      // For RTL locales, also set direction
      ...(locale === 'ar-SA' || locale === 'he-IL' ? { 
        extraHTTPHeaders: { 'Accept-Language': locale }
      } : {}),
    },
    testMatch: '**/locale-switching.spec.js',
  })),
};
// tests/locale-detection.spec.js
test('app detects locale from browser and renders correctly', async ({ page, locale }) => {
  await page.goto('/');
  
  // The app should have detected the Playwright browser locale
  const htmlLang = await page.getAttribute('html', 'lang');
  // Normalize: 'de-DE' → 'de', 'fr-FR' → 'fr'
  const baseLang = locale.split('-')[0];
  expect(htmlLang).toMatch(new RegExp(`^${baseLang}`));
});

Smoke Test Checklist for Locale Switching

After implementing locale switching, run through this checklist for each supported locale:

Functional:

  • All navigation labels update
  • All page headings update
  • All form labels, placeholders, and error messages update
  • Number formatting adapts (decimal separator, thousands separator)
  • Currency display adapts (symbol position, code vs symbol)
  • Date and time formatting adapts
  • Relative time ("2 hours ago") uses locale-appropriate phrasing
  • <html lang> attribute updates
  • <title> element updates
  • aria-label attributes on icon buttons update

Persistence:

  • Preference stored (localStorage/cookie/URL)
  • Preference restored on reload
  • Preference restored on new session (if cookie or URL-based)
  • Browser locale detected correctly on first visit

RTL (Arabic, Hebrew, Persian, Urdu):

  • <html dir="rtl"> set
  • Layout mirrors horizontally
  • Navigation is on the correct side
  • Text alignment is right-to-left
  • Icons that have directional meaning are flipped

SSR:

  • No hydration mismatch errors in console
  • No flash of English (or wrong language) before hydration
  • Server reads locale from cookie/URL on initial request

Locale switching is a feature that must be tested as a system, not just as individual translation lookups. The integration points between the locale store, the rendering layer, the persistence mechanism, and the server-side rendering pipeline are where bugs live. Playwright's ability to simulate specific browser locales, inspect computed styles, and check network cookies makes it the right tool for covering this surface systematically.

Read more