09 thg 8, 2025·7 phút đọc

Kiểm thử các handler REST trong Go: httptest và kiểm tra theo bảng

Kiểm thử handler REST trong Go bằng httptest và các case theo bảng giúp bạn kiểm tra xác thực, validation, mã trạng thái và các edge case một cách lặp lại trước khi phát hành.

Kiểm thử các handler REST trong Go: httptest và kiểm tra theo bảng

Những thứ bạn nên tự tin trước khi phát hành

Một handler REST có thể biên dịch, qua kiểm tra thủ công nhanh, nhưng vẫn hỏng khi chạy ở production. Phần lớn lỗi không phải do cú pháp. Chúng là vấn đề hợp đồng: handler chấp nhận thứ phải bị từ chối, trả về mã trạng thái sai, hoặc tiết lộ chi tiết trong lỗi.

Kiểm thử thủ công hữu ích, nhưng dễ bỏ sót các edge case và hồi quy. Bạn thử đường thành công, có lẽ một lỗi hiển nhiên, rồi xong. Rồi một thay đổi nhỏ ở validation hoặc middleware lặng lẽ phá vỡ hành vi mà bạn nghĩ là ổn định.

Mục tiêu của kiểm thử handler đơn giản: làm cho các cam kết của handler có thể lặp lại được. Đó bao gồm quy tắc xác thực, xác thực đầu vào, mã trạng thái dự đoán được, và kiểu lỗi mà client có thể phụ thuộc.

Gói httptest của Go rất phù hợp vì bạn có thể gọi handler trực tiếp mà không cần khởi động server thật. Bạn dựng một HTTP request, đưa vào handler, rồi kiểm tra body, header và mã trạng thái của response. Test nhanh, cô lập và dễ chạy trên mỗi commit.

Trước khi phát hành, bạn nên biết (không phải hy vọng) rằng:

  • Hành vi auth nhất quán cho token thiếu, token không hợp lệ và vai trò sai.
  • Đầu vào được validate: trường bắt buộc, kiểu, phạm vi, và (nếu áp dụng) trường lạ.
  • Mã trạng thái khớp hợp đồng (ví dụ 401 vs 403, 400 vs 422).
  • Phần phản hồi lỗi an toàn và nhất quán (không có stack trace, cùng dạng JSON mỗi lần).
  • Các trường hợp không thành công được xử lý: timeout, lỗi downstream, và kết quả rỗng.

Một endpoint “Tạo ticket” có thể hoạt động khi bạn gửi JSON hoàn hảo với quyền admin. Test sẽ bắt được những gì bạn quên thử: token hết hạn, một trường thừa client vô tình gửi, priority âm, hay khác biệt giữa “không tìm thấy” và “lỗi nội bộ” khi một dependency thất bại.

Định nghĩa hợp đồng cho mỗi endpoint

Viết rõ handler hứa làm gì trước khi viết test. Một hợp đồng rõ ràng giữ cho test tập trung và ngăn chúng biến thành suy đoán về điều code “muốn” làm. Nó cũng giúp refactor an toàn hơn vì bạn có thể thay đổi nội bộ mà không đổi hành vi bên ngoài.

Bắt đầu với đầu vào. Nêu rõ mỗi giá trị lấy từ đâu và điều gì là bắt buộc. Endpoint có thể lấy id từ path, limit từ query, header Authorization, và một body JSON. Ghi rõ định dạng được chấp nhận, min/max, trường bắt buộc, và chuyện gì xảy ra khi thiếu.

Rồi định nghĩa đầu ra. Đừng dừng lại ở “trả về JSON.” Quyết định thành công trông như thế nào, header nào quan trọng, và lỗi trông ra sao. Nếu client phụ thuộc vào mã lỗi ổn định và dạng JSON dự đoán được, coi đó là một phần hợp đồng.

Một checklist thực tế:

  • Đầu vào: giá trị path/query, header bắt buộc, trường JSON, và quy tắc validation
  • Đầu ra: mã trạng thái, response header, dạng JSON cho thành công và lỗi
  • Tác dụng phụ: dữ liệu thay đổi gì và cái gì được tạo
  • Phụ thuộc: gọi database, dịch vụ ngoài, thời gian hiện tại, ID sinh ra

Cũng hãy quyết định điểm dừng của kiểm thử handler. Handler tests mạnh nhất ở ranh giới HTTP: auth, parsing, validation, mã trạng thái, và body lỗi. Đẩy các mối quan tâm sâu hơn sang integration tests: truy vấn database thật, gọi mạng, và routing đầy đủ.

Nếu backend của bạn được sinh tự động (ví dụ AppMaster tạo handler Go và business logic), cách tiếp cận contract-first càng hữu ích. Bạn có thể regen code và vẫn kiểm chứng rằng mỗi endpoint giữ cùng hành vi công khai.

Thiết lập một harness httptest tối thiểu

Một test handler tốt nên cảm giác như gửi request thật, mà không cần khởi động server. Trong Go, điều này thường có nghĩa: tạo request với httptest.NewRequest, ghi nhận response với httptest.NewRecorder, và gọi handler.

Gọi handler trực tiếp cho test nhanh và tập trung. Điều này lý tưởng khi bạn muốn validate hành vi bên trong handler: kiểm tra auth, validation, mã trạng thái và body lỗi. Dùng router trong test hữu ích khi hợp đồng phụ thuộc vào path params, route matching, hoặc thứ tự middleware. Bắt đầu với gọi trực tiếp và chỉ thêm router khi cần.

Header quan trọng hơn nhiều người nghĩ. Thiếu Content-Type có thể làm handler đọc body khác đi. Đặt header mong đợi cho mọi case để lỗi rõ ràng là logic, không phải setup test.

Dưới đây là pattern tối thiểu bạn có thể tái sử dụng:

req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()

Để giữ các assert nhất quán, hữu ích khi có một helper nhỏ để đọc và decode response body. Trong hầu hết test, kiểm tra mã trạng thái trước (để lỗi dễ quét), rồi kiểm tra header bạn hứa (thường là Content-Type), rồi body.

Nếu backend của bạn được sinh (bao gồm backend Go do AppMaster tạo), harness này vẫn áp dụng. Bạn đang test hợp đồng HTTP mà người dùng phụ thuộc, không phải phong cách mã phía sau.

Thiết kế các case theo bảng sao cho dễ đọc

Test theo bảng hoạt động tốt nhất khi mỗi case đọc như một câu chuyện nhỏ: request bạn gửi và điều bạn mong nhận lại. Bạn nên có thể quét bảng và hiểu coverage mà không phải nhảy lung tung trong file.

Một case tốt thường có: tên rõ ràng, request (method, path, header, body), mã trạng thái mong muốn, và kiểm tra response. Với body JSON, ưu tiên assert vài trường ổn định (như mã lỗi) thay vì so khớp toàn bộ JSON string, trừ khi hợp đồng yêu cầu chặt.

Một dạng case đơn giản để tái sử dụng

Giữ struct case tập trung. Đặt phần setup riêng biệt trong helper để bảng gọn.

type tc struct {
	name       string
	method     string
	path       string
	headers    map[string]string
	body       string
	wantStatus int
	wantBody   string // substring or compact JSON
}

Với các input khác nhau, dùng các chuỗi body nhỏ để thấy khác biệt ngay: payload hợp lệ, thiếu trường, sai kiểu, và chuỗi rỗng. Tránh viết JSON nhiều dòng trong bảng vì nhanh chóng trở nên lộn xộn.

Khi thấy setup lặp (tạo token, header chung, body mặc định), đẩy vào helper như newRequest(tc) hoặc baseHeaders().

Nếu một bảng bắt đầu trộn quá nhiều ý tưởng, tách nó. Một bảng cho đường thành công và bảng khác cho lỗi thường dễ đọc và debug hơn.

Kiểm tra auth: các case thường bị bỏ qua

Turn contracts into code
Build Go backends with visual logic and keep endpoints consistent as requirements change.
Try AppMaster

Test auth thường ổn với đường thành công, rồi hỏng production vì một case “nhỏ” không được thử. Xem auth như hợp đồng: client gửi gì, server trả gì, và điều gì không bao giờ được tiết lộ.

Bắt đầu với token tồn tại và tính hợp lệ. Endpoint bảo vệ nên hành xử khác khi header thiếu so với khi header tồn tại nhưng sai. Nếu dùng token ngắn hạn, test expiry nữa, thậm chí bằng cách inject validator trả “expired.”

Các khoảng trống thường bị che phủ bởi những case sau:

  • Không có header Authorization -> 401 với response lỗi ổn định
  • Header sai định dạng (prefix sai) -> 401
  • Token không hợp lệ (chữ ký sai) -> 401
  • Token hết hạn -> 401 (hoặc mã bạn chọn) với message dự đoán được
  • Token hợp lệ nhưng vai trò/quyền sai -> 403

Phân biệt 401 vs 403 quan trọng. Dùng 401 khi caller chưa xác thực. Dùng 403 khi đã xác thực nhưng không được phép. Nếu mờ nhạt, client sẽ retry vô ích hoặc hiển thị UI sai.

Kiểm tra vai trò thôi chưa đủ với endpoint “một người dùng sở hữu” (ví dụ GET /orders/{id}). Test ownership: user A không nên thấy order của user B ngay cả khi token hợp lệ. Đó nên trả 403 (hoặc 404 nếu bạn cố ý che giấu sự tồn tại), và body không nên lộ chi tiết. Giữ lỗi chung chung. Đừng gợi ý “order thuộc user 42.”

Quy tắc đầu vào: validate, reject và giải thích rõ ràng

Nhiều bug trước phát hành là lỗi đầu vào: thiếu trường, sai kiểu, định dạng không mong muốn, hoặc payload quá lớn.

Ghi tên mọi input handler chấp nhận: trường body JSON, query params, và path params. Với mỗi trường, quyết định điều gì xảy ra khi thiếu, rỗng, sai định dạng, hoặc vượt phạm vi. Rồi viết case chứng minh handler từ chối đầu vào xấu sớm và trả cùng loại lỗi mỗi lần.

Một tập validation nhỏ thường bao phủ rủi ro chính:

  • Trường bắt buộc: thiếu vs chuỗi rỗng vs null (nếu cho phép null)
  • Kiểu và định dạng: số vs chuỗi, email/ngày/UUID, parsing boolean
  • Giới hạn kích thước: độ dài tối đa, số lượng tối đa, payload quá lớn
  • Trường lạ: bỏ qua vs từ chối (nếu decode nghiêm ngặt)
  • Query và path params: thiếu, không parse được, và hành vi mặc định

Ví dụ: handler POST /users nhận { "email": "...", "age": 0 }. Test email thiếu, email123, email là "not-an-email", age-1, và age là "20". Nếu yêu cầu strict JSON, test thêm { "email":"[email protected]", "extra":"x" } và xác nhận nó thất bại.

Làm cho lỗi validation dự đoán được. Chọn một mã trạng thái cho lỗi validation (nhóm dùng 400, nhóm khác 422) và giữ dạng body lỗi nhất quán. Test assert cả mã trạng thái và một message (hoặc trường details) chỉ ra input bị lỗi.

Mã trạng thái và body lỗi: làm cho chúng dự đoán được

Connect common integrations
Add Stripe payments, messaging, or OpenAI integrations and keep API behavior consistent.
Explore Integrations

Test handler dễ hơn khi lỗi API nhàm chán và nhất quán. Bạn muốn mỗi lỗi ánh xạ tới một mã trạng thái rõ ràng và trả cùng dạng JSON, bất kể ai viết handler.

Bắt đầu với một ánh xạ nhỏ, đồng thuận từ loại lỗi tới HTTP status:

  • 400 Bad Request: JSON sai cú pháp, thiếu query param bắt buộc
  • 404 Not Found: resource ID không tồn tại
  • 409 Conflict: ràng buộc duy nhất hoặc xung đột trạng thái
  • 422 Unprocessable Entity: JSON hợp lệ nhưng vi phạm business rule
  • 500 Internal Server Error: lỗi bất ngờ (db down, nil pointer, outage bên thứ ba)

Rồi giữ body lỗi ổn định. Dù message thay đổi sau này, client vẫn có trường ổn định để dựa vào:

{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }

Trong test, assert cấu trúc, không chỉ status. Lỗi phổ biến là trả về HTML, plain text, hoặc body rỗng khi lỗi, điều này phá client và che lỗi.

Cũng test header và encoding cho response lỗi:

  • Content-Typeapplication/json (và charset nhất quán nếu bạn gán)
  • Body là JSON hợp lệ ngay cả khi lỗi
  • code, messagedetails tồn tại (details có thể rỗng nhưng không nên ngẫu nhiên)
  • Panic và lỗi bất ngờ trả về 500 an toàn mà không lộ stack trace

Nếu bạn dùng middleware recover, thêm một test ép panic và xác nhận vẫn nhận được JSON lỗi sạch sẽ.

Edge cases: thất bại, thời gian và đường không thành công

Add auth without guesswork
Use built-in authentication modules and verify 401 vs 403 behavior with stable contracts.
Add Auth

Test đường thành công chứng minh handler hoạt động một lần. Test edge-case chứng minh nó tiếp tục hoạt động khi thế giới hỗn độn.

Ép các dependency thất bại theo cách cụ thể, có thể lặp lại. Nếu handler gọi database, cache, hoặc API ngoài, bạn muốn xem chuyện gì xảy ra khi những lớp đó trả lỗi ngoài kiểm soát.

Những thứ sau đây đáng mô phỏng ít nhất một lần cho mỗi endpoint:

  • Timeout từ một cuộc gọi downstream (context deadline exceeded)
  • Not found từ storage khi client mong có dữ liệu
  • Vi phạm ràng buộc duy nhất khi tạo (email trùng, slug trùng)
  • Lỗi mạng hoặc transport (connection refused, broken pipe)
  • Lỗi nội bộ bất ngờ ("đã xảy ra lỗi")

Giữ test ổn định bằng cách kiểm soát mọi thứ có thể thay đổi giữa các lần chạy. Test flaky còn tệ hơn không có test vì khiến người ta bỏ qua failure.

Làm cho thời gian và randomness có thể dự đoán

Nếu handler dùng time.Now(), ID, hoặc giá trị ngẫu nhiên, inject chúng. Truyền một hàm clock và generator ID vào handler hoặc service. Trong test, trả về giá trị cố định để bạn có thể assert chính xác các trường JSON và header.

Dùng fake nhỏ, và assert "không có side effect"

Ưu tiên fake nhỏ hoặc stub hơn là mock đồ sộ. Một fake có thể ghi lại các cuộc gọi và cho phép bạn assert rằng không có gì xảy ra sau khi thất bại.

Ví dụ, trong handler “tạo user”, nếu insert vào DB thất bại vì unique constraint, assert mã trạng thái đúng, body lỗi ổn định, và không gửi email chào mừng. Fake mailer có thể lộ bộ đếm (sent=0) để dòng lỗi chứng minh nó không kích hoạt side effect.

Những sai lầm phổ biến khiến test handler không đáng tin

Test handler thường hỏng vì lý do sai. Request bạn dựng trong test không giống request client thật. Điều đó dẫn đến lỗi ồn ào và tin tưởng sai.

Một vấn đề thường gặp là gửi JSON mà thiếu header handler mong đợi. Nếu code kiểm tra Content-Type: application/json, quên header đó có thể khiến handler bỏ qua decode JSON, trả mã khác, hoặc rẽ vào nhánh không xảy ra production. Tương tự với auth: thiếu header Authorization không giống token không hợp lệ. Đó phải là hai case khác nhau.

Cạm bẫy khác là assert toàn bộ JSON response như chuỗi thô. Thay đổi nhỏ về thứ tự trường, khoảng trắng, hoặc thêm trường mới sẽ phá test dù API vẫn đúng. Decode body vào struct hoặc map[string]any, rồi assert điều quan trọng: status, mã lỗi, message, và vài trường chính.

Test cũng không đáng tin khi case chia sẻ state mutable. Tái sử dụng cùng in-memory store, biến toàn cục, hoặc router singleton qua các hàng bảng có thể làm rò rỉ dữ liệu giữa case. Mỗi test case nên bắt đầu sạch, hoặc reset state trong t.Cleanup.

Những pattern gây brittle thường:

  • Tạo request mà không có header và encoding giống client thật
  • Assert toàn bộ JSON string thay vì decode và kiểm tra trường
  • Tái sử dụng database/cache/state toàn cục giữa các case
  • Nhồi nhét auth, validation và business logic vào một test quá lớn

Giữ mỗi test tập trung. Nếu một case fail, bạn nên biết ngay đó là auth, validation hay format lỗi trong vài giây.

Checklist nhanh trước phát hành bạn có thể tái sử dụng

Ship predictable REST endpoints
Generate handlers, validation, and error responses you can test with httptest from day one.
Build API

Trước khi ship, test nên chứng minh hai điều: endpoint theo hợp đồng, và nó fail theo cách an toàn, dự đoán được.

Chạy các case theo bảng, và bắt mỗi case assert cả response lẫn side effect:

  • Auth: không token, token sai, vai trò sai, vai trò đúng (và xác nhận case "vai trò sai" không lộ chi tiết)
  • Đầu vào: thiếu trường bắt buộc, sai kiểu, giới hạn biên (min/max), trường lạ bạn muốn từ chối
  • Đầu ra: mã trạng thái, header chính (như Content-Type), trường JSON bắt buộc, dạng lỗi nhất quán
  • Phụ thuộc: ép một thất bại downstream (DB, queue, payment, email), xác minh message an toàn, xác nhận không có ghi không hoàn chỉnh
  • Idempotency: lặp lại cùng request (hoặc retry sau timeout) và xác nhận không tạo trùng

Sau đó, thêm một assert sanity: xác nhận handler không chạm vào thứ không được phép. Ví dụ, trong case validate thất bại, verify không có record được tạo và không có email gửi đi.

Nếu bạn xây API bằng công cụ như AppMaster, cùng checklist vẫn áp dụng. Ý chính: chứng minh hành vi công khai giữ ổn định.

Ví dụ: một endpoint, một bảng nhỏ, và những gì nó bắt được

Giả sử bạn có endpoint đơn giản: POST /login. Nó nhận JSON với emailpassword. Trả 200 với token khi thành công, 400 cho input không hợp lệ, 401 cho cred sai, và 500 nếu dịch vụ auth bị down.

Một bảng súc tích như sau bao phủ phần lớn lỗi xảy ra production.

func TestLoginHandler(t *testing.T) {
	// Fake dependency so we can force 200/401/500 without hitting real systems.
	auth := &FakeAuth{ /* configure per test */ }
	h := NewLoginHandler(auth)

	tests := []struct {
		name       string
		body       string
		authHeader string
		setup      func()
		wantStatus int
		wantBody   string
	}{
		{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
		{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
		{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
		{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
		{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
		{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tt.setup()
			req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
			req.Header.Set("Content-Type", "application/json")
			if tt.authHeader != "" {
				req.Header.Set("Authorization", tt.authHeader)
			}

			rr := httptest.NewRecorder()
			h.ServeHTTP(rr, req)

			if rr.Code != tt.wantStatus {
				t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
			}
			if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
				t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
			}
		})
	}
}

Theo dõi một case từ đầu đến cuối: với “missing password”, bạn gửi body chỉ có email, đặt Content-Type, chạy ServeHTTP, rồi assert 400 và lỗi rõ ràng chỉ ra password. Case đơn này chứng minh decoder, validator và format lỗi hoạt động cùng nhau.

Nếu bạn muốn tốc độ để chuẩn hóa hợp đồng, auth và integrations đồng thời vẫn xuất mã Go thật, AppMaster (appmaster.io) được thiết kế cho điều đó. Dù vậy, các test như trên vẫn có giá trị vì chúng khoá hành vi mà client phụ thuộc.

Dễ dàng bắt đầu
Tạo thứ gì đó tuyệt vời

Thử nghiệm với AppMaster với gói miễn phí.
Khi bạn sẵn sàng, bạn có thể chọn đăng ký phù hợp.

Bắt đầu