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();
});Cookie-Based Persistence
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 updatesaria-labelattributes 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.