mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
508 lines
13 KiB
Markdown
508 lines
13 KiB
Markdown
# Internationalization (i18n) Testing
|
|
|
|
## Table of Contents
|
|
|
|
1. [Locale Configuration](#locale-configuration)
|
|
2. [Testing Multiple Locales](#testing-multiple-locales)
|
|
3. [RTL Layout Testing](#rtl-layout-testing)
|
|
4. [Date, Time & Number Formats](#date-time--number-formats)
|
|
5. [Translation Verification](#translation-verification)
|
|
6. [Visual Regression for i18n](#visual-regression-for-i18n)
|
|
|
|
## Locale Configuration
|
|
|
|
### Setting Browser Locale
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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" } },
|
|
],
|
|
});
|
|
```
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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](../advanced/clock-mocking.md) for timezone testing
|
|
- **Mobile Testing**: See [mobile-testing.md](../advanced/mobile-testing.md) for device-specific locales
|