Product Management
Đăng nhập
ESC

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

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

Test file upload & download

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

Trong sự nghiệp làm API Tester, có một loại endpoint mà gần như dự án nào cũng có nhưng lại rất hay bị test hời hợt: đó là các API upload và download file. Khi bạn kiểm thử một API trả về JSON, mọi thứ khá "sạch sẽ" — bạn gửi text, nhận text, so sánh field. Nhưng khi endpoint phải nhận về một file ảnh 4MB, một hợp đồng PDF, hay xuất một báo cáo Excel hàng nghìn dòng, thì bản chất giao tiếp thay đổi hoàn toàn: bạn đang làm việc với binary data (dữ liệu nhị phân), với multipart/form-data, với header Content-Disposition, và với những cái bẫy về encoding mà một request JSON thông thường không bao giờ chạm tới.

Tôi đã chứng kiến không ít đội QA ở Việt Nam bỏ sót toàn bộ lớp test này, chỉ vì Postman "trông có vẻ" không thân thiện với file. Rồi đến khi lên production, người dùng upload ảnh chụp CMND bằng điện thoại (định dạng HEIC), hoặc kế toán tải file có tên tiếng Việt "Báo_cáo_Quý_1.xlsx" và nhận về file tên loạn ký tự — lúc đó mới cuống. Bài học hôm nay sẽ giúp bạn làm chủ việc test cả hai chiều: đẩy file lên (upload) và kéo file về (download), bằng Postman, một cách bài bản và có thể lặp lại được. Đây là kỹ năng rất "hữu hình", nhà tuyển dụng SDET đánh giá cao vì nó cho thấy bạn hiểu tầng HTTP thật sự, chứ không chỉ bấm nút Send.

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

Upload: multipart/form-data là gì

Khi bạn upload file qua HTTP, dữ liệu không đi trong một JSON body gọn gàng. Thay vào đó, trình duyệt (và Postman) đóng gói request theo chuẩn multipart/form-data. Hãy hình dung body được chia thành nhiều "phần" (part), mỗi phần ngăn cách bởi một chuỗi boundary ngẫu nhiên. Một phần có thể là text (ví dụ title=Avatar mới), phần khác là nội dung nhị phân của file kèm metadata như tên file và Content-Type riêng của nó.

Điểm mấu chốt cần nhớ: khi dùng form-data với một key kiểu File, bạn KHÔNG được tự set header Content-Type cho cả request. Postman phải tự sinh header này kèm chuỗi boundary, ví dụ multipart/form-data; boundary=----WebKitFormBoundary7MA4.... Nếu bạn tự tay ghi đè thành multipart/form-data trống boundary, server sẽ không tách được các part và trả về lỗi 400. Đây là lỗi phổ biến số một khi mới test upload.

Ba cách gửi payload nhị phân trong Postman

Postman cho bạn ba lựa chọn ở tab Body, tùy API thiết kế thế nào:

  • form-data: dùng khi API nhận multipart. Bạn thêm key (ví dụ file), đổi Type của dòng đó từ Text sang File, rồi chọn file từ máy. Có thể kèm nhiều field text khác trong cùng request. Đây là kiểu phổ biến nhất.
  • binary: dùng khi API nhận thẳng nội dung file làm toàn bộ body (raw bytes), không bọc multipart. Thường gặp ở các API kiểu PUT /files/{id} hoặc upload lên S3 presigned URL. Lúc này bạn tự đặt header Content-Type: image/png cho đúng loại file.
  • raw + base64: một số API "cũ kỹ" yêu cầu bạn nhúng file dưới dạng chuỗi base64 trong JSON. Cách này tốn băng thông (base64 làm phình dữ liệu ~33%) nhưng đôi khi bắt buộc.

Download: đọc header và lưu file về

Ở chiều ngược lại, khi test download, thân response chính là bytes của file. Bạn cần quan tâm ba thứ:

  • Content-Type của response cho biết loại file (application/pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet cho .xlsx…).
  • Content-Disposition thường có dạng attachment; filename="report.pdf" — quyết định tên file khi lưu và báo cho client biết đây là file tải về chứ không phải hiển thị inline.
  • Content-Length cho biết kích thước, hữu ích để kiểm tra file không rỗng hoặc không bị cắt cụt.
Trong Postman, sau khi Send một request download, bạn có nút Save Response → Save to a file để lưu file về đĩa và kiểm tra thủ công. Với automation, ta sẽ assert trên header và độ dài body.

Tình huống thực tế

Ví dụ 1 — Tiki: upload ảnh sản phẩm cho seller

Giả sử bạn là QA cho luồng Seller Center của một sàn TMĐT như Tiki. Seller upload ảnh sản phẩm qua POST /api/v2/seller/products/{id}/images. Yêu cầu nghiệp vụ: chỉ chấp nhận JPG/PNG/WebP, tối đa 5MB, và mỗi ảnh phải có chiều rộng tối thiểu 500px.

Khi test, bạn không chỉ upload một ảnh hợp lệ rồi kết luận "OK". Bạn dựng cả một ma trận: ảnh 4.9MB (biên trên hợp lệ), ảnh 5.1MB (phải bị từ chối 413 Payload Too Large hoặc 400), file .exe đổi đuôi thành .jpg (server phải kiểm magic bytes, không tin đuôi file), và file rỗng 0 byte. Trong một lần chạy như vậy tại một dự án tương tự, đội tôi phát hiện server chỉ kiểm tra phần mở rộng tên file mà không kiểm magic bytes — nghĩa là hacker có thể upload script PHP đổi tên thành .png. Đây là lỗ hổng bảo mật nghiêm trọng, và nó chỉ lộ ra vì ta cố tình gửi file "bẩn".

Bài học rút ra: test upload không phải để chứng minh "upload chạy được", mà để chứng minh server từ chối đúng những thứ cần từ chối. Giá trị nằm ở các case âm.

Ví dụ 2 — Công ty bảo hiểm: download hợp đồng PDF tên tiếng Việt

Một khách hàng của tôi là công ty bảo hiểm nhân thọ. Sau khi khách ký hợp đồng số, hệ thống cho tải bản PDF qua GET /api/contracts/{id}/document. Ban đầu mọi thứ có vẻ ổn cho tới khi bộ phận CSKH báo: khách tải file về nhưng tên file hiển thị thành Ha_p_a_a_ng_2026.pdf thay vì Hợp_đồng_2026.pdf.

Nguyên nhân nằm ở header Content-Disposition. Theo chuẩn, tên file có ký tự non-ASCII (tiếng Việt có dấu) phải được mã hóa theo RFC 5987, dạng filename=UTF-8''H%E1%BB%A3p_%C4%91%E1%BB%93ng_2026.pdf, chứ không thể để trần trong filename="...". Backend đã set sai, và chỉ khi QA viết một test assert cụ thể trên chuỗi Content-Disposition mới bắt được. Trong Postman, ta kiểm tra header này và verify nó chứa cả biến thể filename mã hóa UTF-8.

Bài học rút ra: với người dùng Việt Nam, tên file có dấu là một test case bắt buộc, không phải "nice to have". Header Content-Disposition là nơi encoding hay vỡ nhất.

Ví dụ 3 — Startup logistics: xuất báo cáo Excel lớn

Một startup logistics ở TP.HCM có endpoint GET /api/reports/shipments/export?month=2026-06 xuất file Excel toàn bộ đơn hàng trong tháng. Với tháng cao điểm, file lên tới vài chục nghìn dòng. QA ban đầu chỉ assert status === 200, thấy pass là yên tâm. Nhưng thực tế có bug: khi dữ liệu lớn, server bị timeout ở tầng proxy và trả về một file HTML báo lỗi "504 Gateway Timeout" nhưng vẫn kèm status 200 do cấu hình sai.

Chỉ khi bổ sung assertion kiểm tra Content-Type phải đúng là kiểu Excel (không phải text/html) và Content-Length phải lớn hơn một ngưỡng tối thiểu (ví dụ > 10KB), test mới bắt được ca lỗi này. File Excel rỗng hoặc file HTML lỗi đều "trông giống 200 OK" nếu bạn không nhìn kỹ.

Bài học rút ra: với download, status 200 gần như vô nghĩa nếu đứng một mình. Luôn assert loại và kích thước nội dung.

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

Phần A — Test upload một file

  • Tạo request mới, chọn method POST, điền URL endpoint upload, ví dụ {{baseUrl}}/api/v2/seller/products/123/images.
  • Sang tab Body, chọn form-data.
  • Ở cột Key, gõ tên field mà API mong đợi — thường là file (hỏi backend hoặc đọc docs, có nơi đặt là image, upload, attachment).
  • Hover vào ô Key, ở cột bên phải có dropdown mặc định là Text — đổi sang File. Ô Value sẽ biến thành nút chọn file. Bấm Select Files và chọn file từ máy.
  • Nếu API cần thêm metadata (ví dụ alt_text, position), thêm các dòng key khác giữ Type là Text.
  • Sang tab Headers, kiểm tra rằng bạn không có dòng Content-Type tự thêm. Postman sẽ tự sinh header multipart kèm boundary khi Send.
  • Bấm Send, quan sát response.
Viết assertion trong tab Tests:

pm.test("Upload thành công, trả về 201", function () {
    pm.response.to.have.status(201);
});

pm.test("Response chứa URL ảnh đã lưu", function () { const body = pm.response.json(); pm.expect(body).to.have.property("image_url"); pm.expect(body.image_url).to.match(/^https?:\/\/.+\.(jpg|jpeg|png|webp)$/i); });

// Lưu id ảnh để bài test download dùng lại pm.test("Lưu image_id vào biến", function () { const body = pm.response.json(); pm.collectionVariables.set("uploaded_image_id", body.id); });

Phần B — Dùng file trong Postman Collection (file ngoài data)

Một điểm hay bị vướng: khi bạn share collection cho đồng đội hoặc chạy bằng Newman, file chọn thủ công không đi kèm collection. Postman lưu đường dẫn tuyệt đối trên máy bạn. Để chạy được bằng Newman, bạn phải truyền file qua tham số --working-dir và đảm bảo file nằm trong thư mục đó, hoặc dùng cấu hình working directory trong Postman (Settings → General → Working directory) rồi commit file cùng repo. Đây là mẹo để test upload có thể chạy trong CI.

Phần C — Test download một file

  • Tạo request GET, URL trỏ tới endpoint download, ví dụ {{baseUrl}}/api/contracts/{{contract_id}}/document.
  • Bấm Send. Postman hiển thị response nhị phân (với PDF/ảnh có thể xem preview).
  • Viết assertion kiểm tra header và nội dung:
pm.test("Trả về 200", function () {
    pm.response.to.have.status(200);
});

pm.test("Content-Type là PDF", function () { pm.expect(pm.response.headers.get("Content-Type")).to.include("application/pdf"); });

pm.test("File không rỗng và đủ lớn", function () { const bytes = pm.response.responseSize; // kích thước body tính bằng byte pm.expect(bytes).to.be.above(1024); // lớn hơn 1KB });

pm.test("Content-Disposition là attachment với tên file", function () { const cd = pm.response.headers.get("Content-Disposition"); pm.expect(cd).to.include("attachment"); pm.expect(cd).to.include("filename"); });

  • Với file tên tiếng Việt, bổ sung assert kiểm tra mã hóa RFC 5987:
pm.test("Tên file tiếng Việt được mã hóa UTF-8 đúng chuẩn", function () {
    const cd = pm.response.headers.get("Content-Disposition");
    pm.expect(cd).to.match(/filename\\s=\s*UTF-8''/i);
});
  • Để lưu file kiểm tra thủ công: bấm mũi tên cạnh nút Save ở khu vực response → Save response to file, chọn nơi lưu, mở file bằng ứng dụng tương ứng để xác nhận nội dung mở được, không lỗi.

Phần D — Verify checksum để chắc chắn file không đổi

Với những file cần đảm bảo toàn vẹn (ví dụ tải chứng chỉ, tải lại đúng file đã upload), bạn có thể so sánh mã băm. Postman có sẵn thư viện CryptoJS:

pm.test("Checksum khớp với bản gốc mong đợi", function () {
    const CryptoJS = require("crypto-js");
    // stream response về dạng WordArray để băm
    const hash = CryptoJS.MD5(pm.response.stream.toString(CryptoJS.enc.Latin1)).toString();
    pm.expect(hash).to.eql(pm.environment.get("expected_md5"));
});

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

Tự set Content-Type cho request form-data. Đây là lỗi kinh điển. Khi bạn thấy server trả 400 "boundary not found" hay "Required part 'file' is not present", việc đầu tiên hãy vào tab Headers, xóa mọi dòng Content-Type bạn từng thêm bằng tay. Để Postman tự lo.

Nhầm giữa form-data và binary. Nếu API dùng multipart mà bạn chọn Body kiểu binary, hoặc ngược lại, server sẽ không parse được. Đọc kỹ docs: có field text đi kèm file thì gần như chắc chắn là form-data; upload thẳng vào một URL (như presigned S3) thường là binary.

Chỉ assert status 200 khi download. Như ví dụ Excel ở trên, status 200 có thể che giấu file rỗng hoặc trang HTML lỗi. Luôn kèm assert Content-Type và kích thước.

Quên test file quá lớn và file rỗng (0 byte). Đây là hai biên hay vỡ nhất. Server thường quên xử lý file 0 byte, dẫn tới crash hoặc lưu record rác.

Tin vào phần mở rộng tên file. Server an toàn phải kiểm magic bytes (vài byte đầu file). Là tester, hãy cố tình đổi đuôi file để kiểm tra: đổi virus.exe thành photo.png rồi upload — nếu server chấp nhận, đó là finding bảo mật đáng báo cáo.

Bỏ qua Unicode trong tên file. Với người dùng Việt, luôn có case tên file có dấu và có khoảng trắng. Test cả upload (tên file gửi lên) lẫn download (tên file trả về trong Content-Disposition).

Mẹo chạy được trong Newman/CI: đặt file test vào thư mục working directory và commit chung với repo; truyền newman run collection.json --working-dir ./test-files. Nếu không, upload sẽ fail vì Newman không tìm thấy file theo đường dẫn tuyệt đối của máy bạn.

Mẹo kiểm tra nhanh loại file thật: dùng pm.response.stream để đọc vài byte đầu và so với magic number (PDF bắt đầu %PDF, PNG bắt đầu \x89PNG, JPG bắt đầu \xFF\xD8).

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

  • Upload cơ bản: Với một API demo (ví dụ https://httpbin.org/post chấp nhận mọi upload), tạo request form-data upload một ảnh JPG. Viết test verify status 200 và verify response phản chiếu tên file bạn gửi.
  • Ma trận case âm: Chuẩn bị 4 file: một JPG hợp lệ, một file > 5MB, một file 0 byte, một file .txt đổi đuôi thành .jpg. Ghi lại hành vi thực tế của server với từng file. Nếu bạn không có server thật, hãy viết bảng mô tả kết quả mong đợi cho mỗi case.
  • Download có kiểm chứng: Tạo request tải một PDF công khai bất kỳ. Viết bộ 4 assertion: status, Content-Type là PDF, kích thước > 1KB, Content-Disposition chứa tên file. Sau đó Save response to file và mở file để xác nhận nó không hỏng.
  • Case tiếng Việt: Giả lập (hoặc mô tả) một endpoint trả file tên "Báo_cáo_Tháng_6.pdf". Viết assertion kiểm tra header Content-Disposition có dạng mã hóa filename*=UTF-8''. Giải thích tại sao dạng filename="Báo_cáo..." trần lại nguy hiểm.
  • Round-trip: Kết hợp một request upload rồi một request download đúng file vừa upload (dùng biến collection để truyền id). So sánh checksum MD5 hai đầu để chứng minh file không bị biến đổi.

Tóm tắt

Test file upload/download là mảng "hữu hình" nhất trong nghề API Tester, và cũng là nơi nhiều lỗi thật ẩn nấp. Với upload, hãy dùng Body kiểu form-data, đổi Type dòng key sang File, và tuyệt đối không tự set header Content-Type để Postman tự sinh boundary; giá trị test lớn nhất nằm ở các case âm — file quá lớn, file rỗng, file giả đuôi, tên file tiếng Việt. Với download, đừng bao giờ dừng ở status 200: luôn assert Content-Type, kích thước body, và Content-Disposition (đặc biệt kiểm mã hóa UTF-8 RFC 5987 cho tên file có dấu). Ba tình huống thực tế — ảnh sản phẩm Tiki, hợp đồng PDF bảo hiểm, báo cáo Excel logistics — đều cho thấy cùng một bài học: bug không nằm ở đường "hạnh phúc", mà nằm ở biên và ở tầng nhị phân mà request JSON không bao giờ chạm tới. Khi chạy trong CI với Newman, nhớ đóng gói file test vào working directory để pipeline không gãy. Làm chủ được lớp test này, bạn đã nâng mình từ người "bấm Send" lên người thực sự hiểu HTTP.

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