mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
13 KiB
13 KiB
Internationalization (i18n) Testing
Table of Contents
- Locale Configuration
- Testing Multiple Locales
- RTL Layout Testing
- Date, Time & Number Formats
- Translation Verification
- Visual Regression for i18n
Locale Configuration
Setting Browser Locale
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "english",
use: {
...devices["Desktop Chrome"],
locale: "en-US",
timezoneId: "America/New_York",
},
},
{
name: "german",
use: {
...devices["Desktop Chrome"],
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
},
{
name: "japanese",
use: {
...devices["Desktop Chrome"],
locale: "ja-JP",
timezoneId: "Asia/Tokyo",
},
},
{
name: "arabic",
use: {
...devices["Desktop Chrome"],
locale: "ar-SA",
timezoneId: "Asia/Riyadh",
},
},
],
});
Per-Test Locale Override
test("test in French locale", async ({ browser }) => {
const context = await browser.newContext({
locale: "fr-FR",
timezoneId: "Europe/Paris",
});
const page = await context.newPage();
await page.goto("/");
// Verify French content
await expect(page.getByRole("button", { name: "Connexion" })).toBeVisible();
await context.close();
});
Accept-Language Header
test("server-side locale detection", async ({ browser }) => {
const context = await browser.newContext({
locale: "es-ES",
extraHTTPHeaders: {
"Accept-Language": "es-ES,es;q=0.9,en;q=0.8",
},
});
const page = await context.newPage();
await page.goto("/");
// Server should respond with Spanish content
await expect(page.locator("html")).toHaveAttribute("lang", "es");
});
Testing Multiple Locales
Parameterized Locale Tests
const locales = [
{ locale: "en-US", greeting: "Hello", button: "Sign In" },
{ locale: "de-DE", greeting: "Hallo", button: "Anmelden" },
{ locale: "fr-FR", greeting: "Bonjour", button: "Se connecter" },
{ locale: "ja-JP", greeting: "こんにちは", button: "ログイン" },
];
for (const { locale, greeting, button } of locales) {
test(`login page in ${locale}`, async ({ browser }) => {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto("/login");
await expect(page.getByText(greeting)).toBeVisible();
await expect(page.getByRole("button", { name: button })).toBeVisible();
await context.close();
});
}
Locale Fixture
// fixtures/i18n.ts
import { test as base } from "@playwright/test";
type LocaleFixtures = {
localePage: (locale: string) => Promise<Page>;
};
export const test = base.extend<LocaleFixtures>({
localePage: async ({ browser }, use) => {
const pages: Page[] = [];
const createLocalePage = async (locale: string) => {
const context = await browser.newContext({ locale });
const page = await context.newPage();
pages.push(page);
return page;
};
await use(createLocalePage);
// Cleanup
for (const page of pages) {
await page.context().close();
}
},
});
// Usage
test("compare locales", async ({ localePage }) => {
const enPage = await localePage("en-US");
const dePage = await localePage("de-DE");
await enPage.goto("/pricing");
await dePage.goto("/pricing");
const enPrice = await enPage.getByTestId("price").textContent();
const dePrice = await dePage.getByTestId("price").textContent();
expect(enPrice).toContain("$");
expect(dePrice).toContain("€");
});
Testing Locale Switching
test("user can switch locale", async ({ page }) => {
await page.goto("/");
// Initial locale (from browser)
await expect(page.locator("html")).toHaveAttribute("lang", "en");
// Switch to German
await page.getByRole("button", { name: "Language" }).click();
await page.getByRole("menuitem", { name: "Deutsch" }).click();
// Verify switch
await expect(page.locator("html")).toHaveAttribute("lang", "de");
await expect(page.getByRole("heading", { level: 1 })).toContainText(
/Willkommen/,
);
// Verify persistence (reload)
await page.reload();
await expect(page.locator("html")).toHaveAttribute("lang", "de");
});
RTL Layout Testing
Setting Up RTL Tests
// playwright.config.ts
export default defineConfig({
projects: [
{
name: "rtl-arabic",
use: {
locale: "ar-SA",
// RTL is usually set by the app based on locale
},
},
{
name: "rtl-hebrew",
use: {
locale: "he-IL",
},
},
],
});
Verifying RTL Direction
test("RTL layout is applied", async ({ page }) => {
await page.goto("/");
// Check document direction
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
// Or check computed style
const direction = await page.evaluate(() => {
return window.getComputedStyle(document.body).direction;
});
expect(direction).toBe("rtl");
});
RTL-Specific Element Positioning
test("sidebar is on the right in RTL", async ({ page }) => {
await page.goto("/dashboard");
const sidebar = page.getByTestId("sidebar");
const main = page.getByTestId("main-content");
const sidebarBox = await sidebar.boundingBox();
const mainBox = await main.boundingBox();
// In RTL, sidebar should be to the right of main content
expect(sidebarBox!.x).toBeGreaterThan(mainBox!.x);
});
RTL Visual Regression
test("RTL layout matches snapshot", async ({ page }) => {
await page.goto("/");
// Screenshot for RTL comparison
await expect(page).toHaveScreenshot("homepage-rtl.png", {
// Separate snapshots per locale/direction
fullPage: true,
});
});
// LTR comparison
test("LTR layout matches snapshot", async ({ browser }) => {
const context = await browser.newContext({ locale: "en-US" });
const page = await context.newPage();
await page.goto("/");
await expect(page).toHaveScreenshot("homepage-ltr.png", { fullPage: true });
});
Testing Bidirectional Text
test("bidirectional text renders correctly", async ({ page }) => {
await page.goto("/profile");
// Mixed LTR/RTL content
const nameField = page.getByTestId("full-name");
// Arabic name with English email
await expect(nameField).toContainText("محمد (mohammed@example.com)");
// Verify text doesn't overlap or break
const box = await nameField.boundingBox();
expect(box!.width).toBeGreaterThan(100); // Content not collapsed
});
Date, Time & Number Formats
Testing Date Formats
test("dates are formatted per locale", async ({ browser }) => {
const testDate = new Date("2024-03-15");
const formats = [
{ locale: "en-US", expected: "March 15, 2024" },
{ locale: "en-GB", expected: "15 March 2024" },
{ locale: "de-DE", expected: "15. März 2024" },
{ locale: "ja-JP", expected: "2024年3月15日" },
];
for (const { locale, expected } of formats) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto(`/event?date=${testDate.toISOString()}`);
const dateDisplay = page.getByTestId("event-date");
await expect(dateDisplay).toContainText(expected);
await context.close();
}
});
Testing Number Formats
test("numbers are formatted per locale", async ({ browser }) => {
const testNumber = 1234567.89;
const formats = [
{ locale: "en-US", expected: "1,234,567.89" },
{ locale: "de-DE", expected: "1.234.567,89" },
{ locale: "fr-FR", expected: "1 234 567,89" },
];
for (const { locale, expected } of formats) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto(`/stats?value=${testNumber}`);
await expect(page.getByTestId("formatted-number")).toHaveText(expected);
await context.close();
}
});
Testing Currency Formats
test("currency displays correctly", async ({ browser }) => {
const price = 99.99;
const currencies = [
{ locale: "en-US", currency: "USD", expected: "$99.99" },
{ locale: "de-DE", currency: "EUR", expected: "99,99 €" },
{ locale: "ja-JP", currency: "JPY", expected: "¥100" }, // JPY has no decimals
{ locale: "en-GB", currency: "GBP", expected: "£99.99" },
];
for (const { locale, currency, expected } of currencies) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto(`/product?price=${price}¤cy=${currency}`);
await expect(page.getByTestId("price")).toContainText(expected);
await context.close();
}
});
Translation Verification
Checking for Missing Translations
test("no missing translations", async ({ page }) => {
await page.goto("/");
// Common patterns for missing translations
const missingPatterns = [
/\{\{.*\}\}/, // Handlebars-style
/\$\{.*\}/, // Template literal style
/t\(["'][\w.]+["']\)/, // i18n key exposed
/MISSING_TRANSLATION/, // Common placeholder
/\[UNTRANSLATED\]/, // Another placeholder
];
const bodyText = await page.locator("body").textContent();
for (const pattern of missingPatterns) {
expect(bodyText).not.toMatch(pattern);
}
});
Detecting Text Overflow
test("translations fit UI containers", async ({ browser }) => {
const locales = ["en-US", "de-DE", "fr-FR", "es-ES"];
const issues: string[] = [];
for (const locale of locales) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto("/");
const overflowing = await page.evaluate(() => {
const elements = document.querySelectorAll("button, .label, h1, h2, h3");
return Array.from(elements)
.filter(
(el) =>
(el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth,
)
.map((el) => `${el.tagName}: "${el.textContent?.substring(0, 20)}..."`);
});
if (overflowing.length > 0)
issues.push(`${locale}: ${overflowing.join(", ")}`);
await context.close();
}
expect(issues).toEqual([]);
});
Visual Regression for i18n
Locale-Specific Snapshots
// playwright.config.ts
export default defineConfig({
snapshotPathTemplate:
"{testDir}/__snapshots__/{projectName}/{testFilePath}/{arg}{ext}",
projects: [
{ name: "en-US", use: { locale: "en-US" } },
{ name: "de-DE", use: { locale: "de-DE" } },
{ name: "ja-JP", use: { locale: "ja-JP" } },
{ name: "ar-SA", use: { locale: "ar-SA" } },
],
});
// test file
test("homepage visual", async ({ page }) => {
await page.goto("/");
// Snapshot auto-saved to {projectName}/homepage.png
await expect(page).toHaveScreenshot("homepage.png");
});
Critical Element Screenshots
test("navigation in all locales", async ({ page }) => {
await page.goto("/");
// Just the nav - catches overflow, truncation
const nav = page.getByRole("navigation");
await expect(nav).toHaveScreenshot("navigation.png");
});
test("buttons dont truncate", async ({ page }) => {
await page.goto("/checkout");
const ctaButton = page.getByRole("button", {
name: /checkout|kaufen|acheter/i,
});
await expect(ctaButton).toHaveScreenshot("checkout-button.png");
});
Font Loading for i18n
test("wait for fonts before screenshot", async ({ page }) => {
await page.goto("/");
// Wait for fonts (important for CJK, Arabic)
await page.evaluate(() => document.fonts.ready);
await page.waitForFunction(() =>
document.fonts.check("16px 'Noto Sans Arabic'"),
);
await expect(page).toHaveScreenshot("with-fonts.png");
});
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Hardcoded text assertions | Breaks in other locales | Use test IDs or parameterize |
| Single locale testing | Misses i18n bugs | Test multiple locales |
| Ignoring RTL | Layout broken for RTL users | Dedicated RTL project |
| No font wait | Screenshots with fallback fonts | Wait for document.fonts.ready |
Related References
- Clock Mocking: See clock-mocking.md for timezone testing
- Mobile Testing: See mobile-testing.md for device-specific locales