在当今⼤型的Go应⽤程序中,整体的架构都会⽐较复杂,从程序本身来讲,它可能会依赖很多的基础服务,⽐如数据库存储mysql等、缓存系统redis等,其它的⼀些基础服务⽐如Prometheus等,程序的业务逻辑会访问这些基础服务。
同时,微服务架构额流⾏,也导致⼀个产品会划分成多个微服务,每个微服务只专注于⾃⼰的业务,服务之间通过restful的 API或者RPC框架或者其它⽅式进⾏通讯。
这就给测试带来了很⼤的困难,本来,我们要测试的是很⼩的⼀段代码逻辑,⽐如某个类型的⼀个⽅法,但是由于⽅法体中依赖基础服务或者其它的微服务,这些基础服务和其它的微服务都是线上的服务,没有办法集成测试,即使搭建了线上的环境,也可能因为我们的开发机的环境由于安全等原因,也没有办法直接访问这些服务,导致没有办法执⾏单元测试。
另外如果我们在开发代码的时候,⼀开始并没有从易于单元测试的⻆度去开率设计⽅法实现,导致很难进⾏单元测试,⽐如实现某个⽅法的时候,把所有的逻辑都写在⼀个⽅法实现中:
func (s *S) AMethod() {
读取⼀些配置
建⽴数据库的连接
访问数据库
执⾏⼀些业务逻辑
访问第三⽅的微服务
访问redis缓存
执⾏⼀些业务逻辑
调⽤另⼀个微服务
返回
}
⼀些代码规范或者最佳实践会告诉你,每⼀个函数和⽅法的实现应该保持尽量的⼩,它们只执⾏单⼀的业务逻辑。⾯向对象编程五⼤原则之⼀就是单⼀职责原则:⼀个类或者⼀个函数应该只负责程序中的单⼀逻辑部分。⽐如上⾯的代码,如果拆分成多个⽅法,⼀个⽅法的返回值可以是另外⼀个⽅法的输⼊参数,这样我们在实现这些⽅法的单元测试时,就可以mock这些参数,针对这些⽅法进⾏测试。
Mock的⽅式有很多,你可以⾃⼰写⼀些Mock对象,也可以使⽤第三⽅的mock库。这⾥我只举⼏个mock库例⼦。
google/mock
google/mock 是已个Go testing集合⾮常好的mock库,star数很⾼,贡献者也众多,也是值得⼀试。
你可以通过下⾯的命令安装mockgen⼯具,它可以为借⼝对象⽣成mock实现。
go install github.com/golang/mock/mockgen@latest
⽐如你可以为标准库的 io.Writer,ioReader ⽣成Mock对象,不不必关⼼⾃动⽣成的Mock对象,⽽是直接使⽤它们即可。
mockgen -destination io_test.go io Reader,Writer
重要的你可以实现 Controler mock相应的对象:
func TestReader(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
r := NewMockReader(ctrl)
r.EXPECT().Read(gomock.All()).Do(func(arg0 []byte) {
for i := 0; i < 10; i++ {
arg0[i] = 'a' + byte(i)
}
}).Return(10, nil).AnyTimes()
data := make([]byte, 1024)
n, err := r.Read(data)
assert.NoError(t, err)
assert.Equal(t, 10, n)
assert.Equal(t, "abcdefghij", string(data[:n]))
}
DATA-DOG/go-sqlmock
我们很多的应⽤都是和·数据库访问相关的,测试和数据库相关函数时就相当的痛苦。有时候我们使⽤sqlite3这样的嵌⼊式数据库来作为集成数据库测试,但是我们也可以mock数据库对象。
datadog就提供了这样⼀个⾮常好⽤的库:DATA-DOG/go-sqlmock: Sql mock driver for golang totest database interactions。
假定你使⽤ go-sql-driver/mysql 提供数据库访问的功能,recordStats
提供了数据:
package main
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
func recordStats(db *sql.DB, userID, productID int64) (err error) {
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
switch err {
case nil:
err = tx.Commit()
default:
tx.Rollback()
}
}()
if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
return
}
if _, err = tx.Exec("INSERT INTO product_viewers (user_id, product_id)
VALUES (?, ?)", userID, productID); err != nil {
return
}
return
}
func main() {
// @NOTE: the real connection is not required for tests
db, err := sql.Open("mysql", "root@/blog")
if err != nil {
panic(err)
}
defer db.Close()
if err = recordStats(db, 1 /*some user id*/, 5 /*some product id*/); err !=
nil {
panic(err)
}
}
我们就可以使⽤个 go-sqlmock
来进⾏正⾯测试和负⾯测试:
package main
import (
"fmt"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// 成功的单测
func TestShouldUpdateStats(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)) // 执⾏update ...的时候返回值
mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2,
3).WillReturnResult(sqlmock.NewResult(1, 1)) // 执⾏insert ...的返回值
mock.ExpectCommit()
// now we execute our method
if err = recordStats(db, 2, 3); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
// 失败的单测
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)
}
}
标准库的httptest
Go标准库httptest提供了测试handler和模拟http server的⽅法。
经常,我们会使⽤Go的http包写⼀些web应⽤程序,我们会实现⼀些handler,测试这些handler其实很⽅便,使⽤httptest.NewRecorder
就可以模拟⼀个http.ResponseWriter
:
package httptest
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<html><body>Hello World!</body></html>")
}
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode)
fmt.Println(resp.Header.Get("Content-Type"))
fmt.Println(string(body))
}
如果我们想测试client的⾏为,需要临时启动⼀个server进⾏测试,也可以使⽤httptest启动⼀个http server,还可以⽀持http2:
package httptest
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPServer(t *testing.T) {
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s", r.Proto)
}))
ts.EnableHTTP2 = true
ts.StartTLS()
defer ts.Close()
res, err := ts.Client().Get(ts.URL)
if err != nil {
log.Fatal(err)
}
greeting, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s", greeting)
}
当然还有⼀些其它的mock库,如果有需要,你也可以快速评估⼀下,看看能否应⽤到你的单元测试中,为你的单元测试提供便利:
- https://github.com/h2non/gock
- GitHub - gavv/httpexpect: End-to-end HTTP and REST API testing for Go.
- GitHub - steinfletcher/apitest: A simple and extensible behavioural testing library for Go.You can use api test to simplify REST API, HTTP handler and e2e tests.
- GitHub - carlmjohnson/be: Generic testing helper for Go
- GitHub - jfilipczyk/gomatch: Library created for testing JSON against patterns.
容器化服务
虽然mock能够解决⼀部分的单元测试的痛点,但是mock库⼀般要求mock的对象是接⼝,你的函数的输⼊参数类型也得要求是接⼝类型,这会将⼤部分的业务函数和⽅法排除在外。另外写这些mock对象有时候也⾮常麻烦,没有直接的集成测试⽅便,所以⽬前有⼏个库,提供了集成容器测试的功能,那么依赖的基础服务,⽐如redis、mysql、kafka等,在测试额时候启动这些容器,测试完毕后再删除这些容器。
使⽤这些容器的时候,要求你的docker要运⾏着,因为这些库会和docker服务交互,拉去镜像、启动容器、停⽌容器和删除容器。
为了减少测试的时间,你最好先把这些镜像⼿⼯下载下来。
当然你不使⽤这些库,你也可以⼿⼯启动容器配合测试。使⽤这些库的好处是⾃动化,⽐如⾃动化在kafka上创建 topic、测试完毕后⾃动删除容器。
这⾥我们看三个相关的库。
arikama/go-mysql-test-container
go-mysql-test-container是⼀个专⻔⽤来集成mysql的库。
因为只针对集成mysql,所以它使⽤起来也⾮常的简单:
package main
import (
"testing"
"github.com/arikama/go-mysql-test-container/mysqltestcontainer"
)
func Test(t *testing.T) {
mySql, _ := mysqltestcontainer.Create("test")
db := mySql.GetDb()
err := db.Ping()
if err != nil {
log.L.Errorln(err.Error())
}
}
testcontainers/testcontainers-go
事实上上⾯的go-mysql-test-container底层使⽤的是testcontainers/testcontainers-go)库,只不过对mysql的集成做了进⼀步的封装。
testcontainers-go 提供了⼀个通⽤的集成docker容器的⽅法。⽐如集成redis:
package container
import (
"context"
"testing"
"github.com/go-redis/redis"
"github.com/stretchr/testify/assert"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestWithRedis(t *testing.T) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "redis:latest",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForLog("Ready to accept connections"),
}
redisC, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Error(err)
}
defer redisC.Terminate(ctx)
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
err = rdb.Set("key", "value", 0).Err()
assert.NoError(t, err)
val, err := rdb.Get("key").Result()
assert.NoError(t, err)
assert.Equal(t, "value", val)
}
⼜或者集成kafka:
package container
import (
"context"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/segmentio/kafka-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
)
func TestWithKafka(t *testing.T) {
kafkaContainer := testcontainers.NewLocalDockerCompose(
[]string{"testdata/docker-compose.yml"},
strings.ToLower(uuid.New().String()),
)
execError := kafkaContainer.WithCommand([]string{"up", "-d"}).Invoke()
require.NoError(t, execError.Error)
// kafka starts ver slow
time.Sleep(time.Minute)
defer destroyKafka(kafkaContainer)
// test write
w := &kafka.Writer{
Addr: kafka.TCP("localhost:9092"),
Topic: "test-topic",
Balancer: &kafka.LeastBytes{},
}
err := w.WriteMessages(context.Background(),
kafka.Message{
Key: []byte("Key-A"),
Value: []byte("Hello World!"),
},
kafka.Message{
Key: []byte("Key-B"),
Value: []byte("One!"),
},
kafka.Message{
Key: []byte("Key-C"),
Value: []byte("Two!"),
},
)
assert.NoError(t, err)
err = w.Close()
assert.NoError(t, err)
// test read
r := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{"localhost:9092"},
Topic: "test-topic",
Partition: 0,
MinBytes: 10e3, // 10KB
MaxBytes: 10e6, // 10MB
})
m, err := r.ReadMessage(context.Background())
assert.NoError(t, err)
assert.NotEmpty(t, m)
}
func destroyKafka(compose *testcontainers.LocalDockerCompose) {
compose.Down()
time.Sleep(1 * time.Second)
}
其中kafka是通过docker compose启动的,它的配置如下:
version: "3"
services:
zookeeper:
image: wurstmeister/zookeeper:latest
container_name: zookeeper
expose:
- 2181
kafka:
image: wurstmeister/kafka:latest
container_name: kafka
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_CREATE_TOPICS: "test-topic:1:1"
orlangure/gnomock
testcontainers-go
使⽤起来稍显繁琐,gnomock也提供了⼀个更为简单的办法,并且提供了⼗⼏种基础服务的集成,⽐如redis、kafka、mongodb等。
⽐如它的kafka集成测试就⽐较简单了:
package gnomock_test
import (
"context"
"os"
"testing"
"time"
"github.com/orlangure/gnomock"
"github.com/orlangure/gnomock/preset/kafka"
kafkaclient "github.com/segmentio/kafka-go"
"github.com/stretchr/testify/require"
)
func TestKafka(t *testing.T) {
// init some messages
messages := []kafka.Message{
{
Topic: "events",
Key: "order",
Value: "1",
Time: time.Now().UnixNano(),
},
{
Topic: "alerts",
Key: "CPU",
Value: "92",
Time: time.Now().UnixNano(),
},
}
p := kafka.Preset(
kafka.WithTopics("topic-1", "topic-2"),
kafka.WithMessages(messages...),
)
// start the kafka container
container, err := gnomock.Start(
p,
gnomock.WithDebugMode(), gnomock.WithLogWriter(os.Stdout),
gnomock.WithContainerName("kafka"),
gnomock.WithUseLocalImagesFirst(),
)
require.NoError(t, err)
// stop the kafka container on exit
defer func() { require.NoError(t, gnomock.Stop(container)) }()
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
alertsReader := kafkaclient.NewReader(kafkaclient.ReaderConfig{
Brokers: []string{container.Address(kafka.BrokerPort)},
Topic: "alerts",
})
m, err := alertsReader.ReadMessage(ctx)
require.NoError(t, err)
require.NoError(t, alertsReader.Close())
require.Equal(t, "CPU", string(m.Key))
require.Equal(t, "92", string(m.Value))
eventsReader := kafkaclient.NewReader(kafkaclient.ReaderConfig{
Brokers: []string{container.Address(kafka.BrokerPort)},
Topic: "events",
})
m, err = eventsReader.ReadMessage(ctx)
require.NoError(t, err)
require.NoError(t, eventsReader.Close())
require.Equal(t, "order", string(m.Key))
require.Equal(t, "1", string(m.Value))
c, err := kafkaclient.Dial("tcp", container.Address(kafka.BrokerPort))
require.NoError(t, err)
require.NoError(t, c.DeleteTopics("topic-1", "topic-2"))
require.Error(t, c.DeleteTopics("unknown-topic"))
require.NoError(t, c.Close())
}
redis的集成也很简单:
package gnomock_test
import (
"fmt"
"testing"
redisclient "github.com/go-redis/redis/v7"
"github.com/orlangure/gnomock"
"github.com/orlangure/gnomock/preset/redis"
)
func TestRedis(t *testing.T) {
vs := make(map[string]interface{})
vs["a"] = "foo"
vs["b"] = 42
vs["c"] = true
p := redis.Preset(redis.WithValues(vs))
container, _ := gnomock.Start(p,
gnomock.WithDebugMode(),
gnomock.WithUseLocalImagesFirst(),
)
defer func() { _ = gnomock.Stop(container) }()
addr := container.DefaultAddress()
client := redisclient.NewClient(&redisclient.Options{Addr: addr})
fmt.Println(client.Get("a").Result())
var number int
err := client.Get("b").Scan(&number)
fmt.Println(number, err)
var flag bool
err = client.Get("c").Scan(&flag)
fmt.Println(flag, err)
}
monkey/gomonkey
Mock⽅式使⽤起来有很多的限制,容器化的⽅式⼜依赖docker环境,测试也很慢,只能集成通⽤的基础服务,那么有没有⼀种⽅法能更好的⽤来单元测试呢?
有!
这⾥就不得不提到bouk/monkey,这个库可以说是开创了⼀个运⾏时修改函数的⻔派,好⼏个库都是基于他的思想去实现的,尽管后来的库有各种⽅法和提供遍历的⼿段,但是核⼼思想还是受到 boulk/monkey 的影响。
此库的作者专⻔写了⼀篇⽂章,介绍这个库的实现⽅法:Monkey Patching in Go (bou.ke)。
这个库的实现的功能太过“邪恶”,除了测试作者不建议你把它应⽤到线上产品。,所以它的版权也很特殊,这个库也被被归档了:
Copyright Bouke van der Bijl
I do not give anyone permissions to use this tool for any purpose. Don’t use it.
I’m not interested in changing this license. Please don’t ask.
这个库最⼤的本事就是可以在运⾏时修改⽅法,⽐如下⾯的程序就修改了fmt.Println
⽅法:
package main
import (
"fmt"
"os"
"strings"
"bou.ke/monkey"
)
func main() {
monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {
s := make([]interface{}, len(a))
for i, v := range a {
s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1)
}
return fmt.Fprintln(os.Stdout, s...)
})
fmt.Println("what the hell?") // what the *bleep*?
}
但是这个库如果⽤来辅助单元测试,就太⽅便了。因为我们可以在单元测试的时候,修改相关依赖的函数和⽅法,不实际调⽤这些依赖的函数和⽅法,⽽是使⽤⼀个期望的返回值做替换(当然也可以修改输⼊参数),可以完全不依赖基础服务和第三⽅服务,单元测试就不会再有什么阻碍了。
但是这个库的版权限制了我们的使⽤。
中兴通讯资深架构师张晓⻰基于monkey思想,实现了⼀个适合⽤来单元测试的monkey patching库:agiledragon/gomonkey。
gomonkey不是⼀个monkey的简单克隆,它专注于单元测试,所以提供了很多的便利的⽅法:
- 可以patch 函数
- 可以patch public ⽅法
- 可以patch private ⽅法
- 可以patch 接⼝
- 可以patch函数变量
- 可以patch全局变量
- 可以为函数、⽅法、接⼝和函数变量提供指定顺序的返回值,适合它们多次调⽤的场景
这个库提供很⼤量的单元测试,可以⽤来参考和学习。这⾥我举⼀个简单的例⼦:
func TestPrintlnByGomonkey(t *testing.T) {
patches := gomonkey.ApplyFunc(fmt.Println, func(a ...any) (n int, err
error) {
return fmt.Fprintln(os.Stdout, "I have changed the arguments")
})
defer patches.Reset()
fmt.Println("hello world")
}
不过monkey/gomonkey在MacOS上使⽤有问题,会报错或者不起作⽤。下⾯是在MacPro M1上的⼀种配置。
- ⾸先在MacOS安装的Go架构版本是Amd64, ⽽不是arm64。安装 Amd64de1Go版本可以正常运⾏。
- 使⽤eisenxp/macos-golink-wrapper解决mprotect权限问题
注意运⾏gomonkey的时候,你需要加上-gcflags=all=-l
避免函数内联。
最后编辑:admin 更新时间:2024-10-22 02:10