Danh sách bài viết

Bài 93: Load Test Data Từ JSON

Bài 90 đã cover kỹ thuật dùng mảng data inline cùng file spec. Bài này chuyển sang bước tiếp theo: lưu data vào file JSON riêng và load vào test. Hai cách load — import static (TypeScript resolveJsonModule) và readFileSync runtime — có trade-off khác nhau về type safety, dynamic path, và thời điểm lỗi xuất hiện. Bài cover cú pháp từng cách, type-safe JSON với interface, validation schema bằng Zod, pattern nested JSON, dynamic path theo biến môi trường, các pitfall thường gặp và quiz 5 câu.

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

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

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

  • Tổ chức test data vào file JSON riêng và load vào spec file bằng import hoặc readFileSync.
  • Cấu hình tsconfig.json với resolveJsonModule để import JSON có type-check.
  • Định nghĩa TypeScript interface cho JSON data để bắt lỗi mismatch tại compile time.
  • Dùng Zod để validate schema JSON khi data đến từ nguồn ngoài (không phải data do team tự viết).
  • Load data từ path động theo biến môi trường để một test suite chạy với nhiều bộ data khác nhau.
  • Nhận biết 4 pitfall phổ biến khi làm việc với JSON trong Playwright.
2

Tại Sao Tách Data Ra File JSON

Mảng data inline trong spec file (bài 90) phù hợp khi số lượng case nhỏ và data chỉ dùng cho một spec. Khi data vượt ngưỡng đó, tách ra file JSON có thêm lợi ích:

  • QA không cần đọc TypeScript — người viết test case chỉ cần biết JSON syntax. QA có thể thêm case mới vào file .json mà không đụng đến spec file.
  • Dùng chung giữa nhiều specdata/users.json import vào cả login.spec.ts, profile.spec.ts, admin.spec.ts. Sửa một chỗ, cập nhật tất cả.
  • Data được generate tự động — script CI có thể tạo file JSON từ DB snapshot hoặc API response, rồi test suite đọc file đó mà không cần sửa code.
  • Git diff rõ ràng hơn — thay đổi data và thay đổi logic test nằm ở hai file riêng, diff dễ review hơn.
3

Cấu Trúc File JSON

File JSON cho test data thường đặt trong thư mục data/ hoặc fixtures/data/ cùng cấp với spec file. Ví dụ file login cases:

// data/login-cases.json
[
  { "username": "admin", "password": "admin123", "expected": "success" },
  { "username": "wrong", "password": "wrong", "expected": "error" },
  { "username": "", "password": "x", "expected": "validation" }
]

Một số quy ước nên theo:

  • Mỗi object trong mảng là một test case — giữ field names nhất quán giữa các object.
  • Field expected nên dùng literal string mô tả outcome ("success", "error", "validation") thay vì boolean — dễ đọc hơn trong test name và report.
  • Không để comment trong JSON chuẩn (JSON spec không hỗ trợ). Nếu cần ghi chú, thêm field "description" hoặc dùng JSON5 (xem mục 14).
  • Đặt tên file theo domain: login-cases.json, product-search.json, checkout-scenarios.json.
4

Cách 1: Import Static (ESM / resolveJsonModule)

TypeScript hỗ trợ import file JSON trực tiếp khi bật resolveJsonModule: true trong tsconfig.json:

// Cách dùng phổ biến nhất — TypeScript tự infer type từ JSON
import loginCases from './data/login-cases.json';

// loginCases có type được infer tự động:
// { username: string; password: string; expected: string; }[]

Đây là cú pháp CommonJS-style import của TypeScript — không phải ESM import assertion. Hoạt động ngay khi resolveJsonModule: true.

Cú pháp ESM import assertion (Node.js ≥ 16.14 hoặc v18+):

// ESM assert syntax — cần Node.js hỗ trợ
import loginCases from './data/login-cases.json' assert { type: 'json' };

// Hoặc import attribute syntax (Node.js 22+, thay thế assert)
import loginCases from './data/login-cases.json' with { type: 'json' };

Với Playwright và TypeScript thông thường, cú pháp đầu tiên (resolveJsonModule không có assert) là lựa chọn ổn định nhất vì không phụ thuộc Node.js version. Cú pháp assert { type: 'json' } đã bị deprecated từ Node.js 21 — Node.js 22 chuyển sang with { type: 'json' }.

5

Cách 2: readFileSync Runtime

readFileSync từ Node.js built-in module fs đọc file tại runtime — không cần cấu hình TypeScript đặc biệt:

import { readFileSync } from 'fs';

const loginCases = JSON.parse(
  readFileSync('./data/login-cases.json', 'utf-8')
);
// Type: any — cần cast hoặc dùng interface (xem mục 8)

Lưu ý về path: readFileSync resolve path tương đối so với process working directory (thư mục chạy lệnh npx playwright test), không phải so với vị trí của spec file. Nếu chạy từ project root:

project/
  tests/
    login/
      login.spec.ts     ← spec file
      data/
        login-cases.json
// Chạy từ project root: npx playwright test
// Path tương đối tính từ project root
const data = JSON.parse(readFileSync('./tests/login/data/login-cases.json', 'utf-8'));

// Hoặc dùng __dirname để path tính từ vị trí file spec (an toàn hơn)
import { join } from 'path';
const data = JSON.parse(
  readFileSync(join(__dirname, 'data/login-cases.json'), 'utf-8')
);

Dùng join(__dirname, ...) là cách an toàn nhất — path không phụ thuộc vào cwd khi chạy test.

6

Import vs readFileSync — Khi Nào Dùng Cái Nào

Import static readFileSync
Path Static, biết tại compile time Có thể dynamic (tính toán tại runtime)
Type safety TypeScript infer tự động từ JSON structure any — phải cast hoặc dùng interface
Lỗi JSON malformed Compile time (TypeScript báo ngay) Runtime (khi JSON.parse thực thi)
Phụ thuộc tsconfig Cần resolveJsonModule: true Không cần config đặc biệt
Dynamic path Không (path phải biết tại compile time) Có (build path từ biến, env, logic)
Phù hợp khi Data tĩnh, path cố định, cần type-check Path phụ thuộc env, data generate động, cần flexibility

Nguyên tắc chọn: nếu path file JSON biết trước (data viết tay, commit vào repo, không thay đổi theo env) — dùng import. Nếu path cần tính toán tại runtime (theo TEST_ENV, theo ngày, theo argument) — dùng readFileSync.

7

Kết Hợp Với for...of

Sau khi load được data, pattern kết hợp với for...of giống hệt bài 90 — điểm khác duy nhất là data đến từ file thay vì mảng inline:

import { test, expect } from '@playwright/test';
import loginCases from './data/login-cases.json';

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

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

Test names sinh ra từ data:

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

Username rỗng trong tên test dùng tc.username || 'empty' để tránh tên test có chuỗi rỗng — tên như login: → validation khó đọc trong reporter.

8

Type-Safe JSON Với Interface

Import static cho type infer tự động nhưng type bị widened — ví dụ field expected bị infer là string thay vì union literal 'success' | 'error' | 'validation'. Định nghĩa interface rõ ràng giải quyết điều này:

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

// Cách 1: cast sau import
import rawData from './data/login-cases.json';
const loginCases = rawData as LoginCase[];

// Cách 2: dùng require với type annotation (CommonJS)
const loginCases: LoginCase[] = require('./data/login-cases.json');

// Cách 3: readFileSync + JSON.parse với type annotation
import { readFileSync } from 'fs';
const loginCases: LoginCase[] = JSON.parse(
  readFileSync('./data/login-cases.json', 'utf-8')
);

Với interface, TypeScript sẽ báo lỗi nếu file JSON có field thiếu hoặc giá trị sai kiểu — ví dụ nếu expected"timeout" (không có trong union), lỗi xuất hiện tại lúc gán as LoginCase[].

Lưu ý: cast (as LoginCase[]) không validate dữ liệu thực — nó chỉ nói với TypeScript "tin tao, data này đúng kiểu". Nếu cần validation thực sự tại runtime, dùng Zod (mục 9).

9

Validate Schema Với Zod

Khi data JSON đến từ nguồn ngoài (CI generate, teammate tạo tay, export từ tool), không nên tin tưởng hoàn toàn vào structure. Zod parse và validate đồng thời — nếu data không khớp schema, lỗi rõ ràng thay vì undefined hoặc wrong assertion:

import { z } from 'zod';
import { readFileSync } from 'fs';
import { join } from 'path';

const LoginCaseSchema = z.object({
  username: z.string(),
  password: z.string(),
  expected: z.enum(['success', 'error', 'validation']),
});

const LoginCasesSchema = z.array(LoginCaseSchema);

// Type được infer từ schema — không cần định nghĩa interface riêng
type LoginCase = z.infer<typeof LoginCaseSchema>;

const raw = JSON.parse(
  readFileSync(join(__dirname, 'data/login-cases.json'), 'utf-8')
);

// parse() throw ZodError chi tiết nếu data sai
const loginCases = LoginCasesSchema.parse(raw);

Khi JSON có entry sai — ví dụ "expected": "timeout" — Zod báo:

ZodError: [
  {
    "code": "invalid_enum_value",
    "options": ["success", "error", "validation"],
    "path": [2, "expected"],
    "message": "Invalid enum value. Expected 'success' | 'error' | 'validation', received 'timeout'"
  }
]

Lỗi này xuất hiện khi spec file được load — trước khi bất kỳ test nào chạy. Dễ debug hơn nhiều so với assertion fail mơ hồ trong test body.

Dùng safeParse() thay vì parse() nếu muốn xử lý lỗi thay vì throw:

const result = LoginCasesSchema.safeParse(raw);
if (!result.success) {
  throw new Error(`Invalid test data: ${result.error.message}`);
}
const loginCases = result.data;
10

Nested JSON

Một file JSON có thể chứa nhiều tập data cho nhiều test suite khác nhau:

// data/test-data.json
{
  "users": [
    { "username": "admin", "password": "admin123", "role": "admin" },
    { "username": "user1", "password": "user123", "role": "user" }
  ],
  "products": [
    { "id": "P001", "name": "Widget", "price": 9.99 },
    { "id": "P002", "name": "Gadget", "price": 24.99 }
  ],
  "config": {
    "baseURL": "https://staging.example.com",
    "timeout": 5000
  }
}
import testData from './data/test-data.json';

// Destructure phần cần dùng
const { users, products, config } = testData;

// Dùng trong test
for (const user of users) {
  test(`login as ${user.role}: ${user.username}`, async ({ page }) => {
    await page.goto(`${config.baseURL}/login`);
    await page.getByLabel('Username').fill(user.username);
    await page.getByLabel('Password').fill(user.password);
    await page.getByRole('button', { name: 'Login' }).click();
    await expect(page.getByTestId('user-role')).toHaveText(user.role);
  });
}

Nested JSON có ích khi muốn commit một file data duy nhất thay vì nhiều file nhỏ. Nhược điểm: file trở nên lớn và khó diff. Nên cân nhắc tách file theo domain khi file JSON vượt ~100 dòng.

11

Dynamic Path Theo Biến Môi Trường

Khi cần chạy test suite với bộ data khác nhau tùy môi trường, dùng biến môi trường để build path động:

import { readFileSync } from 'fs';
import { join } from 'path';

const env = process.env.TEST_ENV ?? 'staging';
// Kết quả: ./data/staging.json hoặc ./data/prod.json hoặc ./data/local.json

const loginCases = JSON.parse(
  readFileSync(join(__dirname, `data/${env}.json`), 'utf-8')
);

Chạy với data khác nhau:

# Dùng data staging (mặc định)
npx playwright test

# Dùng data production (smoke test)
TEST_ENV=prod npx playwright test --project=chromium

# Dùng data local (development)
TEST_ENV=local npx playwright test login.spec.ts

Cấu trúc thư mục khi dùng pattern này:

tests/
  login/
    login.spec.ts
    data/
      staging.json    ← nhiều case, edge cases
      prod.json       ← smoke test cases thôi
      local.json      ← data cụ thể cho local dev

Nếu file không tồn tại (TEST_ENV có giá trị sai), readFileSync throw ENOENT ngay khi spec load. Lỗi xuất hiện trước khi test nào chạy — dễ debug. Thêm fallback rõ ràng nếu cần:

const allowedEnvs = ['staging', 'prod', 'local'] as const;
const env = process.env.TEST_ENV ?? 'staging';

if (!allowedEnvs.includes(env as typeof allowedEnvs[number])) {
  throw new Error(`TEST_ENV="${env}" không hợp lệ. Dùng: ${allowedEnvs.join(', ')}`);
}
12

tsconfig Setup

Để import JSON static hoạt động với TypeScript, cần bật hai option trong tsconfig.json:

{
  "compilerOptions": {
    "resolveJsonModule": true,
    "esModuleInterop": true
  }
}
  • resolveJsonModule: true — cho phép TypeScript import file .json và infer type từ nội dung.
  • esModuleInterop: true — đảm bảo default import từ CommonJS module hoạt động đúng (nhiều project bật sẵn).

Nếu project Playwright dùng file tsconfig.json riêng (thường trong thư mục tests/), thêm vào đó thay vì tsconfig root:

// tests/tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "resolveJsonModule": true
  }
}

Playwright đọc tsconfig.json gần nhất với spec file — không cần cấu hình thêm trong playwright.config.ts.

13

Use Cases

Large test data — hàng trăm case

Test form nhập liệu với 200 tổ hợp input khác nhau: giữ 200 object trong JSON file, spec file chỉ có vài chục dòng test logic. Khi cần thêm case, thêm vào JSON.

Shared data giữa nhiều spec

// Một file JSON
// data/users.json: [{ username, password, role, ... }]

// Dùng trong nhiều spec
// login.spec.ts
import users from '../data/users.json';

// profile.spec.ts
import users from '../data/users.json';

// admin-panel.spec.ts
import users from '../data/users.json';

Thay đổi credentials trong users.json cập nhật tất cả spec — không cần sửa từng file.

QA edit JSON không cần viết TypeScript

Với team có QA riêng, tách data ra JSON để QA tự thêm/sửa case mà không cần đọc spec code. Spec file là trách nhiệm của developer; JSON file là trách nhiệm của QA.

Data generate từ script

// scripts/generate-test-data.ts
// Pull users từ staging DB → ghi ra tests/data/users.json
// CI chạy script này trước khi chạy test suite

Pattern này cho phép test chạy với data thực từ hệ thống thay vì data hardcode.

14

Limitation

JSON không hỗ trợ comment

JSON spec chuẩn không cho phép comment // ... hay /* ... */. Nếu cần ghi chú bên cạnh test case, có hai lựa chọn:

  • Thêm field "description" vào mỗi object — { "username": "admin", ..., "description": "admin default creds" }.
  • Dùng JSON5 (npm install json5) — superset của JSON hỗ trợ comment, trailing comma, unquoted keys. Cần dùng JSON5.parse() thay vì JSON.parse().

File JSON lớn → tốn memory

Import hoặc readFileSync đọc toàn bộ file vào memory tại load time. Nếu file JSON hàng trăm MB (hiếm trong test context, nhưng có thể xảy ra với data generate), cân nhắc stream hoặc tách file nhỏ hơn.

Import assert syntax thay đổi giữa Node versions

  • Node.js 16.14–21: assert { type: 'json' }
  • Node.js 22+: with { type: 'json' } (assert bị deprecated)
  • Giải pháp: dùng resolveJsonModule TypeScript thay vì import assertion để tránh phụ thuộc Node version.

Không có built-in schema validation

TypeScript cast (as Type[]) không validate dữ liệu thực — chỉ là hint cho compiler. File JSON hợp lệ về JSON syntax nhưng sai về business logic sẽ gây test fail muộn hơn thay vì fail sớm. Zod giải quyết vấn đề này (mục 9).

15

Pitfalls

Pitfall 1: Quên bật resolveJsonModule

// Lỗi khi import JSON mà không có resolveJsonModule: true
import loginCases from './data/login-cases.json';
// TypeScript error: "Cannot find module './data/login-cases.json'.
// Consider using '--resolveJsonModule' to import module with '.json' extension."

Fix: thêm "resolveJsonModule": true vào compilerOptions trong tsconfig.json của dự án hoặc của thư mục test.

Pitfall 2: Import assert syntax sai Node version

// Dùng assert trên Node.js 22 → deprecation warning hoặc lỗi
import data from './data.json' assert { type: 'json' };

// Node.js 22 dùng with thay vì assert
import data from './data.json' with { type: 'json' };

// An toàn nhất: không dùng assertion, chỉ dùng resolveJsonModule
import data from './data.json';  // hoạt động trên mọi Node version khi có resolveJsonModule

Pitfall 3: JSON malformed — parse error khó debug

// login-cases.json có trailing comma (không hợp lệ trong JSON)
[
  { "username": "admin", "password": "admin123", "expected": "success" },  ← trailing comma sau entry cuối
]
SyntaxError: Unexpected token } in JSON at position 89

Lỗi này xuất hiện tại runtime với readFileSync, hoặc tại compile time với import static. Dùng editor có JSON linting (VS Code highlight sẵn) hoặc chạy node -e "JSON.parse(require('fs').readFileSync('./data/login-cases.json','utf-8'))" để kiểm tra.

Pitfall 4: Path tương đối sai cwd với readFileSync

// spec file ở: tests/login/login.spec.ts
// data file ở: tests/login/data/login-cases.json

// SAI — path tính từ project root khi chạy từ root
const data = JSON.parse(readFileSync('./data/login-cases.json', 'utf-8'));
// Lỗi: ENOENT: no such file or directory, open './data/login-cases.json'
// vì CWD là project root, không có thư mục data/ ở root

// ĐÚNG — dùng __dirname để tính từ vị trí file
import { join } from 'path';
const data = JSON.parse(readFileSync(join(__dirname, 'data/login-cases.json'), 'utf-8'));
16

Quiz

Câu 1. Hai dòng code sau có hành vi khác nhau thế nào?

// A
import loginCases from './data/login-cases.json';

// B
import { readFileSync } from 'fs';
const loginCases = JSON.parse(readFileSync('./data/login-cases.json', 'utf-8'));
Đáp án

A (import static): path phải cố định tại compile time; TypeScript tự infer type từ JSON; lỗi JSON malformed xuất hiện tại compile time; cần resolveJsonModule: true trong tsconfig; path tính từ vị trí file spec.

B (readFileSync): path có thể dynamic (tính toán tại runtime); type là any — cần cast hoặc interface; lỗi JSON malformed xuất hiện tại runtime; không cần config tsconfig đặc biệt; path tính từ CWD khi chạy test (trừ khi dùng __dirname).

Câu 2. Code sau báo lỗi gì? Fix như thế nào?

// tsconfig.json không có resolveJsonModule
import cases from './data/cases.json';

for (const tc of cases) {
  test(`case ${tc.id}`, async ({ page }) => { ... });
}
Đáp án

TypeScript báo: Cannot find module './data/cases.json'. Consider using '--resolveJsonModule' to import module with '.json' extension.

Fix: thêm vào tsconfig.json:

{
  "compilerOptions": {
    "resolveJsonModule": true
  }
}

Câu 3. File test-data.json đặt tại tests/login/data/test-data.json. Spec file chạy từ project root. Dòng nào đúng?

// A
const data = JSON.parse(readFileSync('./data/test-data.json', 'utf-8'));

// B
const data = JSON.parse(readFileSync('./tests/login/data/test-data.json', 'utf-8'));

// C
const data = JSON.parse(readFileSync(join(__dirname, 'data/test-data.json'), 'utf-8'));
Đáp án

A — sai: path ./data/test-data.json tính từ CWD (project root), không tồn tại file này ở đó. Lỗi ENOENT.

B — đúng nhưng fragile: hoạt động khi chạy từ project root, nhưng nếu cwd thay đổi (CI chạy từ thư mục khác), sẽ lỗi.

C — đúng và robust nhất: __dirname là thư mục chứa spec file (tests/login/), không phụ thuộc cwd. Path luôn resolve đúng dù chạy từ đâu.

Câu 4. Tại sao đoạn code sau là sai về mặt type safety dù TypeScript không báo lỗi?

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

import rawData from './data/login-cases.json';
const loginCases = rawData as LoginCase[];
Đáp án

Cast (as LoginCase[]) chỉ là assertion với TypeScript compiler — không có validation thực tại runtime. Nếu login-cases.json có entry với "expected": "timeout" (không trong union), TypeScript không báo lỗi nhưng test sẽ silently fail hoặc hành xử sai vì condition if (tc.expected === 'success') không match.

Để validation thực: dùng Zod LoginCasesSchema.parse(rawData) — throw lỗi rõ ràng nếu data không khớp schema.

Câu 5. Đội QA yêu cầu thêm comment vào file JSON để giải thích từng test case. JSON chuẩn không hỗ trợ comment. Có hai đề xuất: (A) thêm field "description" vào mỗi object, (B) chuyển sang JSON5. Trong trường hợp nào mỗi cách phù hợp hơn?

Đáp án

Cách A (field description): phù hợp hơn trong hầu hết trường hợp. Không cần dependency mới (json5 package), hoạt động với JSON.parse hoặc import thông thường. Field description còn có thể dùng làm một phần tên test. Nhược điểm: mọi object đều phải có field này nếu muốn nhất quán.

Cách B (JSON5): phù hợp khi file JSON phức tạp, cần nhiều comment inline (ví dụ giải thích config, nested structure), hoặc cần trailing comma để diff git gọn hơn. Cần cài npm install json5 và thay JSON.parse bằng JSON5.parse. Import static TypeScript không hỗ trợ JSON5 — chỉ dùng được với readFileSync.

17

Bài Tiếp Theo

Bài 94: Load Test Data Từ CSV — CSV là định dạng phổ biến từ spreadsheet (Google Sheets, Excel). Bài tiếp theo cover đọc CSV với csv-parse, map row thành object, xử lý header row và type coercion.