Mục lục
- Mục Tiêu Bài Học
- Vấn Đề Khi Test App i18n
- Locale Project Matrix
- Translation Data File
- Dùng Translation Data Trực Tiếp Trong Test
- Locale Fixture — Inject Tự Động
- Chiến Lược Chọn Locator Cho i18n
- Assertion Date / Number Format
- RTL Layout Testing
- Filter Chạy Theo Một Locale Cụ Thể
- Use Cases
- Limitations
- 4 Pitfall Hay Gặp
- Quiz + Bài Tiếp
Mục Tiêu Bài Học
Sau bài này bạn sẽ:
- Dựng được locale project matrix — chạy cùng test suite trên nhiều locale mà không copy test.
- Quản lý translation data file dùng chung giữa app source và test suite.
- Viết được locale fixture tự động inject bản dịch đúng vào mỗi test theo project đang chạy.
- Chọn được locator phù hợp: khi nào dùng
getByTestId, khi nào dùnggetByRolevới translated name. - Assert date/number format đúng theo locale, kiểm tra RTL layout, và tránh 4 pitfall phổ biến.
Bài này không lặp lại cơ chế locale và timezoneId option fixture (bài 9) — phần đó đã cover đầy đủ về scope, Intl API, và IANA timezone. Bài này tập trung vào tổ chức test suite i18n quy mô lớn.
Project matrix cơ bản (cú pháp projects.map() với flatMap) đã được trình bày tại bài 92. Bài này áp dụng pattern đó cho bài toán locale cụ thể, với các thành phần bổ sung mà bài 92 không cover: translation data, locale fixture và i18n locator strategy.
Vấn Đề Khi Test App i18n
App hỗ trợ 5 ngôn ngữ nghĩa là cùng một flow (đăng nhập, checkout, hiển thị giá) chạy với 5 bộ text, 5 format number, 5 format date khác nhau. Nếu viết test riêng cho từng locale thì:
- 5× code trùng lặp — logic test giống nhau, chỉ khác string assertion.
- Khi flow thay đổi, phải sửa ở 5 chỗ.
- Dễ bỏ sót — thêm locale thứ 6 mà quên copy test.
Ngoài text, còn có 4 thách thức khác biệt mà test phải xử lý:
| Thách thức | Ví dụ cụ thể | Ảnh hưởng đến test |
|---|---|---|
| Text khác nhau | "Login" / "Đăng nhập" / "ログイン" | Assertion text phải dynamic theo locale |
| Format số/tiền tệ | $1,234.56 / 1.234,56 € / 1.234,56 ₫ | Regex hoặc format parser per locale |
| Format ngày | 01/15/2026 / 15/01/2026 / 2026/01/15 | Assert pattern không phải giá trị cứng |
| RTL layout | Arabic, Hebrew — text chạy phải sang trái | Kiểm tra dir="rtl" trên HTML element |
| Độ dài text | Tiếng Đức thường dài hơn Anh 30-40% | Layout test — không overflow, không truncate |
Pattern bài này giải quyết tất cả bằng cách kết hợp: project matrix để chạy song song, translation data để assertion dynamic, và locale fixture để test nhận đúng data mà không cần biết đang chạy locale nào.
Locale Project Matrix
Thay vì khai báo từng project thủ công, định nghĩa mảng locale config rồi map sang projects. Cách này dễ thêm locale mới — chỉ cần thêm 1 entry vào mảng:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
const locales = [
{ code: 'en-US', tz: 'America/New_York', name: 'Welcome' },
{ code: 'vi-VN', tz: 'Asia/Ho_Chi_Minh', name: 'Chào mừng' },
{ code: 'ja-JP', tz: 'Asia/Tokyo', name: 'ようこそ' },
];
export default defineConfig({
projects: locales.map(l => ({
name: `locale-${l.code}`,
use: { locale: l.code, timezoneId: l.tz },
})),
});
Field name trong mảng locales không được dùng trong config này — nó phục vụ phần sau khi cần lookup translation nhanh trong code không liên quan đến test file. Điểm quan trọng: mỗi project tự động gán đúng cặp locale + timezoneId tương ứng, tránh lỗi nhầm timezone (ví dụ: vi-VN với Asia/Tokyo).
Project name theo convention locale-{code} (ví dụ locale-vi-VN) giúp filter bằng wildcard:
npx playwright test --project="locale-*" # toàn bộ locale
npx playwright test --project="locale-vi-VN" # chỉ tiếng Việt
Nếu app còn cần test thêm chiều browser (Chrome, Firefox), có thể dùng flatMap để tạo matrix locale × browser — cách đó đã được trình bày đầy đủ tại bài 92. Bài này giữ một chiều locale để tập trung vào i18n pattern.
Translation Data File
Test cần biết text đúng cho từng locale để assertion. Cách tốt nhất là dùng lại chính file translation mà app đã có thay vì copy sang file riêng — hai nguồn sync nhau tự động, test không thể outdated so với app.
Giả sử app dùng file JSON translation theo cấu trúc:
// src/i18n/translations.json
{
"en-US": {
"welcome": "Welcome",
"login": "Login",
"logout": "Logout",
"price_label": "Price"
},
"vi-VN": {
"welcome": "Chào mừng",
"login": "Đăng nhập",
"logout": "Đăng xuất",
"price_label": "Giá"
},
"ja-JP": {
"welcome": "ようこそ",
"login": "ログイン",
"logout": "ログアウト",
"price_label": "価格"
}
}
Test import trực tiếp file này:
// tests/helpers/translations.ts
import data from '../../src/i18n/translations.json';
// Type cho một locale entry
export type Translations = typeof data['en-US'];
// Lookup function
export function getTranslations(locale: string): Translations {
const entry = (data as Record)[locale];
if (!entry) throw new Error(`No translations for locale: ${locale}`);
return entry;
}
Nếu app dùng thư viện i18n khác (react-i18next, vue-i18n, next-intl), format file có thể khác nhau — nhưng nguyên tắc giống nhau: import cùng nguồn, không copy-paste.
Trường hợp app dùng namespace hoặc nhiều file per locale, tổng hợp vào object duy nhất trong helper trên để test không cần biết cấu trúc nội bộ của i18n layer:
// Merge nhiều namespace
import common from '../../src/i18n/en-US/common.json';
import auth from '../../src/i18n/en-US/auth.json';
// hoặc load động nếu file path có pattern
const enUS = { ...common, ...auth };
Dùng Translation Data Trực Tiếp Trong Test
Cách đơn giản nhất: đọc testInfo.project.use.locale trong test body để lookup bản dịch phù hợp:
// tests/homepage.spec.ts
import { test, expect } from '@playwright/test';
import { getTranslations } from './helpers/translations';
test('homepage greeting', async ({ page }, testInfo) => {
const locale = testInfo.project.use.locale!;
const t = getTranslations(locale);
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText(t.welcome);
});
test('login button label', async ({ page }, testInfo) => {
const locale = testInfo.project.use.locale!;
const t = getTranslations(locale);
await page.goto('/');
await expect(
page.getByRole('button', { name: t.login })
).toBeVisible();
});
testInfo.project.use chứa object use được khai báo cho project hiện tại. Khi Playwright chạy test homepage.spec.ts với project locale-vi-VN, testInfo.project.use.locale sẽ là 'vi-VN'.
Cách này hoạt động tốt nhưng mỗi test đều phải viết 2 dòng boilerplate (lấy locale, lấy translation). Phần tiếp theo đóng gói logic này vào fixture.
Locale Fixture — Inject Tự Động
Đóng gói logic lookup vào fixture t (translation) để test không cần biết cơ chế bên dưới:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { getTranslations, Translations } from './helpers/translations';
type MyFixtures = {
t: Translations;
};
export const test = base.extend<MyFixtures>({
t: async ({}, use, testInfo) => {
const locale = testInfo.project.use.locale ?? 'en-US';
const translations = getTranslations(locale);
await use(translations);
},
});
export { expect } from '@playwright/test';
Test file giờ import test từ fixtures.ts thay vì từ @playwright/test:
// tests/homepage.spec.ts
import { test, expect } from './fixtures';
test('homepage greeting', async ({ page, t }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText(t.welcome);
});
test('login button', async ({ page, t }) => {
await page.goto('/');
await expect(
page.getByRole('button', { name: t.login })
).toBeVisible();
});
Fixture t tự động nhận đúng bản dịch cho locale của project đang chạy. Test body không chứa bất kỳ logic điều kiện nào liên quan đến locale — đây là điểm khác biệt so với cách trực tiếp ở bài 5.
Fixture này là stateless — không cần setup/teardown phức tạp. Mỗi test nhận object translation mới, không có shared state giữa các test.
Chiến Lược Chọn Locator Cho i18n
Không phải mọi locator đều phù hợp khi test multi-locale. Ba cấp độ từ tốt nhất đến nên tránh:
Best: getByTestId — locale-independent
// Không phụ thuộc text — chạy đúng với mọi locale
await page.getByTestId('login-btn').click();
await page.getByTestId('price-display').textContent();
data-testid là attribute HTML không hiển thị ra UI, không bị dịch. Dùng cho interactive element quan trọng (nút CTA, form field, price display) là cách bền vững nhất. Cần phối hợp với frontend team để đảm bảo attribute này được thêm vào component.
OK: getByRole với translated name
// Dùng khi element có semantic role rõ ràng + text là accessible name
// t.login = "Login" / "Đăng nhập" / "ログイン" tùy locale
await page.getByRole('button', { name: t.login }).click();
await page.getByRole('heading', { name: t.welcome }).isVisible();
Accessible role + translated name — vẫn đúng với mọi locale vì t.login tự thay đổi theo locale. Ưu điểm thêm: test đồng thời xác minh accessible label đúng (screen reader-friendly).
Avoid: getByText với hardcode English
// SAI — break ngay khi locale không phải en-US
await page.getByText('Login').click();
await expect(page.getByText('Welcome')).toBeVisible();
String literal hardcode sẽ fail với mọi locale không phải English, hoặc ngược lại — fail English khi i18n key thay đổi. Pattern này phá vỡ toàn bộ mục đích của multi-locale test.
Thứ tự ưu tiên thực tế
| Locator | Locale-independent | Accessibility check | Khi nào dùng |
|---|---|---|---|
getByTestId |
Có | Không | CTA button, form field, data display |
getByRole + t.key |
Có (qua fixture) | Có | Khi muốn test accessible name đúng |
getByLabel + t.key |
Có (qua fixture) | Có | Form input với label được dịch |
getByText hardcode |
Không | Không | Tránh trong multi-locale test |
Assertion Date / Number Format
Text element hiển thị giá hay ngày thì không thể dùng cùng một expected value cho mọi locale. Có hai cách xử lý:
Cách 1: Tính expected value bằng Intl API
Dùng chính Intl API của Node.js để tính giá trị mong đợi — đảm bảo test và app dùng cùng thuật toán format:
test('price format', async ({ page }, testInfo) => {
const locale = testInfo.project.use.locale!;
await page.goto('/product/1');
const price = 1234.56;
const currency = locale === 'en-US' ? 'USD' : locale === 'vi-VN' ? 'VND' : 'JPY';
// Tính expected value bằng Intl
const expected = new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(price);
// en-US: "$1,234.56"
// vi-VN: "1.234,56 ₫" (hoặc "1.235 ₫" nếu VND không có decimal)
// ja-JP: "¥1,235"
const actual = await page.getByTestId('price').textContent();
expect(actual?.trim()).toBe(expected);
});
Cách 2: Assert pattern thay vì giá trị cụ thể
Khi không chắc app format chính xác ra sao, assert pattern (có ký hiệu tiền tệ, có số, không rỗng) thay vì giá trị chuỗi chính xác:
test('price display shows currency symbol', async ({ page }, testInfo) => {
const locale = testInfo.project.use.locale!;
const currencySymbol = { 'en-US': '$', 'vi-VN': '₫', 'ja-JP': '¥' }[locale] ?? '';
await page.goto('/product/1');
const priceText = await page.getByTestId('price').textContent() ?? '';
expect(priceText).toContain(currencySymbol);
expect(priceText.trim()).not.toBe(''); // không rỗng
});
Date format
test('event date format', async ({ page }, testInfo) => {
const locale = testInfo.project.use.locale!;
await page.goto('/event/42');
const dateText = await page.getByTestId('event-date').textContent() ?? '';
const date = new Date('2026-03-15');
const expected = new Intl.DateTimeFormat(locale, {
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(date);
// en-US: "03/15/2026"
// vi-VN: "15/03/2026"
// ja-JP: "2026/03/15"
expect(dateText.trim()).toBe(expected);
});
Cả hai cách đều hợp lệ — cách 1 chính xác hơn, cách 2 ít phụ thuộc vào cấu hình format cụ thể của app. Chọn tùy mức độ kiểm soát bạn có đối với code app.
RTL Layout Testing
Arabic và Hebrew là hai ngôn ngữ phổ biến viết từ phải sang trái (RTL). App hỗ trợ RTL cần set dir="rtl" trên thẻ <html> khi locale là ar, ar-SA, he, v.v.
Thêm locale Arabic vào matrix và viết test kiểm tra RTL:
// playwright.config.ts
const locales = [
{ code: 'en-US', tz: 'America/New_York' },
{ code: 'vi-VN', tz: 'Asia/Ho_Chi_Minh' },
{ code: 'ar-SA', tz: 'Asia/Riyadh' }, // RTL locale
];
// tests/rtl.spec.ts
import { test, expect } from '@playwright/test';
// Chỉ chạy với locale RTL — skip các locale LTR
test('RTL html dir attribute', async ({ page }, testInfo) => {
const locale = testInfo.project.use.locale!;
const rtlLocales = ['ar', 'ar-SA', 'ar-EG', 'he', 'fa'];
const isRTL = rtlLocales.some(l => locale.startsWith(l.split('-')[0]));
test.skip(!isRTL, `Not an RTL locale: ${locale}`);
await page.goto('/');
const dir = await page.locator('html').getAttribute('dir');
expect(dir).toBe('rtl');
});
Kiểm tra thêm: text align, layout không bị vỡ khi RTL:
test('Arabic navbar layout', async ({ page }, testInfo) => {
test.skip(testInfo.project.use.locale !== 'ar-SA', 'Arabic only');
await page.goto('/');
// Navigation không bị overflow
const nav = page.locator('nav');
await expect(nav).toBeVisible();
const navBox = await nav.boundingBox();
const viewportWidth = page.viewportSize()?.width ?? 1280;
// Nav không tràn ra ngoài viewport
expect(navBox!.x).toBeGreaterThanOrEqual(0);
expect(navBox!.x + navBox!.width).toBeLessThanOrEqual(viewportWidth);
});
Visual regression per locale (chụp screenshot so sánh layout RTL vs LTR) được đề cập ở chương E của series này. Bài này chỉ cover structural test.
Filter Chạy Theo Một Locale Cụ Thể
Khi debug một lỗi chỉ xảy ra với tiếng Việt, không cần chạy lại toàn bộ matrix. Dùng flag --project:
# Chỉ chạy locale tiếng Việt
npx playwright test --project="locale-vi-VN"
# Chỉ chạy locale tiếng Anh và Nhật
npx playwright test --project="locale-en-US" --project="locale-ja-JP"
# Chỉ chạy test RTL (nếu đặt tên project có rtl prefix)
npx playwright test --project="locale-ar*"
Convention đặt tên locale-{code} (ví dụ locale-vi-VN) không chỉ có lợi cho filter — nó còn hiện rõ trong Playwright report để biết test nào fail ở locale nào:
✓ [locale-en-US] › homepage.spec.ts:5 › homepage greeting (1.2s)
✓ [locale-vi-VN] › homepage.spec.ts:5 › homepage greeting (1.1s)
✗ [locale-ja-JP] › homepage.spec.ts:5 › homepage greeting (0.8s)
Expected: 'ようこそ'
Received: 'ようこそ!' ← translation file outdated
Trường hợp muốn skip một locale trong test cụ thể (ví dụ: feature chưa có bản dịch tiếng Nhật):
test('new checkout feature', async ({ page }, testInfo) => {
// Feature chưa có bản dịch ja-JP — skip tạm
test.skip(
testInfo.project.use.locale === 'ja-JP',
'Japanese translation pending — see ticket #1234'
);
// ... test logic
});
Use Cases
Bốn loại test hưởng lợi nhiều nhất từ pattern này:
i18n Validation
Xác minh mọi key translation được render đúng trên UI — không bị undefined, không hiện i18n key thay vì text (ví dụ: component hiện "auth.login.button" thay vì "Login"). Test này chạy toàn bộ các screen quan trọng với từng locale.
Format Verification
Date, number, currency — xác minh app dùng Intl API đúng cách thay vì hardcode format. Locale en-US phải thấy $1,234.56 không phải 1234.56 USD; vi-VN phải thấy đúng ký hiệu ₫.
Layout Integrity
Text tiếng Đức dài hơn tiếng Anh ~30-40% — button label có thể bị truncate. Text tiếng Nhật kanji ngắn hơn — layout có thể trông rỗng. Test kiểm tra element không overflow, không truncate với overflow: hidden làm mất text.
RTL Support
Arabic/Hebrew — xác minh dir="rtl" được set, icon không bị đảo sai (prev/next arrow), layout đối xứng đúng. Đây là loại bug thường không bị catch bằng unit test.
Limitations
Ba hạn chế cần biết trước khi áp dụng pattern:
Translation File Có Thể Outdated
Nếu test dùng chung file translation với app, mọi thay đổi translation sẽ break test — đây là hành vi đúng (test phát hiện thay đổi). Nhưng nếu ai đó cập nhật translation file mà không cập nhật test expectation, test fail vì lý do không phải bug. Cần có quy trình: khi translation key thay đổi, trigger test run để xác nhận không có regression.
Nếu team translation và team dev là khác nhau, cần có quy trình sync rõ ràng — translation file là contract giữa hai team.
Long-Text Languages
Tiếng Đức, Nga, một số ngôn ngữ Ấn Độ có text dài hơn nhiều so với tiếng Anh. Layout test cho những ngôn ngữ này cần được duy trì riêng — không thể dùng cùng assertion về chiều rộng element.
Pseudo-localization (thay text bằng text dài hơn giả, ví dụ "Login" → "[Ĺöğïñ]" với thêm ký tự giả) là kỹ thuật dùng sớm để catch layout bug trước khi có bản dịch thật. Đây là chủ đề nằm ngoài scope bài này.
Số Locale Tăng = Runtime Tăng Tuyến Tính
5 locale × 100 test = 500 test runs. 10 locale × 200 test = 2000 test runs. CI time scale theo. Cần cân nhắc: không phải mọi test đều cần chạy với mọi locale — chỉ test liên quan đến i18n mới cần đủ matrix. Test logic thuần (không có text, không có format) có thể chỉ cần 1 locale đại diện.
4 Pitfall Hay Gặp
1. Hardcode English Text Trong Assertion
// SAI — fail với mọi locale không phải en-US
await expect(page.getByRole('heading')).toHaveText('Welcome');
await page.getByRole('button', { name: 'Login' }).click();
// ĐÚNG — dùng fixture t hoặc getByTestId
await expect(page.getByRole('heading')).toHaveText(t.welcome);
await page.getByTestId('login-btn').click();
Đây là lỗi phổ biến nhất khi bắt đầu thêm locale mới vào project có sẵn — toàn bộ test suite chạy tốt với en-US bỗng fail với vi-VN.
2. Quên Timezone Đi Kèm Locale
// SAI — vi-VN locale nhưng timezone mặc định của CI (thường UTC)
{ name: 'locale-vi-VN', use: { locale: 'vi-VN' } }
// ĐÚNG — locale và timezone phải đi cùng nhau
{ name: 'locale-vi-VN', use: { locale: 'vi-VN', timezoneId: 'Asia/Ho_Chi_Minh' } }
Khi app hiển thị giờ local ("Đặt lịch lúc 10:00 sáng"), thiếu timezoneId làm test nhận giờ UTC thay vì giờ Việt Nam. Test pass trên máy local (UTC+7) nhưng fail trên CI (UTC). Bài 9 đã giải thích cơ chế — bài này nhắc lại vì i18n test hay bỏ sót điểm này.
3. Translation File Trong Test Outdated So Với App
// Tình huống: app đổi key từ "login" sang "sign_in"
// translations.json (app source): { "login": undefined, "sign_in": "Login" }
// translations.ts (test helper): vẫn dùng t.login → undefined → getByRole fails
// Phòng tránh: test import trực tiếp từ app source
import data from '../../src/i18n/translations.json'; // path đến app source
// KHÔNG copy translation vào thư mục tests/
// import data from './fixtures/translations.json'; // sẽ outdated
Nếu app và test ở 2 repository khác nhau, cần có quy trình publish translation package và pin version — tránh tình huống app nâng translation version mà test chưa update.
4. Dùng getByText Với Translated Text — Fragile
// getByText với translated text có vẻ đúng...
await page.getByText(t.login).click();
// Nhưng getByText match substring — nếu page có nhiều element chứa "Đăng nhập"
// (nav link, button, footer link), test sẽ click element đầu tiên tìm thấy.
// Hoặc nếu text thay đổi casing/whitespace, match thất bại.
// Tốt hơn: getByRole với name cho semantic rõ ràng
await page.getByRole('button', { name: t.login }).click(); // chỉ match button
await page.getByRole('link', { name: t.login }).click(); // chỉ match link
getByRole với name option chính xác hơn vì kết hợp semantic type (button/link/heading) với accessible name — tránh match nhầm element sai loại.
Quiz + Bài Tiếp
1. Test suite có 3 locale project: en-US, vi-VN, ja-JP. Một test dùng await page.getByText('Login').click(). Test chạy với project nào sẽ pass, project nào fail?
Đáp án
Chỉ pass với locale-en-US. vi-VN và ja-JP fail vì button label lần lượt là "Đăng nhập" và "ログイン" — không match string literal "Login". Sửa bằng cách dùng t fixture hoặc getByTestId.
2. Locale fixture trong fixtures.ts dùng testInfo.project.use.locale ?? 'en-US'. Tại sao có fallback 'en-US' thay vì throw lỗi khi locale là undefined?
Đáp án
Nếu test được chạy với project không có locale trong use (ví dụ project Browser thuần không set locale), testInfo.project.use.locale sẽ là undefined. Fallback 'en-US' đảm bảo fixture không crash. Nếu muốn strict hơn — bắt buộc mọi project phải set locale — thay ?? 'en-US' bằng lệnh throw: if (!locale) throw new Error('Project must set use.locale').
3. App hiển thị giá sản phẩm 999000 VND. Với locale vi-VN, đoạn code sau tính expected value như thế nào, và kết quả sẽ là gì?
new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }).format(999000);
Đáp án
VND không có đơn vị sub-cent (không dùng decimal). Intl.NumberFormat với VND thường trả về "999.000 ₫" — dấu chấm là thousand separator, không có phần decimal. Kết quả chính xác phụ thuộc ICU data của Node.js version đang chạy — có thể cần test trên Node version giống với CI để đảm bảo khớp.
4. Test có test.skip(!isRTL, ...) để bỏ qua test RTL với locale LTR. Điều gì xảy ra với test result của locale en-US và vi-VN?
Đáp án
Với en-US và vi-VN, isRTL là false, nên !isRTL là true — test bị skip. Playwright report sẽ hiện test với trạng thái skipped, không phải failed. Chỉ với locale ar-SA (và các RTL locale khác), isRTL là true — test thực sự chạy.
5. Project matrix có 5 locale. Developer thêm test logic thuần (tính toán không liên quan đến text hay format) và chạy với toàn bộ matrix. Vấn đề gì có thể xảy ra và cách khắc phục?
Đáp án
Test logic thuần chạy 5 lần (một per locale) dù kết quả giống nhau — lãng phí CI time. Cách khắc phục: đặt test không liên quan đến i18n vào project riêng (ví dụ core) không có locale config, hoặc dùng test.skip để chỉ chạy với một locale đại diện. Ví dụ: test.skip(testInfo.project.use.locale !== 'en-US', 'locale-agnostic test').
Bài Tiếp Theo
Bài 97: Global Setup (Legacy API) — mở nhóm A.11 Global Setup, giải thích cơ chế globalSetup / globalTeardown trong playwright.config.ts, khi nào dùng thay vì setup project.
