1. 单元测试
- 测试文件和目标文件一般放在一个包下,且测试文件命名有要求
hello.go
:目标文件hello_test.go
:测试文件,需要以_test.go结尾
- 测试函数必须以
Test
开头,后面加函数名name
:目标函数名TestName
:测试函数名
func TestAdd(t *testing.T){ ... }
- 参数t用于报告测试失败和附加的日志信息。
- testing.T的拥有的方法如下:
func (c *T) Cleanup(func())
func (c *T) Error(args ...interface{})
func (c *T) Errorf(format string, args ...interface{})
func (c *T) Fail()
func (c *T) FailNow()
func (c *T) Failed() bool
func (c *T) Fatal(args ...interface{})
func (c *T) Fatalf(format string, args ...interface{})
func (c *T) Helper()
func (c *T) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool
func (c *T) TempDir() string
2. 一个简单的例子
目标文件
package simple_demo
import "strings"
func Split(s, sep string) (result []string) {
i := strings.Index(s, sep)
for i > -1 {
result = append(result, s[:i])
s = s[i+len(sep):]
i = strings.Index(s, sep)
}
result = append(result, s)
return
}
测试文件
package simple_demo
import (
"reflect"
"testing"
)
func TestSplit(t *testing.T) {
got := Split("a:b:c", ":") // 程序输出的结果
want := []string{"a", "b", "c"} // 期望的结果
if !reflect.DeepEqual(want, got) { // 因为slice不能比较直接,借助反射包中的方法比较
t.Errorf("expected:%v, got:%v", want, got) // 测试失败输出错误提示
}
}
2.1 命令行下的一些命令
go test
:将当前目录下的所有测试函数进行测试go test -v
:-v表示添加详细信息go test -run=Split
:只测试函数名包含Split的函数go test -cover
:查看测试覆盖率go test -cover -coverprofile=c.out
:将覆盖率输出到一个文件中- ``
2.2 子测试
func TestXXX(t *testing.T){
t.Run("case1", func(t *testing.T){...})
t.Run("case2", func(t *testing.T){...})
t.Run("case3", func(t *testing.T){...})
}
- 可以在一个函数中测试多个测试数据
- 一般是通过切片 定义多个测试数据
package simple_demo
import (
"reflect"
"testing"
)
func TestSplitAll(t *testing.T) {
tests := []struct {
name string
input string
sep string
want []string
}{
{"base case", "a:b:c", ":", []string{"a", "b", "c"}},
{"wrong sep", "a:b:c", ",", []string{"a:b:c"}},
{"more sep", "abcd", "bc", []string{"a", "d"}},
{"leading sep", "明日复明日,明日何其多", "明日", []string{"", "复", ",", "何其多"}},
}
// 遍历测试用例
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { // 使用t.Run()执行子测试
t.Parallel() // 将每个测试用例标记为能够彼此并行运行
got := Split(tt.input, tt.sep)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("expected:%#v, got:%#v", tt.want, got)
}
})
}
}
t.Parallel()
:使得每次测试用例之间可以并行运行
3. 对网络的mock测试
3.1 使用httptest实现对内部API的mock
目标文件
package httptest_demo
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
// Param 请求参数
type Param struct {
Name string `json:"name"`
}
// helloHandler /hello请求处理函数
func helloHandler(c *gin.Context) {
var p Param
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusOK, gin.H{
"msg": "we need a name",
})
return
}
c.JSON(http.StatusOK, gin.H{
"msg": fmt.Sprintf("hello %s", p.Name),
})
}
// SetupRouter 路由
func SetupRouter() *gin.Engine {
router := gin.Default()
router.POST("/hello", helloHandler)
return router
}
测试文件
package httptest_demo
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_helloHandler(t *testing.T) {
// 定义两个测试用例
tests := []struct {
name string
param string
expect string
}{
{"base case", `{"name": "zhangsan"}`, "hello zhangsan"},
{"bad case", "", "we need a name"},
}
r := SetupRouter()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// mock一个HTTP请求
req := httptest.NewRequest(
"POST", // 请求方法
"/hello", // 请求URL
strings.NewReader(tt.param), // 请求参数
)
// mock一个响应记录器
w := httptest.NewRecorder()
// 让server端处理mock请求并记录返回的响应内容
r.ServeHTTP(w, req)
// 校验状态码是否符合预期
assert.Equal(t, http.StatusOK, w.Code)
// 解析并检验响应内容是否复合预期
var resp map[string]string
err := json.Unmarshal([]byte(w.Body.String()), &resp)
assert.Nil(t, err)
assert.Equal(t, tt.expect, resp["msg"])
})
}
}
3.2 使用gock对外部API的mock
目标文件
package gock_demo
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
)
type ReqParam struct {
X int `json:"x"`
}
// Result API返回结果
type Result struct {
Value int `json:"value"`
}
func GetResultByAPI(x, y int) int {
p := &ReqParam{X: x}
b, _ := json.Marshal(p)
// 调用其他服务的API
resp, err := http.Post(
"http://your-api.com/post",
"application/json",
bytes.NewBuffer(b),
)
if err != nil {
return -1
}
body, _ := ioutil.ReadAll(resp.Body)
var ret Result
if err := json.Unmarshal(body, &ret); err != nil {
return -1
}
// 这里是对API返回的数据做一些逻辑处理
return ret.Value + y
}
测试文件
package gock_demo
import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
)
func TestGetResultByAPI(t *testing.T) {
defer gock.Off() // 测试执行后刷新挂起的mock
// mock 请求外部api时传参x=1返回100
gock.New("http://your-api.com").
Post("/post").
MatchType("json").
JSON(map[string]int{"x": 1}).
Reply(200).
JSON(map[string]int{"value": 100})
// 调用我们的业务函数
res := GetResultByAPI(1, 1)
// 校验返回结果是否符合预期
assert.Equal(t, res, 101)
// mock 请求外部api时传参x=2返回200
gock.New("http://your-api.com").
Post("/post").
MatchType("json").
JSON(map[string]int{"x": 2}).
Reply(200).
JSON(map[string]int{"value": 200})
// 调用我们的业务函数
res = GetResultByAPI(2, 2)
// 校验返回结果是否符合预期
assert.Equal(t, res, 202)
assert.True(t, gock.IsDone()) // 断言mock被触发
}
4. 对数据库的mock测试
4.1 sqlmock实现对数据库访问的mock
目标文件
package main
import "database/sql"
// recordStats 记录用户浏览产品信息
func recordStats(db *sql.DB, userID, productID int64) (err error) {
// 开启事务
// 操作views和product_viewers两张表
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
switch err {
case nil:
err = tx.Commit()
default:
tx.Rollback()
}
}()
// 更新products表
if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
return
}
// product_viewers表中插入一条数据
if _, err = tx.Exec(
"INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)",
userID, productID); err != nil {
return
}
return
}
func main() {
// 注意:测试的过程中并不需要真正的连接
db, err := sql.Open("mysql", "root@/blog")
if err != nil {
panic(err)
}
defer db.Close()
// userID为1的用户浏览了productID为5的产品
if err = recordStats(db, 1 /*some user id*/, 5 /*some product id*/); err != nil {
panic(err)
}
}
测试文件
package main
import (
"fmt"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// TestShouldUpdateStats sql执行成功的测试用例
func TestShouldUpdateStats(t *testing.T) {
// mock一个*sql.DB对象,不需要连接真实的数据库
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
// mock执行指定SQL语句时的返回结果
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
// 将mock的DB对象传入我们的函数中
if err = recordStats(db, 2, 3); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
// 确保期望的结果都满足
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
// TestShouldRollbackStatUpdatesOnFailure sql执行失败回滚的测试用例
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").
WithArgs(2, 3).
WillReturnError(fmt.Errorf("some error"))
mock.ExpectRollback()
// now we execute our method
if err = recordStats(db, 2, 3); err == nil {
t.Errorf("was expecting an error, but there was none")
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
4.2 miniredis实现对redis的mock
目标文件
package miniredis_demo
import (
"context"
"github.com/go-redis/redis/v8" // 注意导入版本
"strings"
"time"
)
const (
KeyValidWebsite = "app:valid:website:list"
)
func DoSomethingWithRedis(rdb *redis.Client, key string) bool {
// 这里可以是对redis操作的一些逻辑
ctx := context.TODO()
if !rdb.SIsMember(ctx, KeyValidWebsite, key).Val() {
return false
}
val, err := rdb.Get(ctx, key).Result()
if err != nil {
return false
}
if !strings.HasPrefix(val, "https://") {
val = "https://" + val
}
// 设置 blog key 五秒过期
if err := rdb.Set(ctx, "blog", val, 5*time.Second).Err(); err != nil {
return false
}
return true
}
测试文件
package miniredis_demo
import (
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"testing"
"time"
)
func TestDoSomethingWithRedis(t *testing.T) {
// mock一个redis server
s, err := miniredis.Run()
if err != nil {
panic(err)
}
defer s.Close()
// 准备数据
s.Set("q1mi", "liwenzhou.com")
s.SAdd(KeyValidWebsite, "q1mi")
// 连接mock的redis server
rdb := redis.NewClient(&redis.Options{
Addr: s.Addr(), // mock redis server的地址
})
// 调用函数
ok := DoSomethingWithRedis(rdb, "q1mi")
if !ok {
t.Fatal()
}
// 可以手动检查redis中的值是否复合预期
if got, err := s.Get("blog"); err != nil || got != "https://liwenzhou.com" {
t.Fatalf("'blog' has the wrong value")
}
// 也可以使用帮助工具检查
s.CheckGet(t, "blog", "https://liwenzhou.com")
// 过期检查
s.FastForward(5 * time.Second) // 快进5秒
if s.Exists("blog") {
t.Fatal("'blog' should not have existed anymore")
}
}