Mở đầu — vì sao bài này quan trọng
Có một loại bug mà rất nhiều tester Việt Nam gặp phải nhưng lại không biết cách gọi tên nó: bạn tạo một khách hàng tên "Nguyễn Thị Hoà", API trả về status 200, response body nhìn qua thì đúng y chang, thế nhưng khi tìm kiếm lại bằng đúng cái tên đó thì hệ thống báo "không tìm thấy". Hoặc bạn gõ trường full_name = "Trần Văn Đầy" trong Postman, gửi lên server, và assertion pm.expect(body.full_name).to.eql("Trần Văn Đầy") lại fail — dù trên màn hình hai chuỗi trông giống hệt nhau đến từng nét.
Đây không phải lỗi ngẫu nhiên. Đây là hệ quả của việc tiếng Việt là một trong những ngôn ngữ "khó nhằn" nhất thế giới về mặt Unicode: mỗi ký tự có dấu như "ầ", "ế", "ộ" có thể được máy tính lưu theo hai cách khác nhau mà mắt người không phân biệt được, nhưng máy tính thì coi chúng là hai chuỗi hoàn toàn khác nhau. Khi frontend gõ một kiểu, backend lưu một kiểu, và test của bạn so sánh một kiểu thứ ba — bug xuất hiện, và nó cực kỳ khó tái hiện vì "trông thì đúng mà".
Bài này tập trung riêng vào một góc rất cụ thể của nghề QA API: làm sao viết test kiểm soát được tiếng Việt và Unicode trong Postman — từ chuyện normalization (NFC/NFD), charset/encoding trong HTTP header, cho đến việc so sánh chuỗi có dấu một cách đáng tin cậy. Nắm được nó, bạn sẽ tránh được cả một lớp bug âm thầm mà nhiều dự án Việt Nam mất hàng tuần để truy vết.
Khái niệm cốt lõi
Unicode normalization: một chữ "ầ", hai cách lưu
Hãy nhìn kỹ chữ ầ (a + dấu mũ + dấu huyền). Máy tính có hai cách biểu diễn nó:
- NFC (Normalization Form C — Composed / "gộp"): lưu thành 1 code point duy nhất là
U+1EA7. Đây là dạng gọn nhất, phổ biến nhất. - NFD (Normalization Form D — Decomposed / "tách"): lưu thành nhiều code point riêng lẻ, ví dụ
U+0061(chữ a thường) +U+0302(dấu mũ combining ◌̂) +U+0300(dấu huyền combining ◌̀). Ở đây một "chữ nhìn thấy" được ghép từ 3 mảnh.
const nfc = "ầ"; // giả sử được gõ ở dạng NFC (1 code point)
const nfd = "ầ"; // dạng NFD (a + combining circumflex + combining grave)console.log(nfc === nfd); // false ← QUAN TRỌNG
console.log(nfc.length); // 1
console.log(nfd.length); // 3
console.log(nfc === nfd.normalize("NFC")); // true ← sau khi chuẩn hóa
Đây chính là gốc rễ của phần lớn bug tiếng Việt. Hai chuỗi "bằng nhau với con người" nhưng "khác nhau với ===".
Vì sao lại có chuyện gõ ra hai kiểu? Thủ phạm thường là:
- macOS trong lịch sử hay lưu tên file và một số nhập liệu ở dạng NFD, trong khi Windows/Linux nghiêng về NFC. Một QA dùng MacBook nhập liệu vào Postman rất dễ vô tình gửi lên chuỗi NFD.
- Bộ gõ tiếng Việt (Unikey, EVKey, GoTiengViet, bàn phím iOS/Android) có tùy chọn bảng mã và kiểu Unicode khác nhau. Có bộ gõ xuất NFC, có bộ gõ xuất tổ hợp.
- Copy-paste từ PDF, Word, hay web — chuỗi mang theo dạng chuẩn hóa của nguồn.
Trong JavaScript sandbox của Postman, .normalize() là bạn thân
Postman chạy script bằng engine JavaScript, nên bạn có sẵn method chuẩn của chuỗi: String.prototype.normalize(). Bốn dạng bạn cần biết:
.normalize("NFC")— gộp về code point tổng hợp. Đây là dạng bạn nên dùng làm chuẩn cho hầu hết test tiếng Việt..normalize("NFD")— tách ra combining marks.NFKC/NFKD— "K" là compatibility: ngoài việc gộp/tách còn chuẩn hóa các ký tự tương thích (ví dụ "fi" ligature → "fi", số La Mã "Ⅳ" → "IV"). Với tiếng Việt thuần thường không cần, nhưng hữu ích khi dữ liệu lẫn ký tự đặc biệt.
Encoding và charset trong HTTP: nơi tiếng Việt dễ "biến thành cá"
Normalization là chuyện về "chữ được ghép thế nào". Encoding là chuyện về "chữ được biến thành byte thế nào khi truyền qua mạng". Đây là hai tầng khác nhau nhưng dễ bị lẫn lộn.
- UTF-8 là bảng mã tiêu chuẩn cho tiếng Việt trên web. Mỗi ký tự có dấu chiếm 2–3 byte.
- Header
Content-Type: application/json; charset=utf-8báo cho server biết body đang ở UTF-8. Nếu thiếucharsethoặc server hiểu nhầm sang Latin-1/Windows-1258, tiếng Việt sẽ hiện thành các ký tự rác kiểuNguyá»…nhay dấu hỏiNguy?n. Dân trong nghề hay gọi vui là "bị biến thành cá" hoặc lỗi "tiếng Việt loạn xì ngầu". - Trong URL và query string, ký tự có dấu phải được percent-encode: "Hoà" →
Ho%C3%A0. Postman thường tự làm việc này, nhưng khi bạn tự ghép URL trong pre-request script thì phải chủ động dùngencodeURIComponent().
Tình huống thực tế
Ví dụ 1 — Sàn TMĐT "ChoTot-clone": bug tìm kiếm khách hàng "trông thì đúng"
Một team QA tại một startup thương mại điện tử ở TP.HCM (tạm gọi dự án là VietMart) test API quản lý khách hàng. Kịch bản: POST /customers tạo khách "Lê Thị Hoà", rồi GET /customers?name=Lê Thị Hoà để kiểm tra tìm lại được. Test chạy trên máy MacBook của bạn QA thì fail lúc được lúc không; chạy trên máy Windows của bạn dev thì luôn pass.
Điều tra ra: MacBook, qua bộ gõ, gửi tên ở dạng NFD khi tạo. Database (MySQL, collation utf8mb4_unicode_ci) lưu nguyên byte nhận được. Khi search, Postman lại gửi query string ở dạng NFC (do lần này gõ khác), nên chuỗi trong DB (NFD) không khớp chuỗi tìm kiếm (NFC) ở tầng so sánh nhị phân.
Cách team xử lý trong test suite: chuẩn hóa dữ liệu ngay trong pre-request và assert theo NFC.
// Pre-request: chuẩn hóa biến trước khi đưa vào body/URL
let ten = "Lê Thị Hoà".normalize("NFC");
pm.collectionVariables.set("customerName", ten);// Test: so sánh sau khi normalize CẢ HAI vế
const actual = pm.response.json().name.normalize("NFC");
const expected = pm.collectionVariables.get("customerName").normalize("NFC");
pm.test("Tên khách trả về khớp (đã chuẩn hóa NFC)", () => {
pm.expect(actual).to.eql(expected);
});
Bài học: bug không nằm ở code API mà nằm ở dữ liệu test không được chuẩn hóa. Nếu bạn không kiểm soát dạng Unicode của input, test của bạn sẽ "nhấp nháy" (flaky) tùy theo máy ai chạy — cơn ác mộng của mọi CI pipeline.
Ví dụ 2 — API xuất hóa đơn của một fintech: response "biến thành cá" vì thiếu charset
Một công ty fintech ở Hà Nội (giả định là PayViet) có endpoint POST /invoices nhận tên người mua tiếng Việt và trả về PDF metadata. QA phát hiện field buyer_name trong response JSON hiện ra Trần Quốc Việt thay vì "Trần Quốc Việt". Response status vẫn 200.
Kiểm tra header response: Content-Type: application/json — thiếu charset=utf-8. Một số client (và cả tab Preview cũ) mặc định đọc theo Latin-1, nên byte UTF-8 bị diễn giải sai. Team viết luôn một test canh gác encoding cho mọi endpoint có tiếng Việt:
pm.test("Content-Type khai báo UTF-8", () => {
const ct = pm.response.headers.get("Content-Type") || "";
pm.expect(ct.toLowerCase()).to.include("utf-8");
});pm.test("Tiếng Việt không bị mojibake (ký tự rác)", () => {
const name = pm.response.json().buyer_name;
// Mojibake điển hình chứa các chuỗi như "Ã", "Â", "á»", " Â"
pm.expect(name).to.not.match(/Ã.|Â.|á»|â€/);
// Và phải chứa ký tự tiếng Việt hợp lệ
pm.expect(name.normalize("NFC")).to.match(/[àáảãạăâđêôơư]/i);
});
Bài học: hãy tách bạch hai câu hỏi. "Có phải lỗi normalization không?" → dùng .normalize(). "Có phải lỗi encoding/charset không?" → soi header Content-Type và tìm dấu vết mojibake. Test canh gác header nên là một phần mặc định trong suite của mọi dự án Việt Nam.
Ví dụ 3 — App giao đồ ăn: đếm ký tự sai làm validation "chặn nhầm"
Một team làm app giao đồ ăn (giả định FoodNhanh) có rule: tên món ăn tối đa 30 ký tự. Tester nhập "Bún bò Huế đặc biệt cay xé lưỡi ngon" và bị API trả 422 "vượt quá 30 ký tự", dù đếm bằng mắt chỉ khoảng 30. Nguyên nhân: chuỗi được nhập ở dạng NFD nên mỗi chữ có dấu bị đếm thành 2–3 "ký tự" (do combining marks tính riêng), khiến .length phồng lên bất thường.
const ten = pm.collectionVariables.get("productName");
console.log("length thô (NFD có thể phồng):", ten.length);
console.log("length sau NFC:", ten.normalize("NFC").length);pm.test("Độ dài tên món tính đúng theo NFC", () => {
// Đếm trên chuỗi đã gộp NFC mới phản ánh đúng số 'chữ' người dùng thấy
pm.expect(ten.normalize("NFC").length).to.be.at.most(30);
});
Bài học: normalization không chỉ ảnh hưởng so sánh bằng, mà còn ảnh hưởng đếm độ dài. Bất kỳ validation nào dựa trên số ký tự (giới hạn tên, mã, mô tả) đều phải thống nhất một dạng chuẩn hóa giữa client, server và test — nếu không sẽ có chỗ "chặn nhầm", chỗ "cho lọt".
Hướng dẫn từng bước
Đây là quy trình xây một bộ test tiếng Việt đáng tin trong Postman, áp dụng được ngay.
Bước 1 — Chốt một "dạng chuẩn" cho cả dự án. Hầu hết trường hợp chọn NFC vì nó gọn, phổ biến trên web và tương thích database utf8mb4. Ghi rõ quy ước này vào tài liệu test: "mọi input/expected tiếng Việt đều normalize NFC trước khi dùng".
Bước 2 — Chuẩn hóa input ở pre-request script. Đừng tin dữ liệu gõ tay hay lấy từ file CSV/JSON. Ngay đầu request, chuẩn hóa mọi biến chứa tiếng Việt:
["customerName", "address", "note"].forEach(k => {
const v = pm.collectionVariables.get(k);
if (typeof v === "string") pm.collectionVariables.set(k, v.normalize("NFC"));
});
Bước 3 — Viết một helper so sánh dùng chung. Đặt trong Collection-level pre-request script để mọi request tái sử dụng (chủ đề tái sử dụng code sẽ đào sâu ở bài khác; ở đây ta chỉ cần một hàm nhỏ):
pm.globals.set("veqEqual", function veqEqual(a, b){
return String(a).normalize("NFC") === String(b).normalize("NFC");
});
Trong test script gọi ra bằng eval(pm.globals.get("veqEqual")).
Bước 4 — Test cả normalization lẫn encoding. Với mỗi endpoint có tiếng Việt, đảm bảo có ít nhất ba assertion: (a) Content-Type chứa utf-8; (b) giá trị so sánh sau khi normalize NFC; (c) không có mojibake.
Bước 5 — Kiểm tra vòng lặp (round-trip). Cách chắc chắn nhất: tạo bản ghi với tên tiếng Việt, đọc lại bằng GET, và assert giá trị đọc về (đã NFC) đúng bằng giá trị gửi đi (đã NFC). Nếu round-trip pass, tầng lưu trữ và truyền tải của bạn an toàn với tiếng Việt.
Bước 6 — Chủ động percent-encode khi tự ghép URL. Nếu bạn xây query string trong script:
const ten = pm.collectionVariables.get("customerName").normalize("NFC");
pm.request.url.query.add({ key: "name", value: encodeURIComponent(ten) });
Bước 7 — Test cả biên khó. Đưa vào bộ dữ liệu những ca hiểm: chữ "ạ", "ợ", "ệ" (nhiều dấu chồng), chữ "đ"/"Đ", emoji lẫn tiếng Việt ("Ngon 😋"), và cả chuỗi cố tình NFD để chứng minh test của bạn xử lý được cả hai dạng.
Lỗi thường gặp & mẹo
Lỗi 1 — So sánh chuỗi mà không normalize. pm.expect(actual).to.eql(expected) fail bí ẩn dù hai chuỗi "nhìn giống nhau". 90% là do NFC vs NFD. Mẹo: khi một assertion tiếng Việt fail, in ra [...chuỗi].map(c => c.codePointAt(0).toString(16)) của cả hai vế để thấy code point khác nhau ở đâu.
Lỗi 2 — Nhầm lẫn giữa lỗi encoding và lỗi normalization. Nếu chuỗi hiện ký tự rác (Ã, á») → đó là encoding/charset, .normalize() không cứu được. Nếu chuỗi hiện đúng đẹp nhưng === vẫn false → đó là normalization. Chẩn đoán sai hướng khiến bạn sửa nhầm chỗ hàng giờ.
Lỗi 3 — Tin vào .length để đếm ký tự tiếng Việt. Với NFD, .length phồng lên; với ký tự ngoài BMP (emoji), một "chữ" có thể là 2 code unit. Muốn đếm "chữ người dùng thấy" chính xác, normalize NFC trước, và cân nhắc dùng [...str].length (spread theo code point) thay vì .length.
Lỗi 4 — Quên charset khi tự set header. Khi override Content-Type trong request, luôn ghi đủ application/json; charset=utf-8. Thiếu nó, một số server/proxy đoán sai charset.
Lỗi 5 — Dữ liệu test file bị lưu sai encoding. File CSV/JSON dùng cho test mà lưu bằng ANSI/Windows-1258 thay vì UTF-8 sẽ đưa mojibake vào từ gốc. Mẹo: luôn lưu file test UTF-8 (không BOM); BOM ở đầu file JSON đôi khi làm parser lỗi.
Mẹo vàng — Snapshot code point. Khi bàn giao bug tiếng Việt cho dev, đừng chỉ paste chuỗi (họ sẽ thấy giống bạn). Hãy đính kèm dãy code point hex của chuỗi lỗi và chuỗi mong đợi. Đó là bằng chứng không thể chối cãi về NFC/NFD, giúp fix nhanh gấp nhiều lần.
Bài tập thực hành
- Chứng minh NFC ≠ NFD. Trong một Postman test script, tạo hai biến cùng hiển thị "phở": một gõ trực tiếp, một ghép bằng combining mark (
"phở"hoặc tương tự). In ra.lengthvà kết quả===của chúng, rồi in lại sau khi.normalize("NFC"). Ghi lại quan sát.
- Round-trip tiếng Việt. Với một API bất kỳ có endpoint tạo/đọc (dùng API công khai hoặc mock của riêng bạn), gửi tên "Đặng Thị Ngọc Ánh" ở dạng NFC, đọc lại bằng GET, và viết assertion so sánh sau khi normalize cả hai vế. Sau đó thử gửi lại đúng tên đó ở dạng NFD và xác nhận test của bạn vẫn pass nhờ normalize.
- Bộ canh gác encoding. Viết một test tái sử dụng gồm hai assertion: (a) header
Content-Typechứautf-8; (b) response không chứa mojibake (regex bắtÃ,á»,â€). Gắn nó vào một endpoint và cố tình đổi charset để thấy nó bắt lỗi.
- Đếm ký tự đúng. Viết một hàm nhận chuỗi tiếng Việt (có thể lẫn emoji), trả về số "chữ hiển thị" chính xác bằng cách normalize NFC rồi spread
[...str]. So sánh kết quả với.lengththô trên vài chuỗi mẫu.
Tóm tắt
Tiếng Việt là bài kiểm tra khắc nghiệt cho bất kỳ pipeline test API nào vì hai tầng vấn đề chồng lên nhau. Tầng thứ nhất là Unicode normalization: một chữ như "ầ" có thể là 1 code point (NFC U+1EA7) hay nhiều code point tách rời (NFD), và máy tính coi hai dạng là khác nhau — gây fail so sánh, phồng độ dài, tìm kiếm không ra dù "trông thì đúng". Tầng thứ hai là encoding/charset: nếu không phải UTF-8 và không khai báo charset=utf-8, tiếng Việt biến thành mojibake ngay từ đường truyền.
Trong Postman, vũ khí của bạn gọn nhẹ mà mạnh: .normalize("NFC") để thống nhất dạng chữ trước khi so sánh hay đếm; kiểm tra header Content-Type và quét mojibake để canh gác encoding; encodeURIComponent() khi tự ghép URL; và test round-trip để chứng minh dữ liệu đi–về nguyên vẹn. Nguyên tắc bao trùm: chốt một dạng chuẩn (NFC) cho cả dự án, normalize cả hai vế trước mọi so sánh, và luôn tách bạch lỗi normalization với lỗi encoding. Làm được vậy, bạn xóa sổ cả một lớp bug âm thầm mà nhiều dự án Việt Nam phải trả giá bằng hàng tuần truy vết.