Danh sách bài viết

Bài 75: Test Outcomes — Passed / Flaky / Failed / Skipped

Sau khi test kết thúc (kể cả sau retry), Playwright tổng hợp kết quả thành một trong 5 outcome: passed, flaky, failed, skipped, expected-skipped. Bài này giải thích cơ chế phân loại, bảng outcome matrix, cách đọc testInfo, ý nghĩa CI exit code, và các pitfall thường gặp.

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

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

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

  • Nắm rõ 5 outcome Playwright tổng hợp sau khi test hoàn tất: passed, flaky, failed, skipped, expected-skipped.
  • Phân biệt expectedStatus (dev kỳ vọng) và status (kết quả thực tế của mỗi attempt).
  • Đọc được outcome matrix — biết tổ hợp nào ra outcome nào.
  • Hiểu tại sao flaky có exit code 0 theo mặc định và cờ --fail-on-flaky-tests (v1.45) thay đổi điều đó.
  • Sử dụng testInfo.statustest.outcome() đúng ngữ cảnh.
  • Phân biệt timedOut với failed khi xử lý trong afterEach.
  • Tránh 4 pitfall về outcome mà người mới hay gặp.

Phạm vi: Bài này tập trung vào cơ chế phân loại outcome sau khi test kết thúc. Cấu hình retries đã có ở bài 74. test.fail() deep dive thuộc series Cơ Bản.

2

Tổng Quan 5 Outcome

Playwright phân loại kết quả cuối cùng của mỗi test thành 5 outcome. Outcome được tính sau khi tất cả attempt (bao gồm retry) kết thúc, không phải sau từng attempt riêng lẻ.

Outcome Ý nghĩa
passed Test pass theo đúng kỳ vọng — pass ở attempt đầu tiên, hoặc test.fail() + kết quả fail.
flaky Fail ở attempt đầu, nhưng pass ở một retry tiếp theo. Tín hiệu test không ổn định.
failed Fail ở tất cả attempt (bao gồm cả retry). Hoặc test có test.fail() nhưng lại pass.
skipped Test không chạy — do test.skip() tường minh, điều kiện test.skip(condition), hoặc dependency fail dẫn đến skip ngầm.
expected-skipped Skipped theo đúng kỳ vọng — thường xảy ra khi project setup fail, test chính bị skip và được đánh dấu là expected behavior.

Trong thực tế hàng ngày, 3 outcome bạn sẽ gặp thường xuyên là passed, flaky, và failed. skipped xuất hiện khi dùng test.skip(). expected-skipped ít gặp hơn, chủ yếu trong setup/teardown project dependencies.

3

expectedStatus vs status

Để hiểu outcome matrix ở mục tiếp theo, cần phân biệt rõ hai khái niệm:

expectedStatus — kết quả mà developer kỳ vọng test sẽ có:

  • Mặc định là 'passed' cho mọi test thông thường.
  • Đổi thành 'failed' khi dùng test.fail() — developer khai báo "tôi biết test này sẽ fail, và đó là hành vi đúng".

status — kết quả thực tế của một attempt khi chạy:

  • 'passed' — test chạy qua mà không có assertion fail hoặc exception.
  • 'failed' — assertion fail hoặc exception trong test body.
  • 'timedOut' — test vượt timeout (timeout là subset của failed, nhưng có status riêng).
  • 'skipped' — test bị skip trước khi chạy.
  • 'interrupted' — process bị kill hoặc Ctrl+C trong khi đang chạy.

Outcome được suy ra từ tổ hợp expectedStatus + chuỗi status qua các attempt. Đây là lý do outcome khác với status từng attempt.

// test.fail() thay đổi expectedStatus từ 'passed' → 'failed'
test('this feature is broken', async ({ page }) => {
  test.fail(); // expectedStatus = 'failed'
  await page.goto('/broken-page');
  await expect(page.locator('h1')).toHaveText('Hello'); // sẽ fail
  // Kết quả: status='failed', expectedStatus='failed' → outcome='passed'
});
4

Bảng Outcome Matrix

Bảng đầy đủ tất cả tổ hợp expectedStatus × kết quả attempt → outcome:

expectedStatus Kết quả thực tế Outcome Giải thích
passed Pass ngay attempt đầu passed Normal happy path.
passed Fail attempt đầu, pass sau retry flaky Pass cuối cùng, nhưng không ổn định.
passed Fail tất cả attempt failed Không pass dù có retry.
failed (test.fail) Fail như kỳ vọng passed Dev biết test này fail → behavior đúng.
failed (test.fail) Pass ngoài kỳ vọng failed Dev kỳ vọng fail nhưng lại pass → unexpected.
bất kỳ Skipped (test.skip) skipped Test không chạy.

Điểm hay nhầm nhất: khi dùng test.fail() và test thực sự fail, outcome là passed (không phải failed). Ngược lại, nếu test.fail() được dùng nhưng test lại pass — đó là outcome failed vì behavior không khớp kỳ vọng.

5

Flaky Detection

Playwright xác định flaky theo logic đơn giản: test fail ít nhất một attempt, nhưng pass ít nhất một attempt (trong cùng run). Không có ngưỡng phức tạp hơn.

retries: 2 (tổng 3 attempt)

Attempt 1: FAILED
Attempt 2: FAILED
Attempt 3: PASSED  ← có ít nhất 1 pass → flaky

Attempt 1: FAILED
Attempt 2: PASSED  ← cũng flaky (không cần chạy hết)
(Attempt 3 không chạy nữa vì đã pass)

Attempt 1: FAILED
Attempt 2: FAILED
Attempt 3: FAILED  ← tất cả fail → failed (không phải flaky)

--fail-on-flaky-tests (thêm từ v1.45): treat flaky như failed. Mặc định, flaky có exit code 0 (coi như pass). Với flag này, flaky trả exit code 1.

# Mặc định: flaky không block CI
npx playwright test

# v1.45+: flaky block CI như failed
npx playwright test --fail-on-flaky-tests

Hoặc set trong config:

// playwright.config.ts
export default defineConfig({
  retries: 2,
  // v1.45+
  // @ts-ignore nếu TypeScript chưa nhận type mới
  failOnFlakyTests: true,
});

Ý nghĩa thực tế: Không nên bỏ qua flaky. Test flaky là dấu hiệu của logic không ổn định hoặc race condition trong code, không phải may rủi thuần túy. Bài 76 sẽ đào sâu cách dùng testInfo.retry để phân nhánh xử lý theo attempt.

6

Truy Cập Outcome Trong testInfo

testInfo expose cả hai giá trị statusexpectedStatus. Đây là nơi đọc chúng trong afterEach:

import { test, expect } from '@playwright/test';

test.afterEach(async ({}, testInfo) => {
  console.log('status:', testInfo.status);           // kết quả attempt hiện tại
  console.log('expectedStatus:', testInfo.expectedStatus); // 'passed' hoặc 'failed'
  console.log('retry:', testInfo.retry);             // 0 = attempt đầu, 1 = retry đầu tiên

  // Kiểm tra có unexpected outcome không
  if (testInfo.status !== testInfo.expectedStatus) {
    console.warn(`Unexpected outcome: ${testInfo.status} (expected ${testInfo.expectedStatus})`);
  }
});

Lưu ý quan trọng: Trong afterEach, testInfo.status là status của attempt hiện tại — không phải outcome tổng hợp cuối cùng. Outcome tổng hợp (passed/flaky/failed) chỉ được xác định sau khi tất cả attempt kết thúc và không access được từ bên trong test.

Để đọc outcome tổng hợp, dùng custom reporter (xem mục 10).

// testInfo.retry cho biết đây là attempt thứ mấy
test('login flow', async ({ page }, testInfo) => {
  console.log(`Running attempt ${testInfo.retry + 1}`);
  // testInfo.retry = 0 → attempt 1 (lần đầu)
  // testInfo.retry = 1 → attempt 2 (retry đầu tiên)
  // testInfo.retry = 2 → attempt 3 (retry thứ hai)
});
7

Pattern Xử Lý Theo Outcome

Pattern phổ biến: dùng testInfo.status trong afterEach để thực hiện action phù hợp theo kết quả attempt:

import { test } from '@playwright/test';
import * as path from 'path';

test.afterEach(async ({ page }, testInfo) => {
  switch (testInfo.status) {
    case 'passed':
      // Cleanup bình thường, không cần log thêm
      break;

    case 'failed':
      // Chụp ảnh màn hình để debug
      const screenshotPath = path.join(
        'test-results',
        'failures',
        `${testInfo.title.replace(/\W/g, '_')}-attempt${testInfo.retry}.png`
      );
      await page.screenshot({ path: screenshotPath });
      testInfo.attach('failure-screenshot', {
        path: screenshotPath,
        contentType: 'image/png',
      });
      break;

    case 'timedOut':
      // Timeout cần log riêng vì nguyên nhân khác với failed thông thường
      console.error(`[TIMEOUT] ${testInfo.title} — attempt ${testInfo.retry + 1}`);
      await page.screenshot({
        path: `test-results/timeouts/${testInfo.title.replace(/\W/g, '_')}.png`,
      });
      break;

    case 'skipped':
      // Ghi lại lý do skip nếu có
      if (testInfo.annotations.some(a => a.type === 'skip')) {
        const skipAnnotation = testInfo.annotations.find(a => a.type === 'skip');
        console.log(`Skipped: ${skipAnnotation?.description ?? 'no reason'}`);
      }
      break;
  }
});

Điểm cần chú ý: timedOut phải handle riêng nếu logic cleanup cần phân biệt timeout (thường do page chậm) với fail assertion (thường do logic sai). Nếu chỉ check 'failed' thì miss case 'timedOut' — xem chi tiết ở mục 8.

8

timedOut — Subset Của failed

timedOut là một dạng fail cụ thể: test đạt timeout mà không hoàn tất. Về mặt outcome tổng hợp, timedOut góp vào outcome failed — không có outcome riêng là "timedOut".

Tuy nhiên, ở cấp độ testInfo.status (status của từng attempt), timedOut là giá trị riêng biệt, khác với 'failed'.

// Sai: chỉ check 'failed' sẽ bỏ sót timedOut
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status === 'failed') {
    // KHÔNG chạy khi test bị timeout vì status = 'timedOut'
    await page.screenshot({ path: 'failure.png' });
  }
});

// Đúng: check cả hai
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status === 'failed' || testInfo.status === 'timedOut') {
    await page.screenshot({ path: 'failure.png' });
  }
});

// Hoặc check ngược với expectedStatus
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== testInfo.expectedStatus) {
    // Bắt cả failed, timedOut, interrupted khi expected là 'passed'
    await page.screenshot({ path: 'failure.png' });
  }
});

Khi nào outcome tổng hợp là failed do timeout:

retries: 1 (tổng 2 attempt)

Attempt 1: timedOut   ← status='timedOut'
Attempt 2: timedOut   ← status='timedOut'
Tổng hợp: outcome='failed' (không có outcome='timedOut')

Trong HTML report và JUnit XML, test timedOut out vẫn xuất hiện dưới tab "Failed" — không có tab riêng cho timeout.

9

Hiển Thị Trong Reporter

List reporter (output terminal):

  ✓  login › should redirect after login (1.2s)
  ✗  checkout › place order (failed)
  −  admin › delete user (skipped)
  ↻  search › full text search (flaky)   ← ký hiệu test flaky

Ký hiệu: pass, fail, skip, ký hiệu retry (↻ hoặc highlight màu vàng) cho flaky.

HTML report — có tab riêng cho từng nhóm:

  • Tab Passed — test pass ở attempt đầu.
  • Tab Flaky — test fail rồi pass sau retry. Tab này có thể lọc để theo dõi riêng.
  • Tab Failed — test fail toàn bộ attempt (bao gồm timedOut).
  • Tab Skipped — test skip.

Summary cuối run (console):

  Suite duration: 5m 23s
  Pass:    95
  Flaky:    3
  Failed:   2
  Skipped:  4

JUnit XML (dùng cho CI dashboard như Jenkins, Azure DevOps):

<!-- Test pass: không có child element lỗi -->
<testcase name="login should redirect" classname="login"></testcase>

<!-- Test fail -->
<testcase name="checkout place order" classname="checkout">
  <failure message="Expected 'Thank you' but got 'Error'">...</failure>
</testcase>

<!-- Test skip -->
<testcase name="admin delete user" classname="admin">
  <skipped/>
</testcase>

JUnit XML không có tag riêng cho flaky — test flaky xuất hiện là pass (vì attempt cuối pass). Nếu cần track flaky trên CI, dùng HTML report hoặc custom reporter.

10

Custom Reporter — result.status vs test.outcome()

Khi viết custom reporter, hai API cần phân biệt rõ:

result.status — status của một attempt cụ thể:

import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';

class MyReporter implements Reporter {
  onTestEnd(test: TestCase, result: TestResult) {
    // result.status = 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'
    // Đây là status của attempt cuối cùng (attempt được pass vào onTestEnd)
    console.log(`${test.title}: attempt status = ${result.status}`);
  }
}

test.outcome() — outcome tổng hợp qua tất cả attempt:

class MyReporter implements Reporter {
  onTestEnd(test: TestCase, result: TestResult) {
    // test.outcome() = 'passed' | 'flaky' | 'failed' | 'skipped' | 'expected-skipped'
    // Được tính từ tất cả attempt, sẵn sàng trong onTestEnd
    const outcome = test.outcome();
    console.log(`${test.title}: outcome = ${outcome}`);

    if (outcome === 'flaky') {
      // Gửi alert Slack về flaky test
      sendSlackAlert(`Flaky test detected: ${test.title}`);
    }
  }
}

Thứ tự callback trong reporter lifecycle:

onBegin()
  → onTestBegin(test, result)     ← attempt 1 bắt đầu
  → onTestEnd(test, result)       ← attempt 1 kết thúc (nếu fail và retries > 0)
  → onTestBegin(test, result)     ← attempt 2 (retry 1) bắt đầu
  → onTestEnd(test, result)       ← attempt 2 kết thúc → test.outcome() đã final
onEnd()

test.outcome() chỉ có giá trị đúng sau onTestEnd của attempt cuối cùng. Không nên đọc nó trong onTestBegin.

11

CI Exit Code

Exit code của npx playwright test phụ thuộc vào outcome tổng hợp của toàn suite:

Tình huống Exit code CI pipeline
Tất cả test passed hoặc expected-skipped 0 Pipeline pass
Có ít nhất 1 test flaky, không có failed 0 (mặc định) Pipeline pass (flaky bị bỏ qua)
Có ít nhất 1 test flaky + --fail-on-flaky-tests 1 Pipeline fail
Có ít nhất 1 test failed 1 Pipeline fail
Playwright config error hoặc crash 1 Pipeline fail

Test skipped không ảnh hưởng exit code. Suite toàn skip sẽ trả 0. Đây là behavior có thể gây nhầm — CI pipeline pass nhưng không có test nào thực sự chạy.

Kiểm tra exit code trong shell:

npx playwright test
echo $?   # 0 = OK, 1 = có lỗi

GitHub Actions example:

# .github/workflows/test.yml
- name: Run Playwright tests
  run: npx playwright test --fail-on-flaky-tests
  # Exit code 1 (bao gồm flaky) sẽ tự động fail step này
12

Pitfalls

Pitfall 1: Nhầm outcome passed từ test.fail()

Khi test dùng test.fail() và thực sự fail, outcome là passed. Không phải fail. Developer mới thường kỳ vọng nó ra failed và bị bối rối khi thấy test "pass" dù có exception.

test('expected failure', async ({ page }) => {
  test.fail(); // Khai báo: "tôi kỳ vọng test này fail"
  throw new Error('Known bug #1234');
  // → outcome = 'passed' (expected và actual đều là 'failed')
});

Pitfall 2: Flaky = random, không phải bug

Khi test xuất hiện ở tab Flaky trong HTML report, không nên kết luận ngay đó là lỗi ngẫu nhiên của môi trường. Phần lớn trường hợp, flaky test phản ánh race condition hoặc timing assumption sai trong code test. Test pass khi server nhanh, fail khi server chậm một chút — đây là logic bug cần fix, không phải retry thêm lần nữa để "qua".

Pitfall 3: Skip ngầm vs skip tường minh — cùng status, khác nguyên nhân

Khi project dependency fail, Playwright skip các test phụ thuộc một cách ngầm định. Outcome và status của chúng cũng là skipped — giống hệt test dùng test.skip(). Không có cách phân biệt từ outcome; cần đọc annotations hoặc xem report để biết nguyên nhân cụ thể.

// Skip tường minh
test('some test', async ({ page }) => {
  test.skip(true, 'Feature not ready');
  // status = 'skipped', outcome = 'skipped'
});

// Skip ngầm do setup project fail
// Cũng có status = 'skipped', outcome = 'skipped'
// Không phân biệt được từ outcome

Pitfall 4: testInfo.status trong afterEach bỏ sót timedOut

Pattern if (testInfo.status === 'failed') sẽ không bắt được test bị timeout vì timedOut là giá trị riêng, khác với 'failed'. Dùng testInfo.status !== testInfo.expectedStatus để bắt tất cả unexpected outcome, hoặc check cả hai giá trị tường minh.

13

Quiz

Câu 1. Config retries: 2. Test fail ở attempt 1 và 2, pass ở attempt 3. Outcome là gì? Exit code là gì (không dùng --fail-on-flaky-tests)?

Đáp án

Outcome: flaky (fail rồi pass sau retry). Exit code: 0 — flaky mặc định không block CI. Nếu thêm --fail-on-flaky-tests (v1.45+) thì exit code là 1.

Câu 2. Test có test.fail(). Khi chạy, test pass không có lỗi. Outcome là gì và tại sao?

Đáp án

Outcome: failed. Vì test.fail() đặt expectedStatus = 'failed', nhưng actual status = 'passed'. Outcome xác định bởi so sánh expected vs actual — chúng không khớp → failed.

Câu 3. Trong afterEach, bạn viết if (testInfo.status === 'failed') { await page.screenshot(...) }. Test bị timeout. Screenshot có được chụp không?

Đáp án

Không. Khi test bị timeout, testInfo.status'timedOut', không phải 'failed'. Check === 'failed' không match. Cần check testInfo.status !== testInfo.expectedStatus hoặc testInfo.status === 'failed' || testInfo.status === 'timedOut'.

Câu 4. Phân biệt result.statustest.outcome() trong custom reporter. Khi nào dùng cái nào?

Đáp án

result.status là status của một attempt cụ thể ('passed', 'failed', 'timedOut'…). test.outcome() là outcome tổng hợp sau tất cả attempt ('passed', 'flaky', 'failed', 'skipped'). Dùng result.status khi cần xử lý từng attempt riêng lẻ. Dùng test.outcome() khi cần quyết định cuối cùng sau toàn bộ run, vd để alert flaky hay ghi log aggregated.

Câu 5. Suite có 100 test. Tất cả đều được skip bằng test.skip(). Exit code là bao nhiêu? CI pipeline pass hay fail?

Đáp án

Exit code: 0. CI pipeline pass. skipped không ảnh hưởng exit code. Đây là điểm cần chú ý khi pipeline luôn xanh dù không test nào thực sự chạy — nên bổ sung kiểm tra số test đã chạy trong CI script nếu muốn enforce minimum test count.

14

Bài Tiếp Theo

Bài 76: testInfo.retry — Phân Nhánh Xử Lý Theo Attempt — dùng testInfo.retry để thay đổi hành vi test theo lần chạy: skip setup tốn thời gian ở retry, clear cache, ghi thêm log diagnostic.