Danh sách bài viết

Bài 96: Multi-Locale Test Pattern

Bài 95 đã giải quyết bài toán cấu hình: đọc secret và config môi trường từ ENV. Bài này chuyển sang bài toán i18n: làm sao test app hỗ trợ nhiều ngôn ngữ mà không viết một bộ test riêng cho từng locale. Pattern gồm bốn thành phần chính: locale project matrix để chạy song song, translation data file dùng chung với app source, locale fixture để test tự động nhận bản dịch đúng, và chiến lược chọn locator không bị vỡ khi text thay đổi. Bài cũng cover assertion date/number format theo locale, kiểm tra RTL layout, và 4 pitfall phổ biến.

28/05/2026
0 lượt xem
1

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ùng getByRole vớ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ế localetimezoneId 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.

2

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.

3

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.

4

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 };
5

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.

6

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.

7

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 Không CTA button, form field, data display
getByRole + t.key Có (qua fixture) Khi muốn test accessible name đúng
getByLabel + t.key Có (qua fixture) Form input với label được dịch
getByText hardcode Không Không Tránh trong multi-locale test
8

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.

9

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.

10

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
});
11

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.

12

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.

13

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.

14

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 localeundefined?

Đá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, isRTLfalse, nên !isRTLtrue — 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), isRTLtrue — 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.