Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Test pagination, filtering, sorting

Mở đầu — vì sao bài này quan trọng

Nếu bạn từng mở một trang danh sách sản phẩm trên Tiki hay Shopee, cuộn xuống cuối rồi bấm "Trang 2", hoặc lọc "Giá thấp đến cao", sắp xếp theo "Bán chạy nhất" — thì bạn đã tương tác với đúng ba tính năng mà bài học hôm nay nói tới: pagination (phân trang), filtering (lọc) và sorting (sắp xếp). Đằng sau mỗi thao tác đơn giản đó là một request API với đủ loại tham số (page, limit, sort, filter), và mỗi tham số là một cơ hội để hệ thống trả về dữ liệu sai.

Đây là nhóm tính năng bị test hời hợt nhất mà tôi từng gặp trong các dự án QA ở Việt Nam. Đa số tester chỉ kiểm tra "API trả về 200 và có danh sách" rồi đóng ticket. Nhưng những lỗi thật sự gây đau đầu lại nằm ở chỗ tinh vi: trang cuối bị lặp bản ghi, tổng số total không khớp với số item đếm được, lọc theo nhiều điều kiện cho ra kết quả sai logic AND/OR, sắp xếp không ổn định khiến hai bản ghi cùng giá trị nhảy vị trí giữa các lần gọi. Những lỗi này không làm sập hệ thống, nên chúng lọt lưới cho tới khi khách hàng phàn nàn "sao sản phẩm này hiện hai lần", hoặc tệ hơn, cho tới khi một đơn hàng bị bỏ sót vì nó rơi vào "khe" giữa hai trang.

Trong bài này, chúng ta sẽ dùng Postman để test ba tính năng đó một cách có hệ thống — không phải kiểm tra bề mặt, mà đào sâu vào biên (boundary), tính nhất quán (consistency) và logic kết hợp. Đây là kỹ năng phân biệt một API tester thật sự với người chỉ bấm Send.

Khái niệm cốt lõi

Các kiểu pagination

Có ba kiểu phân trang phổ biến bạn sẽ gặp, và mỗi kiểu cần chiến lược test khác nhau.

Offset-based (page/limit) là kiểu quen thuộc nhất:

GET /products?page=2&limit=20
Response: { "data": [...], "total": 1500, "page": 2, "total_pages": 75 }

Server hiểu là "bỏ qua 20 bản ghi đầu, lấy 20 bản ghi tiếp theo". Ưu điểm: dễ hiểu, nhảy trang tùy ý được. Nhược điểm: nếu dữ liệu thay đổi giữa các lần gọi (có người thêm/xóa sản phẩm), bản ghi có thể bị lặp hoặc mất khi chuyển trang.

Cursor-based (con trỏ) dùng một mã trỏ tới vị trí cuối cùng:

GET /products?limit=20&cursor=eyJpZCI6MTIzfQ==
Response: { "data": [...], "next_cursor": "eyJpZCI6MTQzfQ==", "has_more": true }

Kiểu này ổn định hơn với dữ liệu động (feed mạng xã hội, timeline), nhưng không nhảy trang bất kỳ được — chỉ đi tiếp/lùi tuần tự. Test cursor cần chú ý: cursor có mã hóa đúng không, cursor hết hạn xử lý ra sao, cursor giả mạo có bị từ chối không.

Keyset / seek-based là biến thể tối ưu hiệu năng, dùng chính giá trị cột đã sắp xếp làm mốc (?created_after=2026-06-01&limit=20). Thường xuất hiện ở API có lượng dữ liệu lớn.

Filtering — lọc dữ liệu

Filter có thể đơn giản (?category=laptop) hoặc phức tạp (?price_min=5000000&price_max=15000000&brand=asus,dell&in_stock=true). Điểm cần nắm khi test:

  • Logic kết hợp: nhiều filter thường là AND (thỏa tất cả). Nhưng nhiều giá trị trong một filter (brand=asus,dell) lại là OR. Phải xác minh đúng ngữ nghĩa này với dev/tài liệu.
  • Filter rỗng và không hợp lệ: ?category= (rỗng), ?price_min=abc (sai kiểu), ?status=deleted (giá trị không tồn tại) — API nên trả lỗi rõ ràng hoặc bỏ qua, chứ không được trả toàn bộ hoặc crash.
  • Kết hợp filter với pagination: total phải là tổng số bản ghi sau khi lọc, không phải tổng toàn bộ.

Sorting — sắp xếp

Thường có dạng ?sort=price&order=asc hoặc ?sort=-price (dấu trừ nghĩa là giảm dần). Điều cần test:

  • Đúng thứ tự: asc thì tăng dần, desc thì giảm dần — nghe hiển nhiên nhưng rất hay sai, đặc biệt với chuỗi tiếng Việt có dấu.
  • Sắp xếp ổn định (stable sort): khi hai bản ghi có cùng giá trị sort, thứ tự của chúng có nhất quán giữa các lần gọi không? Nếu không ổn định, pagination sẽ hỏng vì bản ghi có thể xuất hiện ở cả trang 1 lẫn trang 2.
  • Sort field không hợp lệ: ?sort=nonexistent_field nên bị từ chối, không được để lộ lỗi SQL.

Tình huống thực tế

Tình huống 1: Bản ghi lặp giữa hai trang ở sàn TMĐT "ChợViet"

ChợViet là một sàn thương mại điện tử giả định với khoảng 45.000 sản phẩm. Đội QA test API GET /products?page={n}&limit=50&sort=created_at&order=desc bằng cách gọi trang 1, thấy 50 sản phẩm, gọi trang 2, thấy 50 sản phẩm khác — và đóng ticket "PASS".

Ba tuần sau, khách hàng báo lỗi: cùng một chiếc tai nghe xuất hiện ở cả trang 3 và trang 4. Điều tra ra nguyên nhân: sản phẩm được sort theo created_at, nhưng có tới hàng trăm sản phẩm được import hàng loạt cùng một timestamp (do seed dữ liệu bằng script). Vì sort không ổn định — không có khóa phụ (tie-breaker) như id — database trả về thứ tự khác nhau mỗi lần query. Bản ghi ở cuối trang 3 lần gọi này lại nhảy lên đầu trang 4 lần gọi khác.

Bài học rút ra: Luôn test tính ổn định của sort bằng cách gọi cùng một endpoint nhiều lần và so sánh. Trong Postman, lưu danh sách ID của trang N vào environment variable, gọi lại lần 2, so sánh mảng ID phải giống hệt. Và luôn kiểm tra: các bản ghi ở cuối trang N có bị trùng với đầu trang N+1 không. Fix đúng là dev thêm tie-breaker ORDER BY created_at DESC, id DESC.

Tình huống 2: total nói dối ở ứng dụng giao đồ ăn "FoodNow"

FoodNow (giả định, kiểu như GrabFood) có màn hình danh sách nhà hàng với filter ?district=quan-1&cuisine=vietnamese&open_now=true. API trả về:

{ "data": [ ...12 nhà hàng... ], "total": 340, "page": 1, "total_pages": 17, "limit": 20 }

Tester tinh ý nhận ra điều bất thường: total: 340 nhưng khi lọc "đang mở cửa + món Việt + quận 1", làm gì có tới 340 nhà hàng thỏa. Đào sâu, hóa ra total trả về tổng số nhà hàng ở quận 1 trước khi áp filter cuisineopen_now. Frontend tính total_pages = 17, vẽ ra 17 nút phân trang, nhưng bấm sang trang 2 thì API trả về mảng rỗng vì thực tế chỉ có 12 kết quả.

Bài học rút ra: totaltotal_pages phải phản ánh kết quả sau khi áp toàn bộ filter. Đây là một trong những lỗi phổ biến nhất và dễ bỏ sót nhất. Cách test chắc chắn: đặt limit lớn (ví dụ 1000) để lấy hết kết quả trong một request, đếm số phần tử thực tế trong data, rồi so sánh với total. Nếu total = 340 mà đếm được 12, bug đã lộ. Ta sẽ viết assertion này ở phần hướng dẫn.

Tình huống 3: Sắp xếp tên tiếng Việt sai bảng chữ cái ở "SáchViet"

SáchViet là nhà sách online. API GET /books?sort=title&order=asc được kỳ vọng sắp xếp tên sách theo alphabet. Nhưng kết quả trả về: "Đắc Nhân Tâm" đứng sau "Zorba", còn "Ăn Gì Cho Khỏe" bị đẩy xuống dưới cùng.

Nguyên nhân: database dùng collation mặc định (utf8_general_ci kiểu byte-order) thay vì collation tiếng Việt. Với byte order, các ký tự có dấu như "Đ", "Ă", "Ê" có mã Unicode cao hơn nên bị xếp sau hết chữ Latin thường. Người dùng Việt Nam nhìn vào thấy sai ngay, vì trong tiếng Việt "Ă" phải đứng ngay sau "A", "Đ" đứng sau "D".

Bài học rút ra: Sorting trên dữ liệu tiếng Việt cần collation phù hợp (ví dụ utf8mb4_vietnamese_ci trên MySQL). Khi test, đừng chỉ kiểm tra "asc thì phần tử sau >= phần tử trước theo so sánh chuỗi JavaScript" — vì localeCompare mặc định của JS cũng khác với thứ tự người Việt mong đợi. Hãy chuẩn bị một bộ dữ liệu mẫu có dấu tiếng Việt và kiểm tra thứ tự kỳ vọng cụ thể, đồng thời báo dev xác nhận collation. (Bài 35 nói sâu về Unicode; ở đây ta chỉ chạm tới phần liên quan sorting.)

Hướng dẫn từng bước

Ta sẽ xây một bộ test Postman đủ mạnh cho ba tính năng. Giả sử endpoint là GET {{base_url}}/products.

Bước 1: Test cấu trúc phản hồi phân trang cơ bản

Request đầu tiên: GET {{base_url}}/products?page=1&limit=20. Tab Tests:

const res = pm.response.json();

pm.test("Status 200", () => pm.response.to.have.status(200));

pm.test("Có đủ trường phân trang", () => { pm.expect(res).to.have.property("data").that.is.an("array"); pm.expect(res).to.have.property("total").that.is.a("number"); pm.expect(res).to.have.property("page"); pm.expect(res).to.have.property("total_pages"); });

pm.test("Số item không vượt quá limit", () => { pm.expect(res.data.length).to.be.at.most(20); });

pm.test("total_pages tính đúng theo total và limit", () => { const expected = Math.ceil(res.total / 20); pm.expect(res.total_pages).to.eql(expected); });

Assertion cuối cùng bắt được rất nhiều lỗi làm tròn — ví dụ dev dùng Math.floor thay vì Math.ceil, khiến 41 sản phẩm với limit 20 lại báo 2 trang thay vì 3, làm mất 1 sản phẩm.

Bước 2: Kiểm tra tính nhất quán qua các trang (không trùng, không sót)

Đây là phần quan trọng nhất mà đa số bỏ qua. Ta dùng một request lấy trang 1, lưu ID vào biến, rồi request trang 2 và so sánh.

Ở trang 1, tab Tests lưu lại ID:

const ids1 = pm.response.json().data.map(x => x.id);
pm.environment.set("page1_ids", JSON.stringify(ids1));

Ở request trang 2 (?page=2&limit=20), tab Tests:

const ids2 = pm.response.json().data.map(x => x.id);
const ids1 = JSON.parse(pm.environment.get("page1_ids"));

pm.test("Không có ID trùng giữa trang 1 và trang 2", () => { const overlap = ids2.filter(id => ids1.includes(id)); pm.expect(overlap, ID bị lặp: ${overlap}).to.be.empty; });

pm.test("Không có ID nào trùng trong nội bộ trang 2", () => { pm.expect(new Set(ids2).size).to.eql(ids2.length); });

Bước 3: Xác minh total khớp với số bản ghi thực tế (chống lỗi "total nói dối")

Tạo một request với limit lớn để gom hết kết quả, dành riêng cho việc kiểm tra total:

const res = pm.response.json();
pm.test("total khớp số phần tử đếm được", () => {
    // chỉ đúng khi limit đủ lớn để lấy hết trong 1 request
    pm.expect(res.data.length).to.eql(res.total);
});

Với dữ liệu lớn không gom được trong một request, thay bằng vòng lặp cộng dồn qua các trang (dùng chained request — xem Bài 11) rồi so tổng với total.

Bước 4: Test filtering và giữ tính đúng của total

Request GET {{base_url}}/products?category=laptop&price_max=15000000:

const res = pm.response.json();

pm.test("Mọi item đều thỏa filter", () => { res.data.forEach(p => { pm.expect(p.category).to.eql("laptop"); pm.expect(p.price).to.be.at.most(15000000); }); });

pm.test("total phản ánh kết quả sau lọc", () => { // đối chiếu với số đếm khi limit lớn, không phải tổng catalog pm.expect(res.total).to.be.at.most(pm.environment.get("total_all_products")); });

Nhớ thêm ca filter không hợp lệ: ?price_min=abc nên trả 400, ?category= rỗng nên bỏ qua filter đó chứ không crash.

Bước 5: Test sorting đúng thứ tự và ổn định

Request ?sort=price&order=asc:

const prices = pm.response.json().data.map(x => x.price);

pm.test("Sắp xếp tăng dần theo giá", () => { for (let i = 1; i < prices.length; i++) { pm.expect(prices[i]).to.be.at.least(prices[i - 1]); } });

Với desc, đổi thành at.most. Để kiểm tra tính ổn định, gọi cùng request 2 lần và so sánh mảng ID phải giống hệt nhau.

Lỗi thường gặp & mẹo

Chỉ test trang 1. Bug về pagination gần như luôn xuất hiện ở biên: trang cuối, trang vượt quá (?page=9999), limit=0, limit âm, limit cực lớn. Luôn test: trang đầu, trang giữa, trang cuối chính xác, trang cuối + 1 (phải trả mảng rỗng chứ không lỗi), và page=0 hoặc page=-1.

Bỏ quên off-by-one. Đây là lỗi kinh điển. Với 100 bản ghi, limit 10, trang 10 là trang cuối (bản ghi 91–100). Trang 11 phải rỗng. Nhưng nhiều API tính offset sai một bậc, khiến trang cuối thiếu 1 bản ghi hoặc lặp lại bản ghi đầu. Hãy đích thân đếm: tổng bản ghi thu được qua tất cả các trang phải đúng bằng total.

Nhầm ngữ nghĩa AND/OR của filter. Trước khi viết assertion, xác nhận rõ với dev: ?brand=asus,dell là "asus HOẶC dell" (đúng), còn ?brand=asus&color=red là "asus VÀ đỏ". Viết sai assertion sẽ ra false negative làm phiền cả team.

Tin vào localeCompare cho tiếng Việt. Như tình huống SáchViet, so sánh chuỗi mặc định không phản ánh đúng thứ tự tiếng Việt. Với dữ liệu có dấu, dùng bộ dữ liệu mẫu có thứ tự kỳ vọng cố định và assert theo danh sách đó, đồng thời yêu cầu dev xác nhận collation database.

Không kiểm tra tương tác giữa ba tính năng. Lỗi hay ẩn ở giao điểm: filter + sort + paginate cùng lúc. Ví dụ ?category=laptop&sort=price&order=asc&page=2. Hãy chắc chắn trang 2 vẫn đúng filter, vẫn đúng thứ tự nối tiếp trang 1 (giá đầu trang 2 >= giá cuối trang 1), và total vẫn là tổng đã lọc.

Mẹo dùng biến để so sánh nối tiếp giữa các trang. Lưu giá trị sort của phần tử cuối trang N vào environment (pm.environment.set("last_price", ...)), rồi ở trang N+1 assert phần tử đầu tiên >= last_price. Đây là cách bắt lỗi thứ tự bị đứt gãy giữa các trang cực kỳ hiệu quả.

Mẹo an toàn: chặn injection qua sort field. Thử ?sort=id;DROP TABLE hoặc ?sort=(SELECT...). API tốt phải trả 400 với danh sách field cho phép, không được để lộ lỗi SQL — đó vừa là bug chức năng vừa là lỗ hổng bảo mật.

Bài tập thực hành

Dùng một API công khai có phân trang (ví dụ https://dummyjson.com/products hỗ trợ limit, skip, và có total), hãy xây một collection Postman gồm:

  • Request cơ bản: ?limit=10&skip=0. Viết test kiểm tra status 200, có products/total/skip/limit, và products.length <= 10.
  • Test không trùng lặp: gọi skip=0 rồi skip=10, lưu ID trang đầu vào environment, assert không có ID nào lặp giữa hai trang.
  • Test biên: gọi với skip lớn hơn total (ví dụ skip=99999) — assert trả về mảng rỗng, không lỗi. Thử limit=0 và ghi lại hành vi thực tế.
  • Test total nhất quán: gọi với limit lớn (?limit=200), assert số phần tử đếm được khớp total (hoặc khớp min(total, 200)).
  • Tự lọc phía client (nếu API không hỗ trợ filter): lấy toàn bộ, dùng script Postman lọc price < 50, đếm và so với kỳ vọng — để luyện tư duy assertion về filtering.
Nâng cao: dùng endpoint sort của dummyjson (?sortBy=price&order=asc) và viết test kiểm tra thứ tự tăng dần, sau đó gọi 3 lần liên tiếp và so sánh mảng ID để kiểm tra tính ổn định. Ghi lại kết quả vào phần mô tả collection như một mini test report.

Tóm tắt

Pagination, filtering và sorting là ba tính năng tưởng đơn giản nhưng ẩn chứa nhiều lỗi tinh vi mà chỉ lộ ra ở biên và ở giao điểm giữa chúng. Điều cốt lõi cần nhớ:

  • Pagination: hiểu rõ kiểu (offset/cursor/keyset), test đủ các biên (trang cuối, trang vượt quá, limit=0), và luôn kiểm tra không trùng — không sót bằng cách so sánh ID giữa các trang. Đừng bao giờ chỉ test trang 1.
  • Filtering: xác nhận ngữ nghĩa AND/OR, kiểm tra mọi item thỏa điều kiện, xử lý filter rỗng/sai kiểu, và đảm bảo total phản ánh kết quả sau khi lọc.
  • Sorting: kiểm tra đúng chiều asc/desc, kiểm tra tính ổn định (đặc biệt khi có giá trị trùng), và cẩn trọng với thứ tự tiếng Việt cùng lỗ hổng injection qua sort field.
Ba tình huống ChợViet, FoodNow và SáchViet cho thấy các lỗi này rất thật và tốn kém khi lọt ra production. Bộ assertion trong Postman ta xây hôm nay — đối chiếu total, kiểm tra trùng lặp, xác minh thứ tự nối tiếp giữa các trang — chính là tấm lưới bắt những con bug đó trước khi khách hàng gặp phải. Hãy biến các test này thành một phần chuẩn trong mọi collection của bạn.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi