Danh sách bài viết

Bài 94: Load Test Data Từ CSV

CSV phù hợp khi data lớn, dạng bảng, hoặc do QA maintain trong Excel rồi export. Bài này tập trung vào cách parse CSV với csv-parse/sync, hiểu các option quan trọng, xử lý type coercion (vì CSV là string-only), và những pitfall thường gặp như BOM encoding, comma trong value, và column lệch.

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

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

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

  • Cài và dùng csv-parse/sync để đọc file CSV trong Playwright test.
  • Hiểu các option quan trọng: columns, skip_empty_lines.
  • Biết CSV là string-only và cách convert sang kiểu cần thiết (number, boolean).
  • Kết hợp records từ CSV với for...of để sinh test case.
  • Nhận biết 4 pitfall phổ biến: quên columns: true, string coercion, comma trong value, BOM encoding.
  • Phân biệt khi nào dùng CSV, khi nào dùng JSON (bài 93).
2

CSV Vs JSON — Khi Nào Dùng CSV

CSV và JSON đều là nguồn data ngoài cho test, nhưng phục vụ các tình huống khác nhau:

CSV JSON
Cấu trúc Flat, tabular (rows × columns) Nested, hierarchical
Kiểu dữ liệu String-only (phải convert thủ công) String, number, boolean, null, array, object
Edit tool Excel, Google Sheets, LibreOffice Text editor, IDE
Người dùng QA, BA, non-developer Developer
Data lớn Tốt — hàng ngàn rows, cuộn ngang dễ Khó đọc khi nhiều record
Nested data Khó — phải flatten hoặc dùng JSON trong cell Tự nhiên

Chọn CSV khi: QA hoặc BA maintain data trong Excel rồi export, data có dạng bảng đơn giản (login cases, product list), hoặc cần hàng trăm / hàng ngàn rows. Chọn JSON khi data có nesting (object lồng nhau), kiểu dữ liệu quan trọng (boolean flag, số), hoặc developer tự maintain.

3

Cài Đặt csv-parse

csv-parse là package Node.js phổ biến, hỗ trợ cả sync và streaming mode. Cài làm dev dependency:

npm i -D csv-parse

Package export hai entry point:

  • csv-parse/sync — đọc toàn bộ file vào bộ nhớ, trả về ngay (phù hợp test data nhỏ/vừa).
  • csv-parse — streaming API (phù hợp file rất lớn, xử lý row-by-row).

Với test data, csv-parse/sync là lựa chọn thực tế — đơn giản hơn và hoạt động đúng ở load time.

4

Parse CSV Cơ Bản

File CSV login cases (data/cases.csv):

username,password,expected
admin,admin123,success
wrong,wrong,error
,x,validation

Row đầu là header. Ba row tiếp theo là data — row cuối có username rỗng (empty cell trước dấu phẩy).

Parse với csv-parse/sync:

import { parse } from 'csv-parse/sync';
import { readFileSync } from 'fs';

const records = parse(readFileSync('./data/cases.csv', 'utf-8'), {
  columns: true,         // first row = headers → records là array of object
  skip_empty_lines: true,
});

console.log(records);
// [
//   { username: 'admin', password: 'admin123', expected: 'success' },
//   { username: 'wrong', password: 'wrong', expected: 'error' },
//   { username: '', password: 'x', expected: 'validation' },
// ]

Với columns: true, mỗi record là một object có key từ header row. Không có columns: true, mỗi record là một mảng string (xem mục 5).

Lưu ý username của row cuối là chuỗi rỗng '', không phải null hay undefined — CSV không có khái niệm null.

5

Columns Option

Option columns kiểm soát cách records được lập chỉ mục:

columns: true — row đầu tiên làm key cho tất cả records sau. Kết quả là mảng object:

parse('a,b,c\n1,2,3\n4,5,6', { columns: true });
// [ { a: '1', b: '2', c: '3' }, { a: '4', b: '5', c: '6' } ]

columns: ['col1', 'col2', 'col3'] — chỉ định key tường minh, không đọc header từ file. Hữu ích khi CSV không có header row:

parse('admin,admin123,success\nwrong,wrong,error', {
  columns: ['username', 'password', 'expected'],
  skip_empty_lines: true,
});
// [ { username: 'admin', password: 'admin123', expected: 'success' }, ... ]

columns: false (default) — mỗi record là mảng string. Truy cập theo index:

parse('admin,admin123,success\nwrong,wrong,error', {
  columns: false,
  skip_empty_lines: true,
});
// [ ['admin', 'admin123', 'success'], ['wrong', 'wrong', 'error'] ]
// record[0] = username, record[1] = password, record[2] = expected

Dùng columns: false khi CSV không có header và bạn không muốn thêm tên cột — nhưng code test sẽ khó đọc hơn vì phải nhớ index. Ưu tiên columns: true hoặc explicit array khi có thể.

6

Combine Với forEach

Cấu trúc đầy đủ: đọc file CSV → parse → loop sinh test. Dùng path.join(__dirname, ...) để path hoạt động đúng bất kể thư mục nào gọi test:

import { test, expect } from '@playwright/test';
import { parse } from 'csv-parse/sync';
import { readFileSync } from 'fs';
import path from 'path';

const records = parse(
  readFileSync(path.join(__dirname, 'data/login.csv'), 'utf-8'),
  { columns: true, skip_empty_lines: true }
);

for (const record of records) {
  test(`login: ${record.username || 'empty'} → ${record.expected}`, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Username').fill(record.username);
    await page.getByLabel('Password').fill(record.password);
    await page.getByRole('button', { name: 'Login' }).click();

    if (record.expected === 'success') {
      await expect(page.getByText('Welcome')).toBeVisible();
    } else if (record.expected === 'error') {
      await expect(page.getByText('Invalid credentials')).toBeVisible();
    } else if (record.expected === 'validation') {
      await expect(page.getByText('Username is required')).toBeVisible();
    }
  });
}

Tên test dùng record.username || 'empty' để tránh tên rỗng khi username là chuỗi rỗng. Nếu không xử lý, test có tên như login: → validation với khoảng trắng ở đầu — khó đọc trong report.

Với file CSV 3 rows (như ví dụ ở mục 4), code trên sinh 3 test:

  • login: admin → success
  • login: wrong → error
  • login: empty → validation

parse()readFileSync() chạy synchronous tại load time — đây là lý do dùng csv-parse/sync thay vì streaming API.

7

Type Coercion

CSV lưu mọi giá trị dưới dạng string — không có kiểu số, boolean, hay null gốc. Sau khi parse, mọi field đều là string:

username,age,active,score
alice,25,true,98.5
bob,17,false,72.0
const records = parse(readFileSync('./data/users.csv', 'utf-8'), {
  columns: true,
  skip_empty_lines: true,
});

console.log(typeof records[0].age);    // 'string' (không phải number!)
console.log(typeof records[0].active); // 'string' (không phải boolean!)
console.log(records[0].age);           // '25'
console.log(records[0].active);        // 'true' (string)

Phải convert thủ công trước khi dùng:

for (const record of records) {
  const age = parseInt(record.age, 10);          // '25' → 25
  const score = parseFloat(record.score);        // '98.5' → 98.5
  const active = record.active === 'true';       // 'true' → true, 'false' → false

  test(`user ${record.username}`, async ({ page }) => {
    // dùng age (number), active (boolean) sau khi convert
    if (!active) {
      await expect(page.getByText('Account disabled')).toBeVisible();
    }
    await expect(page.getByText(`Age: ${age}`)).toBeVisible();
  });
}

Lưu ý với boolean: record.active === 'true' cho true, record.active === 'false' cho false. Tránh dùng Boolean(record.active)Boolean('false') = true (string không rỗng luôn truthy).

Với parseInt hoặc parseFloat trên giá trị rỗng:

parseInt('', 10)   // NaN
parseFloat('')     // NaN

Cần guard nếu field có thể rỗng:

const age = record.age ? parseInt(record.age, 10) : null;
8

Pattern Type-Safe

parse() trả về any[] theo mặc định. Dùng interface + type assertion để có autocomplete và type check:

interface LoginCase {
  username: string;
  password: string;
  expected: 'success' | 'error' | 'validation';
}

const records = parse(
  readFileSync(path.join(__dirname, 'data/login.csv'), 'utf-8'),
  { columns: true, skip_empty_lines: true }
) as LoginCase[];

Type assertion không validate runtime — nếu CSV có column sai tên hoặc thiếu column, TypeScript không báo lỗi. Để validate, dùng thêm runtime check sau parse:

function assertLoginCases(records: unknown[]): LoginCase[] {
  return records.map((r, i) => {
    if (typeof (r as any).username !== 'string') {
      throw new Error(`Row ${i + 1}: missing "username" column`);
    }
    if (!['success', 'error', 'validation'].includes((r as any).expected)) {
      throw new Error(`Row ${i + 1}: invalid "expected" value: ${(r as any).expected}`);
    }
    return r as LoginCase;
  });
}

const records = assertLoginCases(
  parse(readFileSync(path.join(__dirname, 'data/login.csv'), 'utf-8'), {
    columns: true,
    skip_empty_lines: true,
  })
);

Validate sau parse giúp phát hiện lỗi data sớm — trước khi test chạy và fail với message khó hiểu như Cannot read property 'fill' of undefined.

9

Use Cases

Excel export data

QA maintain test data trong Excel, không cần biết TypeScript. Khi có thay đổi, export sheet sang CSV và commit — không cần sửa code. Phù hợp cho project có nhiều QA không viết code.

Large dataset

Khi cần hàng ngàn rows (ví dụ test import data, test search với nhiều keyword), CSV gọn hơn JSON vì không có overhead dấu ngoặc và key lặp lại. File 10.000 rows CSV thường nhỏ hơn JSON tương đương 20–40%.

Tabular data — matrix-like

Data dạng user × permission hoặc product × locale phù hợp biểu diễn CSV:

role,action,allowed
admin,delete,true
user,delete,false
guest,delete,false
admin,view,true
user,view,true
guest,view,false

Dễ thêm row mới, dễ review diff trong Git (mỗi row là 1 dòng).

Migration data

Import legacy data từ hệ thống cũ: database export CSV → dùng làm test fixture cho migration test. Data sẵn có, không cần viết tay.

10

Alternative Libraries

Library Đặc điểm Phù hợp khi
csv-parse Phổ biến, nhiều option, sync + stream Phần lớn trường hợp
papaparse Hỗ trợ cả browser và Node.js, API đơn giản Code dùng chung browser/Node
fast-csv Tối ưu streaming, xử lý file lớn hiệu quả File CSV hàng chục MB+

Với test data Playwright, csv-parse là lựa chọn mặc định. fast-csv phù hợp nếu file thực sự lớn và cần streaming (xem mục 11).

11

Streaming Large CSV

Sync mode đọc toàn bộ file vào RAM cùng lúc. File 1–5 MB test data là bình thường và không gây vấn đề. Nhưng nếu file thực sự lớn (hàng chục MB), có thể dùng streaming:

// Streaming — đọc row-by-row, không load toàn bộ vào RAM
import { parse } from 'csv-parse';
import { createReadStream } from 'fs';
import path from 'path';

// Streaming cần pre-collect trước load time — phức tạp hơn sync
// Thường không cần thiết cho test data

Vấn đề với streaming cho test: Playwright thu thập test tại load time synchronous. Streaming là async — cần await trước khi loop sinh test. Điều này không hoạt động trực tiếp ở top-level module.

Giải pháp thực tế: nếu file CSV quá lớn để sync, hãy xem lại thiết kế test. Thường test data lớn có thể tách thành nhiều file nhỏ theo nhóm (login, checkout, search) và load file phù hợp trong spec tương ứng.

12

Best Practices

1. Header row rõ ràng, không dấu cách

Header dùng làm key trong TypeScript. Key có dấu cách phải truy cập bằng bracket notation (record['user name']) — không tự nhiên. Dùng camelCase hoặc underscore: username, user_name.

2. UTF-8 không BOM

Khi save file trong Excel, chọn "CSV UTF-8" không phải "CSV UTF-8 with BOM". BOM thêm 3 byte ẩn () vào đầu file — header column đầu có ký tự lạ, dẫn đến key bị sai. Xem thêm ở mục 14.

3. Quote giá trị có special character

Nếu value chứa dấu phẩy, xuống dòng, hay dấu ngoặc kép, phải quote toàn bộ value:

username,description
alice,"Alice, the admin"
bob,"Has ""quotes"" in bio"

Excel và Google Sheets tự xử lý khi export. Nếu viết tay, nhớ quote đúng.

4. Validate sau parse

Trước khi loop sinh test, kiểm tra records có đúng schema không (ít nhất check số columns và tên header). Lỗi data phát hiện sớm — không phải khi test chạy và fail với message không rõ.

5. Dùng path.join(__dirname, ...)

Relative path như ./data/login.csv resolve từ cwd của process, không phải từ vị trí file spec. __dirname là thư mục chứa file spec hiện tại — luôn đúng dù chạy từ thư mục nào.

13

Limitation

1. String-only — phải coerce thủ công

Không như JSON (giữ nguyên kiểu number, boolean), CSV trả về string cho mọi field. Mỗi lần cần số hay boolean đều phải convert — thêm boilerplate, dễ quên.

2. Nested data khó biểu diễn

CSV không có cú pháp native cho object lồng nhau. Nếu data cần structure như { address: { city, zip } }, phải flatten thành address_city, address_zip — hoặc dùng JSON thay thế.

3. Special character escape phức tạp

Comma, newline, và dấu ngoặc kép trong value đều cần xử lý đặc biệt. Viết tay CSV với nhiều special character dễ sai. Excel xử lý tốt khi export nhưng nếu edit thủ công cần cẩn thận.

4. Empty value là string rỗng, không phải null

CSV không có khái niệm null. Cell rỗng → string ''. Code phải phân biệt '' vs giá trị thực nếu logic cần null check.

14

Pitfalls

Pitfall 1: quên columns: true — records là array không có key

// CSV: username,password,expected / admin,admin123,success
const records = parse(content, { skip_empty_lines: true });
// records[0] = ['username', 'password', 'expected']  ← header thành record đầu!
// records[1] = ['admin', 'admin123', 'success']
// records[1].username  →  undefined
// records[1][0]  →  'admin'  (phải dùng index)

// FIX
const records = parse(content, { columns: true, skip_empty_lines: true });
// records[0] = { username: 'admin', password: 'admin123', expected: 'success' }
// records[0].username  →  'admin'  ✓

Pitfall 2: string coercion — quên convert kiểu

// CSV: age,minAge / 25,18
const records = parse(content, { columns: true, skip_empty_lines: true });

// SAI — so sánh string với number
if (records[0].age > records[0].minAge) {  // '25' > '18' → true (string compare!)
  // '9' > '10' → true khi dùng string compare! Sai về mặt số học
}

// ĐÚNG — convert trước
const age = parseInt(records[0].age, 10);
const minAge = parseInt(records[0].minAge, 10);
if (age > minAge) { ... }

String comparison dùng lexicographic order (so từng ký tự), không phải giá trị số. Ví dụ chí mạng nhất: '20' > '3'false (vì ký tự '2' < '3'), trong khi số học 20 > 3true — kết quả NGƯỢC hoàn toàn. Tương tự '9' > '10'true dù số học 9 < 10. Luôn parseInt/parseFloat trước khi so sánh số.

Pitfall 3: comma trong value không quote — column lệch

username,description,role
alice,Alice the admin,admin
bob,Bob, the user,user

Row thứ 3: Bob, the user chứa dấu phẩy không được quote. CSV parser đọc thành 4 column thay vì 3: username='bob', description='Bob', column3='the user', role='user' — nhưng schema chỉ có 3 column → column shift.

# FIX — quote value chứa dấu phẩy
username,description,role
alice,Alice the admin,admin
bob,"Bob, the user",user

Pitfall 4: BOM encoding — first column key có ký tự lạ

// File CSV save với BOM từ Excel (UTF-8 BOM)
// BOM thêm 3 byte  vào đầu file
// Column "username" trở thành "username"

const records = parse(content, { columns: true, skip_empty_lines: true });
console.log(Object.keys(records[0]));
// ['username', 'password', 'expected']  ← key đầu có ký tự ẩn!

records[0].username          // undefined
records[0]['username'] // 'admin'

Fix: strip BOM khi đọc file:

const content = readFileSync(path.join(__dirname, 'data/login.csv'), 'utf-8')
  .replace(/^/, '');  // strip BOM nếu có

const records = parse(content, { columns: true, skip_empty_lines: true });

Hoặc đổi cách save: trong Excel, chọn "Save as" → "CSV UTF-8 (Comma delimited)" thay vì "CSV UTF-8 (BOM)".

15

Quiz

Câu 1. CSV sau parse với { columns: true } trả về gì? record.age có giá trị và kiểu gì?

name,age,active
alice,30,true
Đáp án

Trả về [{ name: 'alice', age: '30', active: 'true' }]. record.age là string '30', không phải number 30. CSV không có kiểu native — mọi field đều là string sau parse.

Câu 2. Code sau in ra gì? Có vấn đề không?

const records = parse('a,b\n9,10\n2,20\n20,3', {
  columns: true,
  skip_empty_lines: true,
});

console.log(records[0].a > records[0].b);  // ?
console.log(records[1].a > records[1].b);  // ?
console.log(records[2].a > records[2].b);  // ?
Đáp án

In ra true, false, false — cả 3 đều là string comparison (lexicographic), không phải số học.

Dòng 1: '9' > '10'true (so ký tự đầu: '9' > '1'). Số học 9 > 10falsesai, nhưng dễ nhận ra vì kết quả khác thường.

Dòng 2: '2' > '20'false ('2' = '2' hòa, '2' ngắn hơn '20' nên nhỏ hơn). Số học 2 > 20 cũng là false → vô tình trùng kết quả số học, nên bug bị che giấu — nguy hiểm vì người đọc tưởng code đúng.

Dòng 3 (chí mạng nhất): '20' > '3'false (so ký tự đầu: '2' < '3'). Nhưng số học 20 > 3true → kết quả NGƯỢC HOÀN TOÀN với toán học. Đây là lý do BẮT BUỘC phải ép kiểu: chỉ cần data có số nhiều chữ số so với số ít chữ số, string comparison cho kết quả sai trầm trọng và khó phát hiện.

Fix: luôn parseInt/parseFloat trước khi so sánh số: parseInt(records[0].a, 10) > parseInt(records[0].b, 10).

Câu 3. File CSV sau khi mở bằng editor xuất hiện  ở đầu, và sau khi parse với columns: true, records[0].usernameundefined dù CSV có column username. Nguyên nhân và cách fix?

Đáp án

Nguyên nhân: file CSV được save với BOM (Byte Order Mark) — 3 byte  ở đầu file. Khi parse, header column đầu tiên trở thành 'username' thay vì 'username'. Truy cập record.username trả về undefined.

Fix: strip BOM trước khi parse:

const content = readFileSync(path.join(__dirname, 'data/login.csv'), 'utf-8')
  .replace(/^/, '');
const records = parse(content, { columns: true, skip_empty_lines: true });

Hoặc save lại file với Excel: "CSV UTF-8" (không có "with BOM").

Câu 4. CSV sau parse có bao nhiêu record và column lệch hay không?

name,city,country
alice,Ho Chi Minh,Vietnam
bob,"New York, NY",USA
carol,Paris,France
Đáp án

Parse đúng, 3 records, không có column lệch. "New York, NY" được quote nên dấu phẩy bên trong không bị hiểu là delimiter. Kết quả: { name: 'bob', city: 'New York, NY', country: 'USA' }.

Câu 5. Có cần dùng csv-parse để đọc CSV đơn giản không? Khi nào nên dùng parse thủ công split(',')?

Đáp án

Không nên dùng split(',') trừ khi data cực kỳ đơn giản, không bao giờ có dấu phẩy trong value, và không có special character. split(',') không xử lý: quoted values ("a,b"), newline trong value, escaped quotes (""), BOM, hay trailing newline.

Dùng library như csv-parse cho mọi trường hợp thực tế — overhead không đáng kể so với độ ổn định đạt được.

16

Bài Tiếp Theo

Bài 95: Load Test Data Từ ENV / dotenv — dùng biến môi trường và file .env để inject config và credentials vào test mà không hard-code.