Danh sách bài viết

Bài 76: testInfo.retry — Branch Logic Theo Retry Attempt

testInfo.retry là số nguyên 0-based cho biết đang chạy attempt thứ mấy: 0 = lần đầu, 1 = retry đầu tiên. Bài này đi vào 5 pattern thực tế để phân nhánh xử lý — log verbose, tăng timeout, đổi fixture, annotation, artifact — cùng với các pitfall cần tránh khi dùng kỹ thuật này.

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 testInfo.retry là gì và tại sao nó bắt đầu từ 0.
  • Hiểu rõ mỗi retry chạy như thế nào — worker, context, fixture.
  • Áp dụng được 5 pattern branch logic theo attempt: verbose log, tăng timeout, đổi fixture, annotation, artifact on final retry.
  • Phân biệt testInfo.retry (current attempt) với testInfo.project.retries (max configured).
  • Nhận ra 4 pitfall thường gặp khi dùng testInfo.retry.

Phạm vi: Bài này tập trung vào cách sử dụng testInfo.retry bên trong test, fixture, và hook. Cấu hình retries và cơ chế outcome đã được đề cập trong các bài 74 và 75.

2

testInfo.retry Là Gì

testInfo.retry là số nguyên cho biết đang chạy attempt thứ mấy tính từ 0:

  • 0 — lần chạy đầu tiên (không phải retry).
  • 1 — retry đầu tiên (attempt thứ 2).
  • 2 — retry thứ hai (attempt thứ 3).
  • ... và tiếp tục đến khi test pass hoặc hết retry.

Cú pháp truy cập:

test('example', async ({ page }, testInfo) => {
  if (testInfo.retry > 0) {
    console.log(`Attempt ${testInfo.retry + 1} (retry ${testInfo.retry})`);
  }
  // ...
});

testInfo được truyền qua tham số thứ hai của hàm test. Ngoài test body, nó cũng có thể truy cập trong beforeEach, afterEach, và định nghĩa fixture (xem mục 6).

Lưu ý: nếu không cấu hình retries hoặc retries: 0, testInfo.retry luôn là 0 vì test chỉ chạy đúng một lần và không có attempt thứ hai.

3

Behavior Của Mỗi Retry

Mỗi retry không phải là tiếp tục từ điểm dừng — nó là một run mới hoàn toàn:

  • Fresh worker: Playwright có thể spawn worker mới cho retry (tùy config).
  • Fresh browser context: Cookie, localStorage, session đều bị reset.
  • Fresh fixture: Mọi fixture (bao gồm page) được khởi tạo lại từ đầu.
  • Không partial retry: Test chạy lại từ đầu đến cuối — không thể skip bước đã pass.

Sơ đồ flow khi retries: 2:

testInfo.retry = 0  →  [beforeAll] [beforeEach] [test body] [afterEach] [afterAll]
                         ↓ fail
testInfo.retry = 1  →  [beforeAll?] [beforeEach] [test body] [afterEach] [afterAll?]
                         ↓ fail
testInfo.retry = 2  →  [beforeAll?] [beforeEach] [test body] [afterEach] [afterAll?]
                         ↓ fail → outcome = 'failed'
                         ↓ pass → outcome = 'flaky' (nếu retry ≥ 1 pass)

beforeAllafterAll chạy lại hay không phụ thuộc vào worker reuse. Nếu retry dùng cùng worker, beforeAll không chạy lại. Nếu worker mới, beforeAll chạy lại.

4

Pattern: Verbose Log On Retry

Mục đích: bật thêm network logging chỉ khi retry để thu thập thông tin debug mà không làm noise ở lần chạy bình thường.

test('flaky network', async ({ page }, testInfo) => {
  if (testInfo.retry > 0) {
    page.on('request', req => console.log('REQ:', req.url()));
    page.on('response', res => console.log('RES:', res.url(), res.status()));
  }

  await page.goto('/dashboard');
  await expect(page.locator('h1')).toHaveText('Dashboard');
});

Ở lần chạy đầu (retry === 0): test chạy bình thường, không có log request/response. Nếu fail và retry, lần chạy kế tiếp (retry === 1) bật listener để in ra toàn bộ traffic — giúp xác định request nào bị lỗi.

Pattern này đặc biệt hữu ích khi test fail do network intermittent và bạn cần biết chính xác response nào trả về status không mong muốn.

Có thể áp dụng tương tự với console event của page:

if (testInfo.retry > 0) {
  page.on('console', msg => console.log(`[BROWSER ${msg.type()}]`, msg.text()));
  page.on('pageerror', err => console.error('[PAGE ERROR]', err.message));
}
5

Pattern: Tăng Timeout Trên Retry

Mục đích: khi test fail do action chậm (API lag, render chậm), tăng timeout ở retry để phân biệt "test logic sai" với "môi trường đôi khi chậm".

test('slow API', async ({ page }, testInfo) => {
  const apiTimeout = 10_000 + testInfo.retry * 5_000;
  // retry 0: 10s, retry 1: 15s, retry 2: 20s
  await page.goto('/dashboard', { timeout: apiTimeout });

  const saveBtn = page.locator('[data-testid="save-button"]');
  await saveBtn.waitFor({ timeout: apiTimeout });
  await saveBtn.click();

  await expect(page.locator('.success-message')).toBeVisible({
    timeout: apiTimeout,
  });
});

Công thức baseTimeout + retry * increment cho timeout tăng tuyến tính qua các attempt. Cách này giúp test không fail ngay do network latency nhất thời, trong khi vẫn giữ timeout hợp lý ở lần đầu.

Lưu ý: Pattern này chỉ hợp lý khi có bằng chứng test fail vì timeout, không phải vì logic sai. Nếu tăng timeout chỉ để test "qua" mà không rõ nguyên nhân thì đang mask vấn đề thực sự.

6

Pattern: Fixture Variant Theo Attempt

Mục đích: dùng real service ở attempt đầu, chuyển sang mock ở retry để cô lập xem test fail do service bên ngoài hay do logic test.

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

export const test = base.extend<{ apiClient: typeof realClient }>({
  apiClient: async ({}, use, testInfo) => {
    const client = testInfo.retry > 0 ? mockClient : realClient;
    await use(client);
  },
});

Test sử dụng fixture này:

// checkout.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';

test('place order', async ({ page, apiClient }) => {
  await apiClient.seedProducts([{ id: 1, name: 'Item A', price: 100 }]);
  await page.goto('/checkout');
  await page.locator('[data-testid="buy-btn"]').click();
  await expect(page.locator('.order-confirmation')).toBeVisible();
});

Khi test fail ở attempt đầu với realClient, retry sẽ dùng mockClient. Nếu retry pass với mock, khả năng cao là service thật đang có vấn đề. Nếu retry vẫn fail với mock, vấn đề nằm ở logic test.

Cân nhắc: Thay đổi fixture giữa các attempt làm cho behavior test không nhất quán. Chỉ dùng khi có mục tiêu rõ ràng là cô lập nguyên nhân, và kết quả retry với mock không nên tính là "test pass thật sự".

7

Pattern: Annotation Retry Attempt

Mục đích: ghi chú vào test report số attempt đã thực hiện, giúp debug sau này khi xem HTML report.

test.beforeEach(async ({}, testInfo) => {
  if (testInfo.retry > 0) {
    testInfo.annotations.push({
      type: 'retry',
      description: `Attempt ${testInfo.retry + 1}`,
    });
  }
});

Annotation được hiển thị trong HTML report bên dưới tên test, cho biết test đã chạy bao nhiêu lần trước khi có kết quả hiện tại. Khi xem report sau, đây là tín hiệu nhanh để nhận biết test đã trải qua retry.

Bạn cũng có thể thêm annotation với thông tin môi trường để so sánh giữa các attempt:

test.beforeEach(async ({}, testInfo) => {
  if (testInfo.retry > 0) {
    testInfo.annotations.push({
      type: 'retry',
      description: `Attempt ${testInfo.retry + 1} — ${new Date().toISOString()}`,
    });
  }
});
8

Pattern: Artifact Chỉ Ở Final Retry

Mục đích: tránh tích lũy screenshot/trace từ mọi attempt (tốn storage), chỉ lưu artifact ở attempt cuối khi test vẫn fail.

test.afterEach(async ({ page }, testInfo) => {
  const isFinalAttempt = testInfo.retry === testInfo.project.retries;
  if (testInfo.status !== 'passed' && isFinalAttempt) {
    await page.screenshot({
      path: `final-fail/${testInfo.title.replace(/\W/g, '_')}.png`,
      fullPage: true,
    });
  }
});

testInfo.project.retries là số retry tối đa được config (ví dụ 2 nếu config retries: 2). Khi testInfo.retry === testInfo.project.retries, đây là attempt cuối cùng có thể chạy.

Nếu muốn lưu artifact ở mọi attempt fail (không chỉ cuối), bỏ điều kiện isFinalAttempt. Nhưng cần đặt tên file khác nhau để tránh overwrite:

test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== 'passed') {
    await page.screenshot({
      path: `failures/${testInfo.title.replace(/\W/g, '_')}-attempt${testInfo.retry}.png`,
    });
  }
});
9

testInfo.retry vs testInfo.project.retries

Hai field này thường bị nhầm lẫn vì tên gần giống nhau:

Property Type Ý nghĩa Ví dụ
testInfo.retry number Số thứ tự của attempt hiện tại (0-based) 0, 1, 2
testInfo.project.retries number Số retry tối đa được cấu hình trong project 2 nếu config retries: 2

Với config retries: 2 (tổng 3 attempt):

// Attempt 1 (lần đầu):
testInfo.retry            // 0
testInfo.project.retries  // 2

// Attempt 2 (retry thứ 1):
testInfo.retry            // 1
testInfo.project.retries  // 2

// Attempt 3 (retry thứ 2 — cuối cùng):
testInfo.retry            // 2
testInfo.project.retries  // 2
// → testInfo.retry === testInfo.project.retries → đây là final attempt

testInfo.project.retries luôn là hằng số trong một run (giá trị lấy từ config). testInfo.retry tăng dần qua mỗi attempt.

Cách kiểm tra có phải final attempt không:

const isFinalAttempt = testInfo.retry === testInfo.project.retries;
// hoặc nếu retries = 0 (không retry):
// testInfo.retry = 0, testInfo.project.retries = 0 → isFinalAttempt = true ✓
10

Pitfalls

Pitfall 1: Branch logic làm test luôn pass trên retry — flaky bị mask

Khi logic retry thay đổi assertion hoặc bỏ qua bước kiểm tra, test có thể pass trên retry dù vấn đề vẫn còn. Outcome sẽ là flaky thay vì failed — nhưng đây là flaky nhân tạo, không phản ánh thực trạng.

// SAI: skip assertion khi retry → test luôn pass ở retry
test('checkout', async ({ page }, testInfo) => {
  await page.goto('/checkout');
  await page.locator('[data-testid="buy-btn"]').click();

  if (testInfo.retry === 0) {
    // Chỉ check ở lần đầu — retry sẽ luôn pass
    await expect(page.locator('.confirmation')).toBeVisible();
  }
});

Pitfall 2: Quên rằng testInfo.retry không khả dụng ngoài test/fixture/hook

testInfo chỉ có trong phạm vi test function, beforeEach, afterEach, và fixture setup. Không thể dùng ở thời điểm config (playwright.config.ts) hay ngoài test context.

// SAI: testInfo không tồn tại ở đây
export default defineConfig({
  // Không có testInfo ở đây — sẽ lỗi runtime
  timeout: someFunction(testInfo?.retry),
});

// ĐÚNG: dùng trong test body hoặc fixture
test('example', async ({}, testInfo) => {
  const delay = testInfo.retry * 1000;
  await page.waitForTimeout(delay);
});

Pitfall 3: Nhầm retry (number) với retries (config)

testInfo.retry là attempt hiện tại (0, 1, 2...). testInfo.project.retries là số tối đa được config. Viết điều kiện testInfo.retry === testInfo.retries sẽ báo lỗi TypeScript vì testInfo.retries không tồn tại — cần dùng testInfo.project.retries.

// SAI: testInfo.retries không tồn tại
if (testInfo.retry === testInfo.retries) { ... }  // TypeScript error

// ĐÚNG
if (testInfo.retry === testInfo.project.retries) { ... }

Pitfall 4: Assertion khác nhau giữa các attempt

Nếu assertion thay đổi theo retry (ví dụ: expect text khác nhau), test không còn kiểm tra cùng một điều qua các attempt. Kết quả test sẽ không có ý nghĩa — không biết test đang verify điều gì.

// SAI: assertion khác nhau theo retry
test('product page', async ({ page }, testInfo) => {
  await page.goto('/product/1');
  const expectedTitle = testInfo.retry === 0 ? 'Product A' : 'Product';
  await expect(page.locator('h1')).toHaveText(expectedTitle); // verify cái gì?
});
11

Quiz

Câu 1. Config retries: 3. Test fail ở attempt đầu và chạy sang retry. Giá trị của testInfo.retrytestInfo.project.retries ở lần chạy thứ hai là bao nhiêu?

Đáp án

testInfo.retry = 1 (attempt thứ hai, 0-based). testInfo.project.retries = 3 (config không thay đổi). Đây chưa phải final attempt vì 1 !== 3 — còn 2 retry nữa có thể chạy.

Câu 2. Bạn muốn chụp screenshot chỉ ở attempt cuối cùng khi test fail. Điều kiện nào đúng?

Đáp án

testInfo.status !== 'passed' && testInfo.retry === testInfo.project.retries. Điều kiện đầu đảm bảo chỉ chụp khi fail (không chụp khi pass). Điều kiện sau đảm bảo đây là attempt cuối. Nếu chỉ kiểm tra testInfo.status !== 'passed' sẽ chụp ở mọi attempt fail, bao gồm cả trung gian.

Câu 3. Đoạn code sau có vấn đề gì?

test('login', async ({ page }, testInfo) => {
  await page.goto('/login');
  if (testInfo.retry === 0) {
    await expect(page.locator('input[name=email]')).toBeVisible();
  }
  await page.fill('input[name=email]', '[email protected]');
  await page.click('[type=submit]');
});
Đáp án

Assertion toBeVisible() chỉ chạy ở attempt đầu, retry sẽ skip assertion này và luôn tiến đến fill ngay cả khi input không tồn tại. Nếu trang lỗi khiến input không xuất hiện, retry sẽ fail ở fill thay vì báo lỗi rõ ràng hơn ở assertion. Nên bỏ điều kiện retry === 0 để assertion chạy ở mọi attempt.

Câu 4. Khi dùng pattern fixture variant (realClient lần đầu, mockClient khi retry), nếu retry pass với mockClient, outcome là gì? Đây có phải tín hiệu test đang hoạt động đúng không?

Đáp án

Outcome là flaky (fail lần đầu, pass sau retry). Không thể kết luận test đang hoạt động đúng — pass với mockClient chỉ có nghĩa là logic test không phụ thuộc vào data từ real service, hoặc mock trả về data phù hợp hơn. Đây là tín hiệu để điều tra service thật, không phải để đóng ticket.

Câu 5. testInfo.retry có giá trị bao nhiêu khi không cấu hình retries (mặc định)?

Đáp án

0. Khi retries không được cấu hình hoặc bằng 0, test chỉ chạy một lần duy nhất. Không có attempt thứ hai nên testInfo.retry luôn là 0.

12

Bài Tiếp Theo

Bài 77: --fail-on-flaky-tests — flag này thay đổi exit code khi có flaky test, cách dùng trong CI và các trường hợp cần cân nhắc trước khi bật.