Skip to content

测试

简介

Goyave 与测试框架无关。您可以使用任何测试库,不会受到限制。为了简化测试,Goyave 提供了一些测试实用工具,位于 goyave.dev/goyave/v5/util/testutil 包中。

测试服务器

*testutil.TestServer*goyave.Server 的包装器,提供了用于测试的有用功能。

  • 创建测试请求
  • 创建测试响应
  • 无需启动服务器并在网络端口上监听即可测试端点
  • 测试中间件
  • 默认丢弃日志以使其静默
  • 从项目根目录轻松加载配置
  • 在可以轻松回滚的事务中运行测试

使用 NewTestServer()NewTestServerWithOptions() 创建新的测试服务器。然后,您可以将此服务器用作控制器、中间件和其他组件的根组件。

go
func TestSomething(t *testing.T) {
	server := testutil.NewTestServer(t, "config.test.json")

	// 或

	cfg := config.LoadDefault()
	cfg.Set("app.debug", false)
	opts := goyave.Options{
		Config: cfg,
		//...
	}
	server := testutil.NewTestServerWithOptions(t, opts)

	//...
}

INFO

  • 默认情况下,语言文件从项目根目录加载,通过存在 go.mod 文件来识别。
  • NewTestServer() 中给出的配置路径相对于项目根目录。
  • 使用 NewTestServerWithOptions() 而不在选项中指定配置,将尝试相对于当前包(而不是相对于项目根目录)加载配置文件。
  • 测试服务器是并发不安全的。不要在多个测试中使用单个实例。

事务模式

server.Transaction() 用事务替换服务器的根数据库实例。这样,使用服务器数据库的所有数据库请求将在事务内执行,并且不会产生任何副作用或与并行运行的并发测试冲突。

返回一个 rollback 函数。建议在测试清理中调用它。事务回滚后,原始数据库实例将被恢复。

go
func TestSomething(t *testing.T) {
	server := testutil.NewTestServer(t, "config.test.json")
	rollback := server.Transaction(&sql.TxOptions{})
	t.Cleanup(rollback)

	//...
}

模拟数据库

如果您想使用数据库模拟,例如使用 go-sqlmock,您可以使用 server.ReplaceDB() 强制测试服务器使用自定义数据库拨号器。

首先创建您的模拟,然后使用它创建您选择的 Gorm 拨号器(此处为 Postgres)。

go
import (
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"gorm.io/driver/postgres"

	"goyave.dev/goyave/v5"
	"goyave.dev/goyave/v5/config"
	"goyave.dev/goyave/v5/util/testutil"
)

func TestMockDB(t *testing.T) {
	// 重要!禁用预处理语句以使模拟期望工作
	cfg := config.LoadDefault()
	cfg.Set("database.config.prepareStmt", false)

	server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: cfg})

	mockDB, mock, err := sqlmock.New()
	if err != nil {
		panic(err)
	}
	dialector := postgres.New(postgres.Config{
		DSN:                  "mock_db",
		DriverName:           "postgres",
		Conn:                 mockDB,
		PreferSimpleProtocol: true,
	})

	err = server.ReplaceDB(dialector)

	//...

	mock.ExpectClose()
}

TIP

测试服务器自动在测试清理钩子中关闭数据库。如果您使用 go-sqlmock,这将为意外的 Close 生成错误,除非您在测试的最后添加 mock.ExpectClose()

日志

测试服务器的默认记录器是 slog.DiscardLogger(),它输出到 io.Discard,使日志静默。

您可能出于功能原因或调试目的希望打印测试中的日志。testutil.LogWriterio.Writer 的实现,它将日志重定向到 testing.T.Log() 以获得更好的可读性。

go
func TestSomething(t *testing.T) {
	opts := goyave.Options{
		Logger: slog.New(slog.NewHandler(true, &testutil.LogWriter{T: t})),
	}
	server := testutil.NewTestServerWithOptions(t, opts)
	//...
}

HTTP 测试

您可能希望编写测试来模拟客户端如何通过 HTTP 调用与您的 API 交互。为此,请使用测试服务器的 TestRequest(*http.Request) 方法。它将从路由器的 ServeHTTP() 实现开始执行请求。因此,请求的生命周期将从开始到结束执行。

对于此类测试,建议模拟您的服务。在以下示例中,我们将测试用户的“show”路由,该路由应通过其 ID 返回用户。

go
// http/controller/user/user_test.go
package user

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"

	"goyave.dev/goyave/v5"
	"goyave.dev/goyave/v5/config"
	"goyave.dev/goyave/v5/util/testutil"
	"my-project/dto"
	"my-project/service"
)

type MockService struct{}

func (*MockService) First(_ context.Context, id int64) (*dto.User, error) {
	return &dto.User{
		ID: id,
		Name: "John Doe",
		Email: "johndoe@example.org",
	}, nil
}

func (*MockService) Name() string {
	return service.User
}

func TestShowUser(t *testing.T) {
	server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: config.LoadDefault()})

	server.RegisterService(&MockService{})
	server.RegisterRoutes(func(_ *goyave.Server, r *goyave.Router) {
		r.Controller(&Controller{})
		// 控制器注册 /users/{userID:[0-9+]}
	})

	request := httptest.NewRequest(http.MethodGet, "/users/123", nil)
	response := server.TestRequest(request)
	defer response.Body.Close()

	//...
}

TIP

您可以使用 testutil.ToJSON() 快速编组任何内容,并从结果创建读取器,可用作测试请求的正文。不要忘记在请求中设置 Content-Type: application/json 头。

JSON 响应

为了简化测试 JSON 响应,testutil.ReadJSONBody[T](io.Reader) 帮助您将响应的正文解组为您选择的类型,简洁的一行代码:

go
user, err := testutil.ReadJSONBody[*dto.User](response.Body)

然后,您可以轻松地对返回的 user DTO 进行断言,以检查是否符合预期。如果您愿意,也可以使用映射而不是结构。但由于您的控制器应返回编组的 DTO,您应期望响应正文能正确解组为相同的 DTO 类型。

测试处理程序

您可以通过生成测试 *goyave.Request*goyave.Response 来测试处理程序,而无需模拟整个 HTTP 请求。

go
func TestShowUser(t *testing.T) {
	cfg := config.LoadDefault()
	server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: cfg})

	request := server.NewTestRequest(http.MethodGet, "/users/123", nil)
	request.RouteParams["userID"] = "123"
	response, recorder := server.NewTestResponse(request)

	ctrl := &Controller{}
	ctrl.Init(server.Server)
	ctrl.Show(response, request)

	result := recorder.Result()
	defer result.Body.Close()

	user, err := testutil.ReadJSONBody[*dto.User](result.Body)
}

INFO

如果您不使用测试服务器,可以使用 testutil.NewTestRequest()testutil.NewTestResponse() 生成您的请求和响应。

  • server.NewTestRequest() 自动将请求的 Lang 设置为默认服务器语言。如果您使用 testutil.NewTestRequest(),请求的 Lang不会被设置。
  • *goyave.Response 需要 *goyave.Server 才能工作。testutil.NewTestResponse() 将使用默认配置创建一个临时测试服务器,仅用于此 *goyave.Response 实例。

测试中间件

您可以使用 server.TestMiddleware() 对中间件进行单元测试。此函数执行给定的请求并返回响应。给定的 procedure 回调是传递给被测试中间件的 next 处理程序,可用于进行断言。请记住,如果您的中间件是阻塞的,则不会调用回调。请求将像常规请求一样经历整个生命周期,并且中间件将自动初始化。

go
func TestMiddleware(t *testing.T) {
	server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: config.LoadDefault()})

	request := server.NewTestRequest(http.MethodGet, "/path", nil)
	response := server.TestMiddleware(&CustomMiddleware{}, request, func(response *goyave.Response, _ *goyave.Request) {
		// 中间件通过
		// ...
		response.Status(http.StatusOK)
	})
	defer response.Body.Close()
	//...
}

INFO

请注意,使用 TestMiddleware 时,给定的请求是克隆的。如果中间件更改了请求对象,这些更改不会反映在输入请求上。您可以在 procedure 内部进行断言。

多部分和文件上传

您可能需要测试需要文件上传的请求。最好的方法是使用 Go 的 multipart.Writertestutil.WriteMultipartFile() 使向此类表单添加文件变得更加容易。

go
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("textField", "value")
err := testutil.WriteMultipartFile(writer, &osfs.FS{}, "test_file.txt", "fileField", "test_file.txt")
if err != nil {
	t.Fatal(err)
}
if err := writer.Close(); err != nil {
	t.Fatal(err)
}

request := httptest.NewRequest(http.MethodPost, "/file-upload", body)
// 不要忘记设置 "Content-Type" 头!
request.Header.Set("Content-Type", writer.FormDataContentType())
//...
}

TIP

testutil.WriteMultipartFile() 和所有与文件相关的功能接受文件系统。此处,&osfs.FS{} 表示本地操作系统文件系统。

如果您想将文件与 NewTestRequest() 一起使用,您将必须使用 testutil.CreateTestFiles() 生成 []fsutil.File。此函数将以与真实场景中相同的方式创建文件。

go
request := testutil.NewTestRequest(http.MethodPost, "/file-upload", nil)

files, err := testutil.CreateTestFiles(&osfs.FS{}, "file_1.txt", "file_2.txt")
if err != nil {
	t.Fatal(err)
}

request.Data = map[string]any{
	"files": files,
}

INFO

testutil.CreateTestFiles() 的路径相对于调用者,而不是相对于项目根目录。

工厂

工厂帮助您生成用于测试目的的记录,并轻松将它们保存到数据库中。

工厂使用生成器函数,该函数将创建所需模型的一个随机记录。您可以使用任何假数据生成器库。在此示例中,我们使用 go-faker

生成器函数写在 database/seed 包中。

go
// database/seed/user.go
package seed

import (
	"github.com/go-faker/faker/v4"
	"github.com/go-faker/faker/v4/pkg/options"

	"my-project/database/model"
)

func UserGenerator() *model.User {
	user := &model.User{}
	user.Name = faker.Name()

	user.Email = faker.Email(options.WithGenerateUniqueValues(true))
	return user
}
go
// database/seed/seed.go
func Seed(db *gorm.DB) {
	userFactory := database.NewFactory(UserGenerator)

	 // 生成 10 个用户而不插入它们
	users := userFactory.Generate(10)

	// 生成并插入 10 个用户
	insertedUsers := userFactory.Save(db, 10)

	//...
}

生成器还可以创建关联记录。关联记录应使用其各自的生成器生成。在以下示例中,我们为允许用户撰写博客文章的应用程序生成用户。

go
func UserGenerator() *model.User {
	user := &model.User{}

	// 生成用户字段...

	// 生成 0 到 10 篇博客文章
	user.Posts = database.NewFactory(PostGenerator).Generate(rand.Intn(10))
	return user
}

覆盖

如果需要,可以覆盖生成的部分数据,例如,如果您需要测试具有特定值的函数的行为。给定覆盖结构的所有非零字段将被复制到所有生成的记录中。复制是深度的,意味着所有嵌套字段都将被复制。

go
userOverride := &model.User{
	Name: "Jérémy",
}

userFactory := database.NewFactory(UserGenerator).Override(userOverride)
userFactory.Save(db, 10)
// 所有生成的记录将具有相同的名称:"Jérémy"

事务

testutil.Sessionsession.Session 接口的高级模拟,为服务使用的事务系统提供动力。此实现旨在提供真实、可观察的事务系统,并帮助识别不正确的用法。

  • 使用此实现创建的每个事务都有一个从其父上下文创建的可取消上下文。会话提交或回滚时,上下文被取消。这有助于检测代码尝试使用已终止事务的情况。
  • 事务不能多次提交或回滚。它不能在回滚后提交,反之亦然。
  • 对于嵌套事务,所有子会话必须在父会话结束之前结束(提交或回滚)。此外,Begin() 上给出的上下文应该是父会话的上下文或子上下文。
  • 如果父上下文已完成,则无法创建或提交子会话。
  • 根事务不能提交或回滚。这有助于检测代码使用根会话而不创建子会话的情况。

示例

让我们举一个例子,我们有一个跟踪用户操作(用户历史)的系统,并且我们希望用户创建其帐户时创建一个“register”历史条目。服务方法将如下定义:

go
func (s *Service) Register(ctx context.Context, user *dto.RegisterUser) (*dto.User, error) {
	u := typeutil.Copy(&model.User{}, user)

	err := s.session.Transaction(ctx, func(ctx context.Context) error {
		var err error
		u, err = s.userRepository.Create(ctx, u)
		if err != nil {
			return errors.New(err)
		}

		history := &model.History{
			UserID: u.ID,
			Action: "register",
		}
		_, err = s.historyRepository.Create(ctx, history)
		return errors.New(err)
	})

	return typeutil.MustConvert[*dto.User](u), err
}

这里,我们想检查:

  • 操作是否在事务内运行。
  • 成功时,事务已提交。
  • 出错时,事务已回滚。

在模拟了我们的用户和历史存储库之后,我们可以简单地使用 testutil.Session 来检查创建的事务及其在过程结束时的状态。在此示例中,我们使用 testify 进行断言:

go
import (
	//...
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"goyave.dev/goyave/v5/util/testutil"
)

func TestCreateUser(t *testing.T) {
	// ...设置用户和历史存储库模拟...
	session := testutil.NewTestSession()
	service := NewService(session, userRepoMock, historyRepoMock)

	createDTO := &dto.RegisterUser{/*...*/}
	createdUser, err := service.Create(context.Background(), createDTO)
	require.NoError(t, err)
	expected := &dto.User{/*...*/}
	assert.Equal(t, expected, createdUser)
	txs := session.Children()
	if assert.Len(t, txs, 1) {
		assert.Equal(t, testutil.SessionCommitted, txs[0].Status())
	}
	// ...断言用户和历史在存储库中创建...

	t.Run("error", func(t *testing.T) {
		// ...设置用户和历史存储库模拟...
		session := testutil.NewTestSession()
		service := NewService(session, userRepoMock, historyRepoMock)

		createDTO := &dto.RegisterUser{/*...*/}
		_, err := service.Create(context.Background(), createDTO)
		assert.ErrorIs(t, err, repo.err)

		txs := session.Children()
		if assert.Len(t, txs, 1) {
			assert.Equal(t, testutil.SessionRolledBack, txs[0].Status())
		}
	})
}

提示

当处理嵌套事务时,您可以递归检查 session.Children() 返回的 testutil.Session 中的每个事务:

go
txs := session.Children()
if assert.Len(t, txs, 1) {
	assert.Equal(t, testutil.SessionCommitted, txs[0].Status())

	nested := txs[0].Children()
	//...
}