今天带来一篇让你在虎年如虎添翼的Go测试工具和技巧的文章分享。大体内容翻译于 Go (Golang): Testing tools & tips to step up your game[1]。文中作者介绍了不少好用的Go库和工具。作者在也已经将这篇文章中提到的所有工具和窍门投入了个人项目中,更多细节可以查看这里:https://github.com/rafael-piovesan/go-rocket-ride。大家也可以在自己的项目中尝试运用。
作者在文章落笔之时,第一个想到的工具是Testcontainers-Go(https://golang.testcontainers.org/)。下面Testcontainers的介绍。
https://github.com/testcontainers/testcontainers-go 是一个Go包,它使得创建和清理基于容器的依赖关系变得简单,主要用于自动化集成/冒烟测试。清晰和易于使用的API使开发者能够以编程方式定义应该作为测试的一部分运行的容器,并在测试完成后清理这些资源。这意味着你可以在你的应用程序的集成和冒烟测试最需要的时候和地方,以某种无缝的方式,不依赖外部资源,以编程方式创建、互动和处置容器。在实践中,不再需要保留和维护各种docker-compose文件、复杂的shell脚本和其他东西。你可以根据你的需要生成许多容器,每一个都是为了一个特定的场景,只要它们对运行你的测试有必要,就可以把它们保留下来。
下面是我如何建立一个Postgres容器并用于我的集成测试(更多细节见https://github.com/rafael-piovesan/go-rocket-ride)。
// NewPostgresContainer creates a Postgres container and returns its DSN to be used
// in tests along with a termination callback to stop the container.
func NewPostgresContainer() (string, func(context.Context) error, error) {
ctx := context.Background()
usr := "postgres"
pass := "postgres"
db := "testdb"
templateURL := "postgres://%s:%s@localhost:%s/%s?sslmode=disable"
// Create the container
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
Started: true,
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:14.1",
ExposedPorts: []string{
"0:5432",
},
Env: map[string]string{
"POSTGRES_DB": db,
"POSTGRES_USER": usr,
"POSTGRES_PASSWORD": pass,
"POSTGRES_SSL_MODE": "disable",
},
Cmd: []string{
"postgres", "-c", "fsync=off",
},
WaitingFor: wait.ForSQL(
"5432/tcp",
"postgres",
func(p nat.Port) string {
return fmt.Sprintf(templateURL, usr, pass, p.Port(), db)
},
).Timeout(time.Second * 15),
},
})
if err != nil {
return "", func(context.Context) error { return nil }, err
}
// Find ports assigned to the new container
ports, err := c.Ports(ctx)
if err != nil {
return "", func(context.Context) error { return nil }, err
}
driverURL := fmt.Sprintf(templateURL, usr, pass, ports["5432/tcp"][0].HostPort, db)
return driverURL, c.Terminate, nil
}
// Using it your tests
ctx := context.Background()
dsn, terminate, err := testcontainer.NewPostgresContainer()
if err != nil {
log.Fatalf("cannot create database container: %v", err)
}
defer terminate(ctx)
sqldb, err := sql.Open("pg", dsn)
if err != nil {
log.Fatalf("cannot open database: %v", err)
}
上面这段代码就是创建Postgres测试容器的实用代码
如果你觉得这个很有趣,那么也可以看看这两个其他项目。
Dockertest (https://github.com/ory/dockertest),旨在提供与Testcontainers-Go所提供的相同的易于使用、更广泛和通用的API。
Gnomock (https://github.com/orlangure/gnomock), 无需使用临时 Docker 容器编写模拟即可测试您的代码 ,被很多项目所普遍使用。
Testify(https://github.com/stretchr/testify)已经存在了很长时间,它几乎不需要任何介绍。但是,由于它是一个提高测试质量的有用工具和技巧的列表,它不能被遗漏。最受欢迎的功能包括简单的断言和创建模拟对象的机制,也可用于检查对它们的期望。
func TestSomething(t *testing.T) {
// assert equality
assert.Equal(t, 123, 123, "they should be equal")
// assert inequality
assert.NotEqual(t, 123, 456, "they should not be equal")
// assert for nil (good for errors)
assert.Nil(t, object)
// assert for not nil (good when you expect something)
if assert.NotNil(t, object) {
// now we know that object isn't nil, we are safe to make
// further assertions without causing any errors
assert.Equal(t, "Something", object.Value)
}
}
断言的例子来自:https://github.com/stretchr/testify
// MyMockedObject is a mocked object that implements an interface
// that describes an object that the code I am testing relies on.
type MyMockedObject struct{
mock.Mock
}
// DoSomething is a method on MyMockedObject that implements some interface
// and just records the activity, and returns what the Mock object tells it to.
//
// In the real object, this method would do something useful, but since this
// is a mocked object - we're just going to stub it out.
//
// NOTE: This method is not being tested here, code that uses this object is.
func (m *MyMockedObject) DoSomething(number int) (bool, error) {
args := m.Called(number)
return args.Bool(0), args.Error(1)
}
// TestSomething is an example of how to use our test object to
// make assertions about some target code we are testing.
func TestSomething(t *testing.T) {
// create an instance of our test object
testObj := new(MyMockedObject)
// setup expectations
testObj.On("DoSomething", 123).Return(true, nil)
// call the code we are testing
targetFuncThatDoesSomethingWithObj(testObj)
// assert that the expectations were met
testObj.AssertExpectations(t)
}
模拟例子来自:https://github.com/stretchr/testify
Mockery (https://github.com/vektra/mockery) 是一个补充工具(Golang 的模拟代码自动生成器),可以和Testify的Mock包一起使用,利用它的功能来创建模拟对象,并为项目源代码中的Golang接口自动生成模拟对象。它有助于减少很多模板式的编码任务。
// the interface declaration found in your project's source code
// Stringer接口声明
type Stringer interface {
String() string
}
// the generated mock file based on the interface
import "github.com/stretchr/testify/mock"
type Stringer struct {
mock.Mock
}
func (m *Stringer) String() string {
ret := m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
模拟对象的例子来自:https://github.com/vektra/mockery
Gofakeit(https://github.com/brianvoe/gofakeit)是一个随机数据生成器。目前,它提供了160多个函数,涵盖少数不同的主题/类别,如Person、 Animals、 Address、 Games、 Cars、 Beers等等。这里的关键词是随机。随机性是计划和编写测试时需要考虑的一个重要方面。与其在测试用例中使用常量值,不如使用随机生成的值,这样我们会更有信心,即使面对未知的(随机的)输入,我们的代码仍然会以我们期望的方式表现出来。
import "github.com/brianvoe/gofakeit/v6"
gofakeit.Name() // Markus Moen
gofakeit.Email() // [email protected]
gofakeit.Phone() // (570)245-7485
gofakeit.BS() // front-end
gofakeit.BeerName() // Duvel
gofakeit.Color() // MediumOrchid
gofakeit.Company() // Moen, Pagac and Wuckert
gofakeit.CreditCardNumber() // 4287271570245748
gofakeit.HackerPhrase() // Connecting the array won't do anything, we need to generate the haptic COM driver!
gofakeit.JobTitle() // Director
gofakeit.CurrencyShort() // USD
// randomly pick an item from a predefined list
numberList := []int{1, 36, 41, 99}
idx := gofakeit.Number(0, len(numberList)-1)
randomItem := numberList[idx]
例子来自:https://github.com/brianvoe/gofakeit
Gock(https://github.com/h2non/gock)是一个用于Golang的HTTP服务器模拟和期望库,它在很大程度上受到了NodeJs的流行和较早的同类库的启发,称为Nock[2]。与Pact-go(https://github.com/pact-foundation/pact-go)不同,它是一个轻量级的解决方案,通过http.DefaultTranspor
t或任何http.Client
使用的http.Transport
拦截出站请求。
func TestSimple(t *testing.T) {
defer gock.Off()
gock.New("http://foo.com").
Get("/bar").
Reply(200).
JSON(map[string]string{"foo": "bar"})
res, err := http.Get("http://foo.com/bar")
assert.Nil(t, err)
assert.Equal(t, 200, res.StatusCode)
body, _ := ioutil.ReadAll(res.Body)
assert.Equal(t, `{"foo":"bar"}`, string(body)[:13])
// Verify that we don't have pending mocks
assert.True(t, gock.IsDone())
}
如何使用Gock的例子
Testfixtures (https://github.com/go-testfixtures/testfixtures) [模仿 "Ruby on Rails方式 "为数据库应用程序编写测试](https://guides.rubyonrails.org/testing.html#the-test-database "模仿 "Ruby on Rails方式 "为数据库应用程序编写测试"),其中样本数据保存在fixtures文件中。在执行每个测试之前,测试数据库都会被清理并将fixture数据加载到数据库中。以下是我在我的项目中使用的一个例子(更多细节请看https://github.com/rafael-piovesan/go-rocket-ride)。
// Load loads database test fixtures specified by the "data" param, a list of files and/or directories (paths
// should be relative to the project's root dir).
//
// It takes as input a Postgres DSN, a list of files/directories leading to the *.yaml files and one more
// optional param, which accepts a map with data to be used while parsing files looking for template
// placeholders to be replaced.
func Load(dsn string, data []string, tplData map[string]interface{}) error {
if len(data) == 0 {
return errors.New("list of fixtures files/directories is empty")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
return err
}
// find out the absolute path to this file
// it'll be used to determine the project's root path
_, callerPath, _, _ := runtime.Caller(0) // nolint:dogsled
// look for migrations source starting from project's root dir
rootPath := fmt.Sprintf(
"%s/../..",
filepath.ToSlash(filepath.Dir(callerPath)),
)
// assemble a list of fixtures paths to be loaded
for i := range data {
data[i] = fmt.Sprintf("%v/%v", rootPath, filepath.ToSlash(data[i]))
}
fixtures, err := testfixtures.New(
testfixtures.Database(db),
testfixtures.Dialect("postgres"),
testfixtures.Template(),
testfixtures.TemplateData(tplData),
// Paths must come after Template() and TemplateData()
testfixtures.Paths(data...),
)
if err != nil {
return err
}
// load fixtures into DB
return fixtures.Load()
}
将测试fixture加载到Postgres实例的实用程序代码
此外,当它与Testcontainers-Go以及其他迁移工具结合时,它会是一个很好的匹配,因为你可以在你的测试中以编程方式协调这些工具,就像下面这样。
- id: {{$.UserId}}
email: {{$.UserEmail}}
stripe_customer_id: zaZPe9XIf8Pq5NK
测试fixtures样本文件 https://gist.github.com/rafael-piovesan/2a06fca2805641d291c1f38323ef68f1#file-users-yaml
ctx := context.Background()
// create database container
dsn, terminate, err := testcontainer.NewPostgresContainer()
require.NoError(t, err)
defer terminate(ctx)
// migrations up
err = migrate.Up(dsn, "db/migrations")
require.NoError(t, err)
// load test fixtures
userID := int64(gofakeit.Number(0, 1000))
idemKey := gofakeit.UUID()
err = testfixtures.Load(dsn, []string{"db/fixtures/users"}, map[string]interface{}{
"UserId": userID,
"UserEmail": gofakeit.Email(),
})
require.NoError(t, err)
把所有东西放在一起 https://gist.githubusercontent.com/rafael-piovesan/2a06fca2805641d291c1f38323ef68f1/raw/2b481bb070ddfc6cb80d6172a0d057a36f7ca831/db_test.go
你有没有想过如何测试你的应用程序的主功能呢?假设您想检查代码在缺少任何环境变量的情况下的行为。假设你至少有一个简单的日志来告诉我们发生了什么,可能的方法是实际检查应用程序的输出并查找该日志。如下所示。(请参阅更多详细信息 https://github.com/rafael-piovesan/go-rocket-ride)
// main.go
package main
import (
"log"
rocketride "github.com/rafael-piovesan/go-rocket-ride"
)
func main() {
// read config values from env vars
cfg, err := rocketride.LoadConfig(".")
if err != nil {
log.Fatalf("cannot load config: %v", err)
}
// continue to start your app ...
}
// your tests
func TestMain(t *testing.T) {
if os.Getenv("START_MAIN") == "1" {
main()
return
}
t.Run("Missing env vars", func(t *testing.T) {
stdout, stderr, err := startSubprocess(t)
if e, ok := err.(*exec.ExitError); ok && !e.Success() {
assert.Empty(t, stdout)
assert.Contains(t, stderr, "cannot load config")
return
}
t.Fatalf("process ran with err %v, want exit status 1", err)
})
}
// startSubprocess calls "go test" command specifying the test target name "TestMain" and setting
// the env var "START_MAIN=1". It will cause the test to be run again, but this time calling the
// "main()" func. This way, it's possible to retrieve and inspect the app exit code along with
// the stdout and stderr as well.
// See more at: https://stackoverflow.com/a/33404435
func startSubprocess(t *testing.T, envs ...string) (stdout string, stderr string, err error) {
var cout, cerr bytes.Buffer
// call test suit again specifying "TestMain" as the target
cmd := exec.Command(os.Args[0], "-test.run=TestMain")
// set "START_MAIN" env var along with any additional value provided as parameter
envs = append(envs, "START_MAIN=1")
cmd.Env = append(os.Environ(), envs...)
// capture subprocess' stdout and stderr
cmd.Stdout = &cout
cmd.Stderr = &cerr
// run the test again
err = cmd.Run()
stdout = cout.String()
stderr = cerr.String()
return
}
这个想法是按类型(即单元、集成、冒烟和端到端)或任何其他你认为合适的标准来分离测试,因为我们使用Go的内置机制,称为Build Contraints[3],也就是众所周知的Build Tags。关于构建约束最近在Go1.17的改动也可以看站长写的Go1.17 新特性:新版构建约束。
//go:build unit || usecase
// +build unit usecase
package usecase
import "testing"
func TestUseCase(t *testing.T) {
// your test code
}
然后,要选择你想运行的测试,只需调用go测试命令,指定-tags即可。
# 运行所有单元测试
go test -tags=unit ./...
# 只运行与用例相关的测试
go test -tags=usecase ./...
https://github.com/rafael-piovesan/go-rocket-ride
https://go.dev/doc/code#Testing
https://bmuschko.com/blog/go-testing-frameworks/
https://github.com/orlangure/gnomock
https://github.com/ory/dockertest
https://github.com/testcontainers/testcontainers-go
https://github.com/stretchr/testify
https://github.com/vektra/mockery
https://github.com/brianvoe/gofakeit
https://github.com/h2non/gock
https://mickey.dev/posts/go-build-tags-testing/
[1]
Go (Golang): Testing tools & tips to step up your game: https://blog.devgenius.io/go-golang-testing-tools-tips-to-step-up-your-game-4ed165a5b3b5
[2]Nock: https://github.com/nock/nock
[3]Build Contraints: https://pkg.go.dev/cmd/go#hdr-Build_constraints
本文转载自公众号「Go 招聘」欢迎点击下方名片关注。