Danh sách bài viết

Bài 79: Patterns Chống Flaky — Fix Root Cause

Sau khi đã identify root cause (bài 78), bài này tổng hợp 12 patterns thực tế để fix flaky theo từng nguyên nhân cụ thể: từ race condition, locator không ổn định, network, animation, đến shared state, random data, time-dependent và resource contention. Kèm anti-pattern cần tránh, audit suite định kỳ, và pitfall khi áp dụng sai pattern.

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

Mục Tiêu Bài Học

Sau bài này, bạn sẽ:

  • Biết pattern cụ thể để fix 12 root cause flaky phổ biến nhất.
  • Phân biệt code anti-pattern và code đúng cho từng loại flaky.
  • Hiểu tại sao chỉ tăng timeout hoặc retry không phải là giải pháp.
  • Áp dụng audit suite để phát hiện flaky sớm.
  • Nhận ra 4 pitfall thường gặp khi áp dụng patterns.

Phạm vi: Bài này tập trung vào code-level fix cho từng root cause đã được phân loại ở bài 78 (diagnose-flaky). Không lặp lại kỹ thuật diagnose. Không lặp lại cấu hình retries (bài 74).

2

Pattern 1 — Race Condition (Timing)

Root cause: Test thực hiện action trước khi UI hoặc network sẵn sàng, dẫn đến kết quả khác nhau tùy tốc độ máy hoặc tải hệ thống.

Anti-pattern

// Hard-coded sleep — không biết 2000ms có đủ không
await page.waitForTimeout(2000);
await page.click('#submit');

Trên máy nhanh: qua. Trên CI chậm hoặc khi server lag: fail.

Best practices

1. Dùng assertion auto-wait — Playwright tự retry assertion tới khi pass hoặc timeout:

// Playwright retry assertion tự động (mặc định 5 giây)
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
await page.getByRole('button', { name: 'Submit' }).click();

2. Wait specific network event — chờ đúng response thay vì chờ mù:

// Chờ response của endpoint cụ thể trước khi assert
const [response] = await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/orders') && resp.status() === 200),
  page.getByRole('button', { name: 'Place Order' }).click(),
]);
expect(response.ok()).toBe(true);

3. waitFor với state cụ thể trên locator:

const dialog = page.getByRole('dialog');
// Chờ dialog thực sự visible trước khi tương tác
await dialog.waitFor({ state: 'visible' });
await dialog.getByLabel('Name').fill('Alice');

Quy tắc: Mỗi lần muốn dùng waitForTimeout, tự hỏi: "Tôi đang chờ điều kiện gì?" — rồi chờ đúng điều kiện đó.

3

Pattern 2 — Locator Instability

Root cause: Locator phụ thuộc vào vị trí DOM, class auto-generated, hoặc index — thay đổi mỗi khi UI refactor.

Anti-pattern

// Fragile: phụ thuộc vị trí trong DOM
await page.locator('ul.menu li:nth-child(3)').click();

// Fragile: class generated bởi CSS-in-JS (Tailwind, Emotion...)
await page.locator('.css-1a2b3c4').click();

Best practices

1. Semantic locator qua role và name:

// Ổn định: tìm theo role + accessible name
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('menuitem', { name: 'Profile' }).click();

2. data-testid cho dynamic UI khi không có accessible name phù hợp:

<!-- Trong component React/Vue/Angular -->
<button data-testid="btn-save-draft">Save Draft</button>
// Test sử dụng testid
await page.getByTestId('btn-save-draft').click();

Mặc định Playwright dùng attribute data-testid. Nếu project dùng attribute khác (vd data-cy), cấu hình testIdAttribute trong playwright.config.ts:

// playwright.config.ts
use: {
  testIdAttribute: 'data-cy',
},

Thứ tự ưu tiên khi chọn locator:

  1. getByRole — stable nhất, phản ánh accessibility
  2. getByLabel, getByPlaceholder, getByText — semantic
  3. getByTestId — explicit, không đổi khi refactor style
  4. CSS selector / XPath — chỉ dùng khi không có lựa chọn nào trên
4

Pattern 3 — Network Flakiness

Root cause: Test phụ thuộc trực tiếp vào external API — response time không ổn định, rate limit, hoặc outage ngắn.

Anti-pattern

// Gọi thẳng 3rd-party API từ test
test('display exchange rate', async ({ page }) => {
  await page.goto('/dashboard'); // app gọi api.exchangerate.host
  await expect(page.getByTestId('rate-usd-vnd')).toHaveText(/\d+/);
});

Best practices

1. Mock 3rd-party API bằng page.route():

test('display exchange rate', async ({ page }) => {
  // Intercept và trả về response cố định
  await page.route('**/api.exchangerate.host/**', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ rates: { VND: 25000 } }),
    });
  });
  await page.goto('/dashboard');
  await expect(page.getByTestId('rate-usd-vnd')).toHaveText('25000');
});

2. Test app retry logic — nếu app có retry network, test chính config đó:

test('retries failed network request', async ({ page }) => {
  let callCount = 0;
  await page.route('**/api/data', async route => {
    callCount++;
    if (callCount < 3) {
      await route.fulfill({ status: 503 });
    } else {
      await route.fulfill({ status: 200, body: JSON.stringify({ ok: true }) });
    }
  });
  await page.goto('/');
  await expect(page.getByTestId('data-loaded')).toBeVisible();
  expect(callCount).toBe(3); // app đã retry 2 lần
});

3. Tăng API timeout cho staging chậm — chỉ áp dụng khi staging inherently chậm, không phải giải pháp cho production:

// playwright.config.ts — chỉ cho staging environment
use: {
  actionTimeout: process.env.ENV === 'staging' ? 30_000 : 10_000,
},
5

Pattern 4 — Animation Timing

Root cause: Element đang trong trạng thái transition/animation khi test click — vị trí element chưa ổn định, click rơi vào sai tọa độ.

Anti-pattern

// Click ngay trong khi modal đang slide-in
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
// Đôi khi miss vì button chưa ổn định vị trí

Best practices

1. Trial click — kiểm tra element actionable trước khi click thật:

const confirmBtn = page.getByRole('dialog').getByRole('button', { name: 'Confirm' });
// trial: true không thực hiện click, chỉ verify element có thể click
await confirmBtn.click({ trial: true });
// Bây giờ mới click thật
await confirmBtn.click();

2. Disable animation toàn bộ qua CSS injection — cách triệt để nhất cho test environment:

// fixtures/base.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use) => {
    await page.addStyleTag({
      content: `
        *, *::before, *::after {
          animation-duration: 0s !important;
          animation-delay: 0s !important;
          transition-duration: 0s !important;
          transition-delay: 0s !important;
        }
      `,
    });
    await use(page);
  },
});

3. Config prefers-reduced-motion — nhiều app đã respect media query này:

// playwright.config.ts
use: {
  // Browser emulate prefers-reduced-motion: reduce
  // App code dùng @media (prefers-reduced-motion: reduce) sẽ tắt animation
  contextOptions: {
    reducedMotion: 'reduce',
  },
},

Nếu app dùng @media (prefers-reduced-motion: reduce) để tắt animation, config này đủ. Nếu không, cần CSS injection ở option 2.

6

Pattern 5 — Shared State

Root cause: Biến hoặc tài nguyên được chia sẻ giữa các test chạy song song — test A modify state, test B đọc giá trị bị lẫn.

Anti-pattern

// module-level variable — shared giữa tất cả tests trong worker
let createdUserId: string;

test('create user', async ({ request }) => {
  const resp = await request.post('/api/users', { data: { name: 'Alice' } });
  createdUserId = (await resp.json()).id; // race: test khác có thể overwrite
});

test('read user', async ({ request }) => {
  // flaky nếu 'create user' chưa chạy hoặc bị overwrite
  const resp = await request.get(`/api/users/${createdUserId}`);
  expect(resp.status()).toBe(200);
});

Best practices

1. Fixture-scope state — mỗi test có state riêng:

// fixtures/base.ts
export const test = base.extend<{ userId: string }>({
  userId: async ({ request }, use) => {
    const resp = await request.post('/api/users', { data: { name: 'Test User' } });
    const { id } = await resp.json();
    await use(id); // mỗi test nhận id riêng
    // cleanup
    await request.delete(`/api/users/${id}`);
  },
});

// test file
test('read user', async ({ request, userId }) => {
  const resp = await request.get(`/api/users/${userId}`);
  expect(resp.status()).toBe(200);
});

2. fullyParallel: true kết hợp với fixture isolation — đảm bảo không có shared mutable state implicit:

// playwright.config.ts
fullyParallel: true,
// Mỗi test chạy trong context riêng — không share browser state

3. Unique resource per worker qua workerIndex:

// fixtures/base.ts
export const test = base.extend({
  dbName: [async ({ workerIndex }, use) => {
    // Mỗi worker dùng database riêng
    const name = `testdb_worker_${workerIndex}`;
    await createDatabase(name);
    await use(name);
    await dropDatabase(name);
  }, { scope: 'worker' }],
});
7

Pattern 6 — Random Data

Root cause: Test tạo dữ liệu random mỗi lần chạy — khi debug không reproduce được, hoặc data ngẫu nhiên vi phạm constraint database.

Anti-pattern

// Email random mỗi run → unique constraint conflict nếu DB chưa cleanup
const email = `user_${Math.random().toString(36).slice(2)}@example.com`;
await page.getByLabel('Email').fill(email);

Best practices

1. Seed faker theo workerIndex — reproducible và không trùng giữa workers:

import { faker } from '@faker-js/faker';

// fixtures/base.ts
export const test = base.extend<{ seededFaker: typeof faker }>({
  seededFaker: async ({ workerIndex }, use) => {
    // Seed cố định per worker — reproducible khi debug
    faker.seed(workerIndex * 1000 + Date.now() % 1000);
    await use(faker);
  },
});

// test file
test('register user', async ({ page, seededFaker }) => {
  const email = seededFaker.internet.email();
  await page.getByLabel('Email').fill(email);
});

2. Unique ID prefix per worker — không cần faker, đơn giản hơn:

// fixtures/base.ts
export const test = base.extend<{ uniquePrefix: string }>({
  uniquePrefix: async ({ workerIndex }, use) => {
    // w0_1716854400000, w1_1716854400001, ...
    await use(`w${workerIndex}_${Date.now()}`);
  },
});

test('create product', async ({ page, uniquePrefix }) => {
  const productName = `Product_${uniquePrefix}`;
  await page.getByLabel('Product Name').fill(productName);
});

Lưu ý: Nếu dùng Date.now() làm seed thì seed thay đổi mỗi lần run — không hoàn toàn reproducible. Để debug flaky specific, lưu seed vào log và dùng fixed seed khi reproduce.

8

Pattern 7 — Time-Dependent

Root cause: Test logic phụ thuộc vào ngày giờ thực — test pass vào thứ 2-6 nhưng fail cuối tuần, hoặc fail vào một giờ nhất định do timezone.

Anti-pattern

// Test behavior "ngày làm việc" phụ thuộc real time
test('show business hours banner', async ({ page }) => {
  await page.goto('/');
  // Pass thứ 2-6 9h-17h, fail các thời điểm khác
  await expect(page.getByText('We are open')).toBeVisible();
});

Best practices

1. Mock time bằng page.clock.install() (v1.45+):

test('show business hours banner on weekday', async ({ page }) => {
  // Fix time: thứ 3, 10h sáng UTC
  await page.clock.install({ time: new Date('2025-06-10T10:00:00Z') });
  await page.goto('/');
  await expect(page.getByText('We are open')).toBeVisible();
});

test('show closed banner on weekend', async ({ page }) => {
  // Fix time: thứ 7
  await page.clock.install({ time: new Date('2025-06-07T10:00:00Z') });
  await page.goto('/');
  await expect(page.getByText('Closed')).toBeVisible();
});

2. Fix timezone qua timezoneId config — tránh flaky do CI server ở timezone khác:

// playwright.config.ts
use: {
  timezoneId: 'Asia/Ho_Chi_Minh',
},

Nếu test assertion dựa trên formatted date string, timezone phải nhất quán giữa dev machine và CI runner.

3. Advance clock — test scenario sau khoảng thời gian mà không chờ real time:

test('session expires after 30 minutes', async ({ page }) => {
  await page.clock.install({ time: new Date('2025-06-10T09:00:00Z') });
  await page.goto('/login');
  await login(page);
  // Giả lập 31 phút trôi qua
  await page.clock.fastForward('31:00');
  await page.reload();
  await expect(page.getByText('Session expired')).toBeVisible();
});
9

Pattern 8 — External Service

Root cause: Test gọi thẳng production API hoặc external service — outage, rate limit, hoặc thay đổi schema làm test fail không liên quan đến code thay đổi.

Anti-pattern

// Gọi production Stripe API trong E2E test
test('payment succeeds', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page.getByText('Payment successful')).toBeVisible();
  // Phụ thuộc Stripe sandbox availability
});

Best practices

1. Mock toàn bộ external API trong E2E test:

test('payment succeeds', async ({ page }) => {
  await page.route('**/api/stripe/charge', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ status: 'succeeded', id: 'ch_mock_123' }),
    });
  });
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page.getByText('Payment successful')).toBeVisible();
});

2. Contract test riêng — tách test verify external API contract ra khỏi E2E suite chính:

// tests/contract/stripe.contract.test.ts
// Chạy riêng, không thuộc E2E suite chính
// Chạy ít hơn (vd: 1 lần/ngày, không mỗi PR)
test('stripe charge endpoint contract', async ({ request }) => {
  // Hit actual Stripe test API
  const resp = await request.post('https://api.stripe.com/v1/charges', {
    headers: { Authorization: `Bearer ${process.env.STRIPE_TEST_KEY}` },
    form: { amount: '1000', currency: 'usd', source: 'tok_visa' },
  });
  expect(resp.status()).toBe(200);
  const body = await resp.json();
  expect(body).toHaveProperty('id');
  expect(body).toHaveProperty('status', 'succeeded');
});

Contract test chạy trong CI schedule riêng, không block PR merge.

10

Pattern 9 — Resource Contention

Root cause: Quá nhiều workers chạy song song trên CI runner ít CPU/RAM — browser process bị OOM, timeout do CPU starvation.

Anti-pattern

// playwright.config.ts — mặc định dùng 50% CPU
// Trên CI 2 CPU → 1 worker — okay
// Nhưng nếu force nhiều workers:
workers: 8, // CI 2 CPU sẽ crash hoặc flaky do OOM

Best practices

1. Điều chỉnh workers theo môi trường:

// playwright.config.ts
workers: process.env.CI ? 2 : '50%',
// CI: giới hạn 2 workers (safe cho runner 2 CPU, 4GB RAM)
// Local: dùng 50% CPU cores

2. Monitor memory trên CI runner — thêm step kiểm tra memory trước khi chạy test:

# .github/workflows/e2e.yml
- name: Check available memory
  run: |
    free -h
    nproc
    echo "Workers: ${{ vars.E2E_WORKERS || '2' }}"

- name: Run Playwright tests
  run: npx playwright test
  env:
    CI: true

3. Phân chia suite theo resource usage:

// playwright.config.ts
projects: [
  {
    name: 'heavy-tests',  // test upload file lớn, render phức tạp
    workers: 1,           // chạy tuần tự
    testMatch: '**/*.heavy.spec.ts',
  },
  {
    name: 'normal-tests',
    workers: process.env.CI ? 2 : '50%',
    testMatch: '**/*.spec.ts',
    testIgnore: '**/*.heavy.spec.ts',
  },
],
11

Pattern 10 — Browser Quirk

Root cause: Feature không được support đồng nhất trên tất cả browser — test assume cross-browser nhưng thực tế WebKit hoặc Firefox xử lý khác.

Anti-pattern

test('drag and drop file upload', async ({ page }) => {
  // WebKit có behavior khác với Chromium khi handle drag events
  const fileChooserPromise = page.waitForEvent('filechooser');
  await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer: ... });
  // Flaky trên WebKit
});

Best practices

1. Skip test có chủ đích cho browser không support:

test('custom drag drop', async ({ page, browserName }) => {
  test.skip(browserName === 'webkit', 'WebKit drag-drop behavior differs, tracked in #1234');
  // Test chỉ chạy trên Chromium và Firefox
  await page.goto('/upload');
  // ...
});

2. Feature detect runtime thay vì assume browser support:

test('clipboard paste', async ({ page }) => {
  // Check clipboard API available trước
  const hasClipboard = await page.evaluate(() =>
    typeof navigator.clipboard !== 'undefined'
  );
  test.skip(!hasClipboard, 'Clipboard API not available in this browser/context');

  await page.evaluate(() => navigator.clipboard.writeText('test data'));
  await page.getByRole('textbox').focus();
  await page.keyboard.press('Control+V');
  await expect(page.getByRole('textbox')).toHaveValue('test data');
});

3. Browser-specific test file qua project config:

// playwright.config.ts
projects: [
  {
    name: 'chromium-only',
    use: { ...devices['Desktop Chrome'] },
    testMatch: '**/*.chromium.spec.ts',
  },
  {
    name: 'cross-browser',
    use: { ...devices['Desktop Chrome'] },
    testIgnore: '**/*.chromium.spec.ts',
  },
  {
    name: 'cross-browser-firefox',
    use: { ...devices['Desktop Firefox'] },
    testIgnore: '**/*.chromium.spec.ts',
  },
],
12

Pattern 11 — Resource Setup Race

Root cause: beforeAll seed data async nhưng test đầu tiên bắt đầu trước khi seed hoàn thành — xuất hiện khi test chạy quá nhanh hoặc await bị thiếu.

Anti-pattern

test.describe('product tests', () => {
  test.beforeAll(async ({ request }) => {
    // Thiếu await — seed chạy background, test bắt đầu ngay
    request.post('/api/seed/products', { data: { count: 10 } });
  });

  test('list products', async ({ page }) => {
    await page.goto('/products');
    // Flaky: đôi khi seed chưa xong
    await expect(page.getByRole('listitem')).toHaveCount(10);
  });
});

Best practices

1. await đầy đủ trong beforeAll:

test.describe('product tests', () => {
  test.beforeAll(async ({ request }) => {
    // Phải await đến khi seed thực sự hoàn thành
    const resp = await request.post('/api/seed/products', { data: { count: 10 } });
    expect(resp.status()).toBe(201); // verify seed succeeded
  });

  test('list products', async ({ page }) => {
    await page.goto('/products');
    await expect(page.getByRole('listitem')).toHaveCount(10);
  });
});

2. Setup project để guarantee order — dùng project dependencies (đã đề cập ở bài 56):

// playwright.config.ts
projects: [
  {
    name: 'setup-db',
    testMatch: 'tests/global-setup.ts',
  },
  {
    name: 'e2e',
    dependencies: ['setup-db'], // e2e chỉ chạy sau setup-db done
    use: { storageState: 'playwright/.auth/admin.json' },
  },
],

Setup project chạy trong single worker, guarantee tất cả seed hoàn thành trước khi e2e bắt đầu.

13

Pattern 12 — Click Trên Element Ẩn

Root cause: Element tồn tại trong DOM (toBeAttached()) nhưng không visible — bị overlay bởi element khác, hoặc nằm ngoài viewport.

Anti-pattern

// Chỉ check attached — element có trong DOM nhưng có thể bị che
await expect(page.getByRole('button', { name: 'Delete' })).toBeAttached();
await page.getByRole('button', { name: 'Delete' }).click();
// Flaky: button bị overlay bởi sticky header

Best practices

1. toBeVisible() trước khi click — Playwright cũng auto-wait trong click() nhưng explicit assertion cho error message rõ hơn:

const deleteBtn = page.getByRole('button', { name: 'Delete' });
await expect(deleteBtn).toBeVisible();
// scrollIntoViewIfNeeded tự động trong Playwright click
await deleteBtn.click();

2. Scroll into view explicit nếu element bị ngoài viewport:

const deleteBtn = page.getByRole('button', { name: 'Delete' });
// Scroll element vào viewport trước
await deleteBtn.scrollIntoViewIfNeeded();
await expect(deleteBtn).toBeVisible();
await deleteBtn.click();

3. dispatchEvent escape hatch — chỉ dùng khi UI broken không thể click thường và đây là known limitation:

// Escape hatch: bypass Playwright actionability checks
// CHỈ dùng khi có lý do kỹ thuật rõ ràng và comment giải thích
await deleteBtn.dispatchEvent('click');
// Comment: button bị overlay bởi cookie banner trong test env,
// đã file bug #5678 — fix sẽ remove workaround này

Khi dùng dispatchEvent, thêm comment track bug và kế hoạch remove workaround.

14

Anti-Patterns Nhất Quyết Tránh

Bốn anti-pattern sau không fix flaky mà chỉ che giấu nó — debt tích lũy và harder to debug:

1. Hard-coded waitForTimeout()

// TRÁNH: timing magic number
await page.waitForTimeout(3000);
// Trên máy chậm 3000ms không đủ → vẫn flaky
// Trên máy nhanh 3000ms thừa → test chậm

Thay bằng event-based wait (Pattern 1).

2. Tăng retries để mask flaky

// playwright.config.ts
retries: 5, // TRÁNH: tăng cao chỉ để CI xanh

retries: 2 là hợp lý cho CI. Cao hơn nghĩa là đang che root cause. Test fail 3/5 lần = flaky, không phải passed.

3. Skip test thay vì fix

test.skip('checkout flow', async ({ page }) => {
  // TODO: flaky, investigate later
});

Skip tạo tech debt. Nếu phải skip, thêm issue tracker link và deadline:

test.skip(true, 'Flaky due to race in payment API — tracked in #1234, fix by 2026-06-01');

4. Comment out assertion

test('user profile', async ({ page }) => {
  await page.goto('/profile');
  // await expect(page.getByText('Email verified')).toBeVisible(); // flaky
  await expect(page.getByText(user.name)).toBeVisible();
});

Test thiếu assertion critical = test không valid. Fix assertion thay vì bỏ qua.

15

Pattern Audit Suite

Sau khi apply patterns, cần audit định kỳ để phát hiện flaky mới trước khi chúng merge vào main.

Chạy repeat-each trên critical path

# Chạy test có tag @critical 10 lần liên tiếp
npx playwright test --repeat-each=10 --grep "@critical"

# Xem số pass/fail trên tổng số
# Flaky rate = (số fail) / (total runs)

Script audit hàng tuần trên CI

# .github/workflows/flaky-audit.yml
name: Flaky Audit
on:
  schedule:
    - cron: '0 2 * * 1'  # Thứ 2 hàng tuần, 2h sáng

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Flaky audit
        run: |
          npx playwright test \
            --repeat-each=5 \
            --reporter=json \
            --output=flaky-report.json
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: flaky-report
          path: flaky-report.json

Phân tích kết quả

Sau audit, đếm test có ít nhất 1 lần fail trong N lần chạy:

# Parse JSON report — tìm test có status mixed (pass + fail)
node -e "
const r = require('./flaky-report.json');
const flaky = r.suites.flatMap(s => s.specs)
  .filter(spec => spec.tests.some(t => t.status !== 'passed'));
console.log('Flaky tests:', flaky.map(s => s.title));
"

Track flaky rate theo thời gian

Lưu flaky count vào time-series (Datadog, Grafana, hoặc đơn giản là CSV) để thấy xu hướng tăng/giảm sau mỗi sprint.

16

Test Design Principles

Ngoài các pattern fix theo root cause, thiết kế test đúng từ đầu giảm flaky probability:

1 test — 1 assertion concept

Test có nhiều independent assertions dễ flaky hơn vì nhiều failure point:

// Fragile: nhiều concerns trong 1 test
test('user flow', async ({ page }) => {
  await login(page);
  await expect(page.getByText('Welcome')).toBeVisible();       // concern 1
  await expect(page.getByRole('navigation')).toBeVisible();    // concern 2
  await page.getByRole('link', { name: 'Profile' }).click();   // concern 3
  await expect(page.getByText('Edit Profile')).toBeVisible();  // concern 4
});

// Tốt hơn: tách thành test riêng theo concern
test('login shows welcome message', async ({ page }) => { ... });
test('login shows navigation', async ({ page }) => { ... });
test('profile page accessible from nav', async ({ page }) => { ... });

Isolation strict

Mỗi test tự tạo data cần thiết, không dùng data của test khác. Thứ tự chạy không ảnh hưởng kết quả.

Cleanup mandatory

test.afterEach(async ({ request }, testInfo) => {
  // Cleanup ngay cả khi test fail
  if (testInfo.attachments.some(a => a.name === 'created-user-id')) {
    const id = testInfo.attachments.find(a => a.name === 'created-user-id')!.body!.toString();
    await request.delete(`/api/users/${id}`).catch(() => {}); // silent fail on cleanup
  }
});

No order dependency

Playwright chạy tests theo order không guaranteed khi fullyParallel: true. Test không được depend vào test khác chạy trước.

17

Limitation & Trade-off

Một số flaky là inherent

3rd-party API (payment gateway, email service, SMS provider) có uptime < 100%. Không thể mock tất cả và vẫn đảm bảo E2E coverage thực sự. Cách tiếp cận:

  • Mock trong E2E suite chính → CI ổn định.
  • Contract test chạy riêng định kỳ → phát hiện breaking change từ provider.
  • Manual smoke test trên staging trước release → verify end-to-end thực sự.

Trade-off: fix vs accept vs skip

Scenario Quyết định
Flaky do code của mình (race, locator, state) Fix root cause
Flaky do 3rd-party unpredictable Mock trong E2E + contract test riêng
Flaky do feature browser-specific Skip với comment + issue tracker
Flaky do CI environment (resource) Tune workers config
Flaky không reproduce được Thêm trace, repeat-each để diagnose (bài 78)

Mock everything = risk

Nếu mock quá nhiều, E2E test mất giá trị — không catch real integration bug. Cân bằng: mock external dependencies, nhưng test integration giữa các service nội bộ với real HTTP khi có thể.

18

Pitfalls

Pitfall 1 — Áp pattern sai root cause

Flaky do network nhưng fix bằng semantic locator → vẫn flaky. Cần identify root cause chính xác (bài 78) trước khi chọn pattern. Dấu hiệu: apply pattern xong, chạy --repeat-each=10 vẫn thấy fail.

Pitfall 2 — Tăng timeout thay vì disable animation

// Sai hướng: tăng actionTimeout để "chờ animation xong"
use: { actionTimeout: 15_000 }, // từ 5s lên 15s

// Đúng hướng: tắt animation (Pattern 4)
// Timeout lớn = chờ animation thật → chậm + mask root cause

Pitfall 3 — Mock tất cả → miss real bug

Nếu mock cả internal API endpoint, test không phát hiện được backend regression. Mock level phù hợp:

  • External 3rd-party: mock toàn bộ.
  • Internal API: chỉ mock khi cần isolate specific behavior, không phải mặc định.

Pitfall 4 — Fix trial-and-error không hiểu root cause

Thêm waitForTimeout(500) → pass. Commit. Tuần sau flaky lại. Rồi tăng lên 1000ms. Cycle tiếp diễn.

Pattern đúng: reproduce bằng --repeat-each, attach trace, xác định root cause, sau đó chọn pattern từ bài này. Không "thử cho qua".

19

Quiz

Câu 1

Test fail không nhất quán sau page.click('#open-modal') — đôi khi các element trong modal không interact được ngay. Nguyên nhân có thể nhất và fix phù hợp là gì?

Xem đáp án

Nguyên nhân có thể nhất: animation timing — modal đang slide-in và element chưa ổn định vị trí. Fix: disable animation qua CSS injection (transition-duration: 0s) hoặc dùng locator.waitFor({ state: 'visible' }) trên element cụ thể trong modal trước khi tương tác.

Câu 2

Code sau có vấn đề gì?

test.describe('cart tests', () => {
  let cartId: string;
  test.beforeAll(async ({ request }) => {
    const resp = await request.post('/api/carts');
    cartId = (await resp.json()).id;
  });
  test('add item to cart', async ({ page }) => { /* dùng cartId */ });
  test('checkout cart', async ({ page }) => { /* dùng cartId */ });
});
Xem đáp án

Module-level biến cartId shared giữa các tests. Khi chạy parallel trong fullyParallel: true, nếu nhiều describe block được assign cho cùng worker, value có thể bị overwrite. Fix: dùng fixture trả về cartId riêng cho mỗi test, hoặc đảm bảo describe block chạy trong serial mode nếu intentional dependency.

Câu 3

Test sau flaky trên CI Linux nhưng pass trên macOS dev machine. CI dùng timezone UTC, dev machine dùng Asia/Ho_Chi_Minh. Test assert: await expect(page.getByText('Today: 28/05/2026')).toBeVisible(). Fix như thế nào?

Xem đáp án

Hai cách: (1) Fix timezone trong config: use: { timezoneId: 'Asia/Ho_Chi_Minh' } để CI và dev cùng timezone. (2) Mock time bằng page.clock.install({ time: new Date('2026-05-28T00:00:00+07:00') }) để test không phụ thuộc system time của CI runner.

Câu 4

Team quyết định chạy retries: 5 để CI luôn xanh. Điều gì xảy ra về lâu dài và vấn đề cụ thể với approach này?

Xem đáp án

Vấn đề: (1) CI time tăng nhiều — 1 flaky test với 5 retries mất 6x thời gian. (2) Report không trung thực — test "passed" nhưng thực ra failed 4/6 lần = flaky. (3) Root cause không được fix → debt tích lũy. (4) --fail-on-flaky-tests (v1.45+) sẽ catch được case này, nhưng team bypass bằng cách tăng retries. Đúng: giữ retries: 2 tối đa cho CI, fix root cause theo patterns.

Câu 5

Test cần tạo email unique cho mỗi lần chạy. Code hiện tại: const email = `test_${Math.random()}@example.com`. Vấn đề là gì khi debug và cách fix?

Xem đáp án

Vấn đề: (1) Mỗi run tạo email khác nhau → khi flaky, không reproduce được email nào đã dùng. (2) Nếu cleanup không chạy, lần sau có thể conflict unique constraint database. Fix: dùng faker.seed(workerIndex) để reproducible per worker, hoặc dùng prefix w${workerIndex}_${timestamp} và log email vào test attachment để có thể inspect khi fail.

20

Bài Tiếp Theo

Bài 80: --repeat-each — Chạy Lặp Để Phát Hiện Flaky — cú pháp, kết hợp với --grep, đọc kết quả repeat run, tích hợp vào CI audit workflow.