Danh sách bài viết

Bài 90: forEach Data-Driven Test — Sinh Test Từ Mảng Data

A.10 đào sâu data-driven testing: forEach, options fixture, project matrix, load JSON/CSV/ENV, multi-locale. Bài mở nhóm này tập trung vào kỹ thuật cơ bản nhất: dùng vòng lặp for...of wrap test() để sinh nhiều test case độc lập từ một mảng data — mỗi iteration cho ra một test riêng với tên, kết quả, và report entry của riêng 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ẽ:

  • Viết được vòng lặp for...of wrap test() để sinh nhiều test từ một mảng data.
  • Hiểu cơ chế: mỗi iteration tạo ra một test riêng, độc lập, có thể run riêng lẻ.
  • Áp dụng pattern vào boundary testing, permission matrix, và multi-input validation.
  • Biết cách kết hợp for...of với test.describe() để group test theo data.
  • Tránh closure capture bug khi dùng var thay vì const trong loop.
  • Nhận biết giới hạn của forEach so với options fixture (bài 91) và project matrix (bài 92).
2

Cú Pháp Cơ Bản

Ý tưởng: khai báo mảng data bên ngoài, rồi dùng for...of để gọi test() cho từng phần tử. Vòng lặp chạy tại load time (khi file được import), sinh ra N test trong test suite.

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

const testCases = [
  { input: '5', expected: '25' },
  { input: '10', expected: '100' },
  { input: '0', expected: '0' },
];

for (const { input, expected } of testCases) {
  test(`square of ${input} = ${expected}`, async ({ page }) => {
    await page.goto('/calculator');
    await page.getByLabel('Number').fill(input);
    await page.getByRole('button', { name: 'Square' }).click();
    await expect(page.getByTestId('result')).toHaveText(expected);
  });
}

Đoạn code trên sinh ra 3 test với tên:

  • square of 5 = 25
  • square of 10 = 100
  • square of 0 = 0

Mảng data nằm ngoài vòng lặp — đây là điểm tách biệt rõ ràng giữa "data" và "test logic". Khi cần thêm case, chỉ cần thêm một object vào mảng, không chạm vào test body.

3

Mỗi Iteration Là Một Test Riêng

Mỗi lần test() được gọi trong vòng lặp tạo ra một test hoàn toàn độc lập:

  • Test name động từ data — tên hiển thị trong reporter là kết quả của template string với data thực. Dễ nhận biết case nào fail.
  • Isolated — mỗi test có context riêng (page, browser context). Không chia sẻ state giữa các iteration.
  • Run riêng lẻ được — có thể filter bằng --grep "square of 5" để chỉ chạy 1 case cụ thể.
  • Reporter entry riêng — HTML report, list reporter đều hiển thị từng case là một dòng riêng với pass/fail status.
  • Retry độc lập — nếu bật retries, chỉ case fail mới được retry, không retry toàn bộ vòng lặp.

Để kiểm tra test sinh ra chạy đúng:

# Liệt kê tên tất cả test không chạy thực sự
npx playwright test --list

Output ví dụ:

  calculator.spec.ts:9:3 › square of 5 = 25
  calculator.spec.ts:9:3 › square of 10 = 100
  calculator.spec.ts:9:3 › square of 0 = 0

Lưu ý cột đầu: cùng line number (9:3) vì cả 3 test đều được khai báo tại cùng một lệnh test() trong source. Đây là hành vi bình thường — tên test là điểm phân biệt, không phải line.

4

Use Cases Phổ Biến

1. Multi-input validation

Test form với nhiều giá trị input hợp lệ / không hợp lệ. Thay vì viết 10 test riêng với cùng logic, viết 1 test body và cung cấp 10 bộ data.

const emailCases = [
  { email: '[email protected]', valid: true },
  { email: 'invalid-email', valid: false },
  { email: '', valid: false },
  { email: 'user@', valid: false },
  { email: '[email protected]', valid: true },
];

for (const { email, valid } of emailCases) {
  test(`email "${email}" is ${valid ? 'valid' : 'invalid'}`, async ({ page }) => {
    await page.goto('/register');
    await page.getByLabel('Email').fill(email);
    await page.getByRole('button', { name: 'Submit' }).click();
    if (valid) {
      await expect(page.getByText('Check your inbox')).toBeVisible();
    } else {
      await expect(page.getByText('Invalid email')).toBeVisible();
    }
  });
}

2. Boundary testing

Test các giá trị biên: min, max, dưới min, trên max, empty. Mảng data liệt kê rõ từng boundary với lý do để reviewer hiểu ý nghĩa từng case.

3. Multi-locale

Test 5 ngôn ngữ với cùng flow — đặc biệt hữu ích khi ứng dụng hỗ trợ i18n:

const locales = ['en-US', 'vi-VN', 'fr-FR', 'de-DE', 'ja-JP'];

for (const locale of locales) {
  test(`checkout flow in ${locale}`, async ({ browser }) => {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();
    await page.goto('/checkout');
    // kiểm tra currency format, date format, text direction
    await context.close();
  });
}

4. Permission matrix

Test tổ hợp role × action — xem chi tiết ở mục 6.

5

Pattern: Boundary Testing

Boundary testing kiểm tra các giá trị biên: đúng 1 đơn vị dưới min, đúng min, khoảng giữa, đúng max, đúng 1 đơn vị trên max. Với data-driven test, mỗi boundary thành một case có tên mô tả rõ lý do:

const passwords = [
  { value: 'short', valid: false, reason: 'too short (5 chars, min is 8)' },
  { value: 'password123', valid: true, reason: 'valid (11 chars)' },
  { value: '', valid: false, reason: 'empty' },
  { value: 'a'.repeat(100), valid: false, reason: 'too long (100 chars, max is 64)' },
  { value: 'abcdefgh', valid: true, reason: 'exactly at min boundary (8 chars)' },
];

for (const { value, valid, reason } of passwords) {
  test(`password "${reason}" → ${valid ? 'accept' : 'reject'}`, async ({ page }) => {
    await page.goto('/signup');
    await page.getByLabel('Password').fill(value);
    await page.getByRole('button', { name: 'Submit' }).click();
    if (valid) {
      await expect(page.getByText('Success')).toBeVisible();
    } else {
      await expect(page.getByText('Invalid')).toBeVisible();
    }
  });
}

Field reason phục vụ hai mục đích: làm tên test mô tả, và làm documentation cho reviewer biết tại sao case này quan trọng. Khi test fail, reporter hiển thị password "empty" → reject — rõ ngay case nào bị vỡ mà không cần đọc code.

Với numeric boundary (ví dụ: tuổi từ 18–120):

const ageCases = [
  { age: 17, valid: false, reason: 'below minimum' },
  { age: 18, valid: true, reason: 'at minimum boundary' },
  { age: 65, valid: true, reason: 'typical value' },
  { age: 120, valid: true, reason: 'at maximum boundary' },
  { age: 121, valid: false, reason: 'above maximum' },
  { age: 0, valid: false, reason: 'zero' },
  { age: -1, valid: false, reason: 'negative' },
];

for (const { age, valid, reason } of ageCases) {
  test(`age ${age} (${reason}) → ${valid ? 'valid' : 'invalid'}`, async ({ page }) => {
    // ...
  });
}
6

Pattern: Permission Matrix

Permission matrix là tổ hợp role × action. Thay vì viết tay N × M test, khai báo matrix dưới dạng mảng object rồi loop:

const matrix = [
  { role: 'admin', action: 'delete', allowed: true },
  { role: 'user', action: 'delete', allowed: false },
  { role: 'guest', action: 'delete', allowed: false },
  { role: 'admin', action: 'view', allowed: true },
  { role: 'user', action: 'view', allowed: true },
  { role: 'guest', action: 'view', allowed: true },
  { role: 'admin', action: 'edit', allowed: true },
  { role: 'user', action: 'edit', allowed: true },
  { role: 'guest', action: 'edit', allowed: false },
];

for (const { role, action, allowed } of matrix) {
  test(`${role} ${action} → ${allowed ? 'allowed' : 'denied'}`, async ({ page }) => {
    // Login theo role (dùng storageState của role tương ứng)
    await page.goto('/dashboard');
    const button = page.getByRole('button', { name: action });
    if (allowed) {
      await expect(button).toBeVisible();
      await expect(button).toBeEnabled();
    } else {
      // Button không tồn tại, disabled, hoặc hidden tuỳ implementation
      await expect(button).toBeHidden();
    }
  });
}

9 dòng data sinh ra 9 test, mỗi test tên như admin delete → allowed, guest edit → denied. Khi có rule thay đổi (ví dụ user được phép delete), chỉ cần sửa field allowed trong matrix — không sửa test body.

Với matrix lớn hơn, tách file data để giữ spec file gọn:

// permissions.data.ts
export const permissionMatrix = [
  { role: 'admin', action: 'delete', allowed: true },
  // ... nhiều entries
];

// permissions.spec.ts
import { permissionMatrix } from './permissions.data';

for (const { role, action, allowed } of permissionMatrix) {
  test(`${role} ${action} → ${allowed ? 'allowed' : 'denied'}`, async ({ page }) => {
    // ...
  });
}
7

forEach + describe

Kết hợp for...of với test.describe() để group nhiều test liên quan vào một nhóm có tên từ data:

for (const role of ['admin', 'user', 'guest']) {
  test.describe(`Role: ${role}`, () => {
    test('can view dashboard', async ({ page }) => {
      await page.goto('/dashboard');
      await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
    });

    test('sees correct nav items', async ({ page }) => {
      await page.goto('/');
      const nav = page.getByRole('navigation');
      if (role === 'admin') {
        await expect(nav.getByRole('link', { name: 'Admin Panel' })).toBeVisible();
      } else {
        await expect(nav.getByRole('link', { name: 'Admin Panel' })).toBeHidden();
      }
    });
  });
}

Sinh ra 3 describe group, mỗi group 2 test:

Role: admin
  ✓ can view dashboard
  ✓ sees correct nav items
Role: user
  ✓ can view dashboard
  ✓ sees correct nav items
Role: guest
  ✓ can view dashboard
  ✓ sees correct nav items

Ưu điểm của cách group: có thể dùng test.use() bên trong describe để set fixture khác nhau cho từng role. Ví dụ load storageState khác nhau:

const roles = [
  { name: 'admin', storageState: 'playwright/.auth/admin.json' },
  { name: 'user', storageState: 'playwright/.auth/user.json' },
];

for (const { name, storageState } of roles) {
  test.describe(`Role: ${name}`, () => {
    test.use({ storageState });

    test('can access profile', async ({ page }) => {
      await page.goto('/profile');
      await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
    });
  });
}

test.use() bên trong describe chỉ áp dụng cho test trong describe đó — đây là cách clean để mỗi role dùng auth state riêng mà không cần beforeEach riêng.

8

Test Name Unique

Playwright không tự động đảm bảo test name unique — đó là trách nhiệm của người viết data. Test name trùng nhau gây ra:

  • Reporter không phân biệt được case nào fail (2 dòng cùng tên).
  • --grep filter match cả hai, chạy cả hai dù chỉ muốn chạy 1.
  • HTML report hiển thị ambiguous entries.

Tình huống dễ trùng nhất: data có giá trị giống nhau ở field dùng trong tên:

// DATA CÓ VẤN ĐỀ — 2 entry cùng expected value
const cases = [
  { input: '3', expected: '9', note: 'positive' },
  { input: '-3', expected: '9', note: 'negative squared' },
];

// Tên trùng nếu chỉ dùng expected:
// test(`result is ${expected}`)  →  "result is 9" và "result is 9"

// Đúng: dùng đủ fields để phân biệt
for (const { input, expected, note } of cases) {
  test(`square(${input}) = ${expected} [${note}]`, async ({ page }) => {
    // ...
  });
}
// → "square(3) = 9 [positive]"
// → "square(-3) = 9 [negative squared]"

Nếu data có thể trùng và không kiểm soát được (ví dụ data load từ file ngoài), thêm index làm fallback:

testCases.forEach((tc, index) => {
  test(`case ${index + 1}: ${tc.description}`, async ({ page }) => {
    // ...
  });
});

Dùng index làm tên chính là anti-pattern (không mô tả gì) — chỉ dùng làm prefix khi tc.description đã có nội dung.

9

Khác Biệt Với Các Kỹ Thuật Liên Quan

forEach vs options fixture (bài 91)

forEach trong file options fixture
Scope 1 spec file Project-level hoặc file-level qua test.use()
Cú pháp for...of wrap test() Fixture option: true + override qua test.use()
Data source Array inline hoặc import Config file, fixture override
Khi nào dùng Test cases trong cùng 1 flow, data logic đơn giản Cần parameterize xuyên suốt nhiều file hoặc project

forEach test vs step loop trong 1 test

// forEach: N test độc lập
for (const item of items) {
  test(`item ${item.name}`, async ({ page }) => {
    // 1 test = 1 item
  });
}
// → N test, mỗi cái pass/fail riêng, retry riêng

// Step loop: 1 test, N step
test('process all items', async ({ page }) => {
  for (const item of items) {
    await test.step(`process ${item.name}`, async () => {
      // tất cả step trong cùng 1 test
    });
  }
});
// → 1 test, nếu bất kỳ step nào fail thì cả test fail

Chọn forEach khi cần isolate: failure của case A không ảnh hưởng case B. Chọn step loop khi muốn test "end-to-end flow": các step phải chạy tuần tự, phụ thuộc nhau.

10

Closure Capture Pitfall

Đây là pitfall phổ biến nhất khi viết data-driven test. Xảy ra khi dùng var hoặc vòng lặp for (let i = 0; ...) với cách capture sai.

Bug: dùng var trong loop

// SAI — var không có block scope
var testCases = ['a', 'b', 'c'];

for (var i = 0; i < testCases.length; i++) {
  var item = testCases[i];  // var: function scope, không phải block scope

  test(`item ${item}`, async ({ page }) => {
    // Lúc test chạy (async), vòng lặp đã kết thúc
    // item lúc này là giá trị cuối cùng của biến var: 'c'
    // Cả 3 test đều test với item = 'c'
    console.log(item);  // 'c', 'c', 'c'
  });
}

Vấn đề: test callback là async function, được Playwright lưu lại để chạy sau. Lúc chạy, biến var item trong outer scope đã bị ghi đè bởi iteration cuối. Tất cả test closure đều tham chiếu cùng một biến — và biến đó là giá trị cuối.

Fix: dùng for...of với const

// ĐÚNG — for...of với const, mỗi iteration tạo binding mới
const testCases = ['a', 'b', 'c'];

for (const item of testCases) {
  // item là const trong block scope của iteration này
  // closure capture binding của iteration này, không phải biến chung
  test(`item ${item}`, async ({ page }) => {
    console.log(item);  // 'a', 'b', 'c' — đúng
  });
}

Với for...of const: mỗi iteration tạo một binding mới cho item. Closure của test() capture binding đó — không bị ghi đè bởi iteration sau.

Cũng đúng với forEach method:

// Array.forEach cũng OK vì callback tạo scope mới
testCases.forEach(({ input, expected }) => {
  test(`square(${input}) = ${expected}`, async ({ page }) => {
    // input và expected là parameter của callback — luôn đúng
  });
});

Tóm tắt rule: dùng for...of với destructuring const, hoặc Array.forEach với callback parameter. Không dùng for (var i...) hay var để khai báo biến loop.

11

Best Practices

1. Tên test descriptive từ data

Tên test là documentation. Dùng template string kết hợp đủ fields để tên tự mô tả case. Reviewer đọc report nên hiểu ngay case nào fail mà không cần mở code.

2. Data array dùng object với named fields

Mảng object có tên field rõ ràng dễ đọc hơn mảng tuple/primitive:

// Khó đọc — không rõ 3 giá trị nghĩa là gì
const cases = [['a', true, 'valid'], ['', false, 'empty']];

// Dễ đọc — named fields
const cases = [
  { value: 'a', valid: true, reason: 'normal value' },
  { value: '', valid: false, reason: 'empty string' },
];

3. Tách data ra file riêng khi mảng lớn

Khi mảng data có hơn 10–15 entries, file spec trở nên khó đọc vì data chiếm phần lớn chiều dọc. Tách ra file *.data.ts riêng và import vào. Bài 93–94 deep dive load từ JSON và CSV cho data lớn hơn.

4. Luôn dùng for...of với const

Tránh hoàn toàn varfor (let i = 0; ...) trừ khi cần index vì lý do khác. for...of với destructuring const là cú pháp an toàn nhất.

5. Không trùng tên test

Kiểm tra tên test trước khi commit bằng npx playwright test --list. Nếu thấy tên trùng, bổ sung thêm field vào template string.

12

Limitation

1. Test sinh tại load time, không dynamic runtime

Playwright thu thập test khi import file, không khi chạy test. Vòng lặp for...of chạy synchronous tại load time. Điều này có nghĩa: không thể sinh test từ kết quả async (ví dụ fetch từ API, đọc database). Nếu cần data từ nguồn async, phải pre-generate và commit file, hoặc dùng pattern load JSON/CSV (bài 93–94).

// KHÔNG LÀM ĐƯỢC
const response = await fetch('/api/test-cases');  // async tại top-level
const cases = await response.json();

for (const tc of cases) {
  test(tc.name, async ({ page }) => { ... });  // file đã load xong trước khi fetch
}

2. Data lớn inline làm file khó đọc

Mảng 50+ entries inline trong spec file làm spec dài và khó tìm test logic. Giải pháp: tách file data (xem best practice 3).

3. Test name collision khi data trùng

Playwright không báo lỗi khi tên test trùng — chỉ phát hiện khi đọc report hoặc chạy --list. Không có cơ chế auto-unique.

4. Không có built-in "skip 1 case" syntax

Không có cú pháp ngắn gọn để skip một case cụ thể trong mảng như xit trong Jest. Cách làm thông thường: thêm field skip: true và kiểm tra trong loop:

for (const tc of testCases) {
  const t = tc.skip ? test.skip : test;
  t(`${tc.name}`, async ({ page }) => { ... });
}
13

Pitfalls

Pitfall 1: dùng var trong loop — closure capture bug

// SAI
var items = ['a', 'b', 'c'];
for (var i = 0; i < items.length; i++) {
  var item = items[i];
  test(`test ${item}`, async ({ page }) => {
    // item = 'c' cho tất cả test lúc chạy
  });
}

// ĐÚNG
const items = ['a', 'b', 'c'];
for (const item of items) {
  test(`test ${item}`, async ({ page }) => {
    // item được capture đúng theo từng iteration
  });
}

Pitfall 2: tên test trùng — reporter confuse

// DATA GÂY TRÙNG TÊN
const cases = [
  { a: 1, b: 2, result: 3 },
  { a: 0, b: 3, result: 3 },  // result trùng với case trên
];

// SAI — chỉ dùng result làm tên → 2 test đều tên "result = 3"
for (const { result } of cases) {
  test(`result = ${result}`, async ({ page }) => { ... });
}

// ĐÚNG — dùng đủ fields
for (const { a, b, result } of cases) {
  test(`${a} + ${b} = ${result}`, async ({ page }) => { ... });
}

Pitfall 3: inline data quá lớn làm file khó đọc

// Dấu hiệu cần tách file: data chiếm >30 dòng trong spec
const testCases = [
  // ... 60 entries
];

// Fix: tách ra file riêng
// test-cases.data.ts
export const testCases = [
  // ... 60 entries
];

// spec.ts
import { testCases } from './test-cases.data';

Pitfall 4: quên await trong async test body — test định nghĩa sai

// SAI — không await action
for (const { url } of urls) {
  test(`loads ${url}`, async ({ page }) => {
    page.goto(url);  // thiếu await — test kết thúc trước khi navigation xong
    expect(page.getByRole('heading')).toBeVisible();  // thiếu await — assertion không chạy đúng
  });
}

// ĐÚNG
for (const { url } of urls) {
  test(`loads ${url}`, async ({ page }) => {
    await page.goto(url);
    await expect(page.getByRole('heading')).toBeVisible();
  });
}

Thiếu await không gây lỗi syntax — test vẫn pass nếu action kịp hoàn thành hoặc assertion đúng ngay. Nhưng kết quả không đáng tin cậy và test trở nên flaky.

14

Quiz

Câu 1. Code sau sinh ra bao nhiêu test? Các test có tên gì?

const sizes = ['S', 'M', 'L', 'XL'];

for (const size of sizes) {
  test(`add size ${size} to cart`, async ({ page }) => {
    // ...
  });
}
Đáp án

Sinh ra 4 test với tên: add size S to cart, add size M to cart, add size L to cart, add size XL to cart. Mỗi test độc lập, có thể run riêng lẻ.

Câu 2. Code sau có bug gì? Hành vi thực tế là gì khi chạy?

const items = ['apple', 'banana', 'cherry'];

for (var i = 0; i < items.length; i++) {
  var fruit = items[i];
  test(`buy ${fruit}`, async ({ page }) => {
    await page.goto(`/shop/${fruit}`);
  });
}
Đáp án

Closure capture bug: var fruitvar i có function scope, không phải block scope. Khi test callback chạy (sau khi toàn bộ vòng lặp đã kết thúc), fruit = 'cherry'i = 3 cho cả 3 test. Cả 3 test đều navigate đến /shop/cherry, không phải apple và banana.

Fix: thay var bằng const và dùng for...of:

for (const fruit of items) {
  test(`buy ${fruit}`, async ({ page }) => {
    await page.goto(`/shop/${fruit}`);
  });
}

Câu 3. Khi nào nên dùng for...of sinh nhiều test thay vì dùng 1 test với step loop? Cho ví dụ tình huống phù hợp mỗi cách.

Đáp án

Dùng for...of nhiều test khi: các case độc lập nhau, không chia sẻ state, muốn failure của case A không ảnh hưởng B, cần retry độc lập. Ví dụ: test form validation với 10 input khác nhau — mỗi input là 1 test riêng.

Dùng step loop (1 test nhiều step) khi: các bước phụ thuộc tuần tự nhau, state được tích lũy xuyên suốt flow. Ví dụ: checkout flow — "add to cart" → "enter address" → "confirm payment" — nếu "add to cart" fail thì không có nghĩa chạy tiếp "confirm payment".

Câu 4. Mảng data sau có vấn đề gì khi dùng làm tên test? Sửa như thế nào?

const cases = [
  { input: 0, output: 'zero' },
  { input: 0, output: 'zero' },
  { input: 1, output: 'one' },
];

for (const { input, output } of cases) {
  test(`${input} → ${output}`, async ({ page }) => { ... });
}
Đáp án

Hai entries đầu giống hệt nhau — sinh 2 test cùng tên 0 → zero. Reporter hiển thị ambiguous, --grep filter sẽ chạy cả hai. Nếu đây là data có chủ ý (2 case khác nhau nhưng result giống), cần thêm field phân biệt:

const cases = [
  { input: 0, output: 'zero', context: 'integer' },
  { input: 0, output: 'zero', context: 'float 0.0' },
  { input: 1, output: 'one', context: 'integer' },
];

for (const { input, output, context } of cases) {
  test(`${context} ${input} → ${output}`, async ({ page }) => { ... });
}

Nếu đây là data bị trùng vô ý, xoá duplicate trước khi commit.

Câu 5. Code sau có chạy được không? Nếu không, lỗi gì xảy ra và cách fix?

// top-level của file spec
const response = await fetch('https://api.example.com/test-data');
const testCases = await response.json();

for (const tc of testCases) {
  test(tc.name, async ({ page }) => { ... });
}
Đáp án

Không chạy được. Top-level await chỉ hợp lệ trong ES module (type: "module" trong package.json). Nhưng ngay cả khi module ES được hỗ trợ, Playwright thu thập test tại load time synchronously — fetch async sẽ không hoàn thành trước khi Playwright cần danh sách test.

Fix: pre-generate data thành file JSON/TS và import synchronously, hoặc dùng pattern load file local (bài 93–94):

// Đọc file local — synchronous, hoạt động tại load time
import { readFileSync } from 'fs';
const testCases = JSON.parse(readFileSync('./test-data.json', 'utf-8'));
15

Bài Tiếp Theo

Bài 91: Parametrize qua Options Fixture — thay vì loop trong file, dùng fixture option: true để parametrize ở project-level hoặc file-level qua test.use(), cho phép cùng một test file chạy với nhiều bộ config khác nhau mà không cần sửa test body.