文章目录
一、构建 HTTP server
1.1 model.go
package main
import (
"errors"
"time"
)
var TopicCache = make([]*Topic, 0, 16)
type Topic struct {
Id int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// 从数组中找到一项, 根据 id 找到数组的下标
func FindTopic(id int) (*Topic, error) {
if err := checkIndex(id); err != nil {
return nil, err
}
return TopicCache[id-1], nil
}
// 创建一个 Topic 实例, 没有输入参数, 内部根据 Topic 数组的长度来确定新 Topic 的 id
func (t *Topic) Create() error {
// 初始时len 为 0, id 为 1, 即数组下标为0时并不放置元素, 而数组从下标为1才开始放置元素
t.Id = len(TopicCache) + 1 // 忽略用户传入的 id, 而是根据数组的长度, 决定此项的 Id
t.CreatedAt = time.Now()
TopicCache = append(TopicCache, t) // 初始时数组为空, 放入的第一个元素是 Id = 1
return nil
}
// 更新一个 Topic 实例, 通过 id 找到数组下标, 最终改的还是数组里的值
func (t *Topic) Update() error {
if err := checkIndex(t.Id); err != nil {
return err
}
TopicCache[t.Id-1] = t
return nil
}
func (t *Topic) Delete() error {
if err := checkIndex(t.Id); err != nil {
return err
}
TopicCache[t.Id-1] = nil
return nil
}
func checkIndex(id int) error {
if id > 0 && len(TopicCache) <= id-1 {
return errors.New("The topic is not exists!")
}
return nil
}
1.2 server.go
package main
import (
"encoding/json"
"net/http"
"path"
"strconv"
)
func main() {
http.HandleFunc("/topic/", handleRequest)
http.ListenAndServe(":2017", nil)
}
// main handler function
func handleRequest(w http.ResponseWriter, r *http.Request) {
var err error
switch r.Method {
case http.MethodGet:
err = handleGet(w, r)
case http.MethodPost:
err = handlePost(w, r)
case http.MethodPut:
err = handlePut(w, r)
case http.MethodDelete:
err = handleDelete(w, r)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// 获取一个帖子
// 如 GET /topic/1
func handleGet(w http.ResponseWriter, r *http.Request) error {
// 用户输入的 url 中有 id, 通过 path.Base(r.URL.Path) 获取 id
id, err := strconv.Atoi(path.Base(r.URL.Path))
if err != nil {
return err
}
topic, err := FindTopic(id)
if err != nil {
return err
}
// 序列化结果并输出
output, err := json.MarshalIndent(&topic, "", "\t\t")
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.Write(output)
return nil
}
// 增加一个帖子
// POST /topic/
func handlePost(w http.ResponseWriter, r *http.Request) (err error) {
// 构造长度为 r.ContentLength 的缓冲区
body := make([]byte, r.ContentLength)
// 读取到缓冲区
r.Body.Read(body)
// 反序列化到对象
var topic = new(Topic)
err = json.Unmarshal(body, &topic)
if err != nil {
return
}
// 执行操作
err = topic.Create()
if err != nil {
return
}
w.WriteHeader(http.StatusOK)
return
}
// 更新一个帖子
// PUT /topic/1
func handlePut(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.Atoi(path.Base(r.URL.Path))
if err != nil {
return err
}
topic, err := FindTopic(id)
if err != nil {
return err
}
body := make([]byte, r.ContentLength)
r.Body.Read(body)
json.Unmarshal(body, topic)
err = topic.Update()
if err != nil {
return err
}
w.WriteHeader(http.StatusOK)
return nil
}
// 删除一个帖子
// DELETE /topic/1
func handleDelete(w http.ResponseWriter, r *http.Request) (err error) {
id, err := strconv.Atoi(path.Base(r.URL.Path))
if err != nil {
return
}
topic, err := FindTopic(id)
if err != nil {
return
}
err = topic.Delete()
if err != nil {
return
}
w.WriteHeader(http.StatusOK)
return
}
1.3 curl 验证 server 功能
1.3.1 新建
curl -i -X POST http://localhost:2017/topic/ -H 'content-type: application/json' -d '{"title":"a", "content":"b"}'
HTTP/1.1 200 OK
Date: Mon, 11 Mar 2024 02:54:08 GMT
Content-Length: 0
1.3.2 查询
curl -i -X GET http://localhost:2017/topic/1
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 11 Mar 2024 03:00:11 GMT
Content-Length: 99
{
"id": 1,
"title": "a",
"content": "b",
"created_at": "2024-03-11T10:59:44.043029+08:00"
}
1.3.3 更新
curl -i -X PUT http://localhost:2017/topic/1 -H 'content-type: application/json' -d '{"title": "c", "content": "d"}'
HTTP/1.1 200 OK
Date: Mon, 11 Mar 2024 03:01:51 GMT
Content-Length: 0
curl -i -X GET http://localhost:2017/topic/1
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 11 Mar 2024 03:01:54 GMT
Content-Length: 99
{
"id": 1,
"title": "c",
"content": "d",
"created_at": "2024-03-11T10:59:44.043029+08:00"
}
1.3.4 删除
curl -i -X DELETE http://localhost:2017/topic/1
HTTP/1.1 200 OK
Date: Mon, 11 Mar 2024 03:03:41 GMT
Content-Length: 0
curl -i -X GET http://localhost:2017/topic/1
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 11 Mar 2024 03:04:27 GMT
Content-Length: 4
null
二、httptest 测试
上文,通过 curl 自测了 controller,现在通过 net/http/httptest 测试,这种测试方式其实是没有 HTTP 调用的,是通过将 handler() 函数绑定到 url 上实现的。
2.1 完整示例
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandlePost(t *testing.T) {
// mux 是多路复用器的意思
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest) // 将 [业务的 handleRequest() 函数] 注册到 mux 的 /topic/ 路由上
// 构造一个请求
reader := strings.NewReader(`{"title":"e", "content":"f"}`)
r, _ := http.NewRequest(http.MethodPost, "/topic/", reader)
// 构造一个响应 (httptest.ResponseRecorder 实现了 http.ResponseWriter 接口)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
//handleRequest(w, r)
// 获取响应结果
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
}
2.2 实现逻辑
实现逻辑如下:
首先配置路由,将 /topic 的请求都路由给 handleRequest() 函数实现。
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest)
因为 handleRequest(w http.ResponseWriter, r *http.Request) 函数的签名是 w 和 r 两个参数,所以为了测试,需要构造这两个参数实例。
因为 httptest.ResponseRecorder 实现了 http.ResponseWriter 接口,所以可以用 httptest.NewRecorder() 表示 w。
准备好之后,就可以执行了
- 可以只调用 handleRequest(w, r)
- 也可以调用 mux.ServeHTTP(w, r),其内部也会调用 handleRequest(w, r),这会更完整的测试整个流程。
最后,通过 go test -v 可以执行测试。
$ go test -v
=== RUN TestHandlePost
--- PASS: TestHandlePost (0.00s)
PASS
ok benchmarkdemo 0.095s
2.3 其他示例
func TestHandleGet(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest)
r, _ := http.NewRequest(http.MethodGet, "/topic/1", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
topic := new(Topic)
json.Unmarshal(w.Body.Bytes(), topic)
if topic.Id != 1 {
t.Errorf("cannot get topic by id")
}
}
注意,因为数据没有落地存储,为了保证后面的测试正常,请将 TestHandlePost 放在最前面。
- 如果 go test -v 测试整个包的话,TestHandlePost 和 TestHandleGet 两个单测都能成功
- 但如果分开测试的话,只有 TestHandlePost 能成功,而 TestHandleGet 会失败(因为没有 POST 创建流程,而只有 GET 创建流程的话,在业务逻辑的数组中,找不到 id = 1 的项,就会报错)
2.4 用 TestMain 避免重复的测试代码
细心的朋友应该会发现,上面的测试代码有重复,比如:
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest)
以及:
w := httptest.NewRecorder()
这正好是前面学习的 setup
可以做的事情,因此可以使用 TestMain
来做重构。实现如下:
var w *httptest.ResponseRecorder
func TestMain(m *testing.M) {
w = httptest.NewRecorder()
os.Exit(m.Run())
}
2.5 gin 框架的 httptest
package service
import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type userINfo struct {
ID uint64 `json:"id"`
Name string `json:"name"`
}
func handler(c *gin.Context) {
var info userINfo
if err := c.ShouldBindJSON(&info); err != nil {
log.Panic(err)
}
fmt.Println(info)
c.Writer.Write([]byte(`{"status": 200}`))
}
func TestHandler(t *testing.T) {
rPath := "/user"
router := gin.Default()
router.GET(rPath, handler)
req, _ := http.NewRequest("GET", rPath, strings.NewReader(`{"id": "1","name": "joe"}`))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
t.Logf("status: %d", w.Code)
t.Logf("response: %s", w.Body.String())
}