在当今⼤型的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库,如果有需要,你也可以快速评估⼀下,看看能否应⽤到你的单元测试中,为你的单元测试提供便利:

容器化服务

虽然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上的⼀种配置。

  1. ⾸先在MacOS安装的Go架构版本是Amd64, ⽽不是arm64。安装 Amd64de1Go版本可以正常运⾏。
  2. 使⽤eisenxp/macos-golink-wrapper解决mprotect权限问题

注意运⾏gomonkey的时候,你需要加上-gcflags=all=-l 避免函数内联。

作者:admin  创建时间:2024-10-22 01:56
最后编辑:admin  更新时间:2024-10-22 02:10