Golang中解决简单问题的最简单方法往往难以测试:过程式编程需要更多测试关注
Golang中解决简单问题的最简单方法往往难以测试:过程式编程需要更多测试关注 我们都在编写代码时使用“分而治之”的方法。实际的“分解”方式可能因任务而异,但在许多情况下,我们只是从需要实现的方法中提取一个“更小”的方法。
例如,在实现某些 gRPC 服务时,这是一种非常典型的方法,我们需要实现某个接口。
那么,它可能看起来像这样:
- 我们有一个需要编写的方法。假设它是
GetByIDs。 - 我们需要使用不同的子系统(数据库、其他服务、队列等)来通过这些 ID 获取实体。
- 目前,我们只能通过 ID 获取单个项目。这只是一个示例,用以表达想法。因此,最清晰的解决方案是引入一个
getByID方法,该方法将被GetByIDs给定的任何 ID 调用。
想象一下我们需要:
- 检查给定 ID 的实体是否存在于 db1 中。如果存在,则转换并返回它。
- 如果实体存在于 db2 中,则像之前一样检索、转换并返回它。
- 如果上述两种方法都未获取到实体,则尝试从另一个服务获取。
需要发送关于成功获取实体的通知。
这可能看起来像这样:
type Struct struct {
db1 repo1.Repo
db2 repo2.Repo
srv srv.Service
mq queue.Queue
}
func (s *Struct) getByID(ctx context.Context, id string) (res *Entity, err error) {
defer func() {
if res == nil {
return
}
if err = s.mq.Send(ctx, constSubject, id, res.Source); err != nil {
err = fmt.Errorf("queue an item got from the source %s: %w", res.Source, err)
}
}()
v1, err := s.db1.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db1: %w", err)
}
if v1 != nil {
return convert1(v1), nil
}
v2, err := s.db1.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db2: %w", err)
}
if v2 != nil {
return convert2(v2), nil
}
v3, err := s.srv.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from service: %w", err)
}
if v3 != nil {
return convertSrv(v3), nil
}
return nil, nil
}
那么 GetByIDs 将如下所示:
func (s *Struct) GetByIDs(ctx context.Context, ids []string) ([]*Entity, error) {
var res []*Entity
for _, id := range ids {
ent, err := s.getByID(ctx, id)
if err != nil {
return fmt.Errorf("get entity by id=%s: %w", id, err)
}
if ent == nil {
logger.Ctx(ctx).Warn().EntID(id).Msg("no entity with such id")
continue
}
res = append(res, ent)
}
return res, nil
}
那么,我们如何测试它呢?我们分解了实现,为什么不也分解测试呢?好的,我们为这些仓库和服务生成/手动编写模拟对象,为 getByID 编写一个好的测试,然后为 GetByIDs 编写一个测试。
结果发现我们无法分解测试。在测试 GetByIDs 时,需要反复测试 getByID。“更小”的 getByID 方法越复杂,这就越令人烦恼。
了解接口。只是不想在这里使用它们,因为这将是毫无实际需要的抽象。
我猜这种简单直接的方法需要一种特殊的测试魔法,我们可以实现一种“字面继承”,即获得一个嵌入了 Struct 的类型,其 getByID 调用将被重定向到替代品。我的意思是类似这样的东西:
//go:testing-redirect Struct.getByID testingStruct.getByID
type testingStruct struct {
Struct
mock *StructMock
}
// this will be called from within testingStruct.GetByIDs
func (s *tesitngStruct) getByID(ctx context.Context, id string) (*Entity, error) {
return s.mock.getByID(ctx, id)
}
你们对此有什么看法?
更多关于Golang中解决简单问题的最简单方法往往难以测试:过程式编程需要更多测试关注的实战教程也可以访问 https://www.itying.com/category-94-b0.html
你在这里把我搞糊涂了。如果你已经为
getByID设置了模拟,为什么不能测试GetByIDs呢?
因为 getByID 只是一个方法,无法被模拟。
(*Struct).GetByIDs调用的是(*Struct).getByID,而不是(*testingStruct).getByID。嵌套的Struct并不知道包装它的类型;这不是真正的继承。Go 对具体类型使用静态函数分派,仅对接口使用虚拟分派。
我知道。这就是为什么我希望有一种特殊的测试魔法来模拟这一点。
如果我需要
GetByIDs提供的功能,我会使用接口,这样我就可以像这样实现它:
仅仅为了测试而引入接口。这正是我想要避免的:有一个直接的解决方案,如果语言也能提供支持来轻松测试它,那就太好了。
现在我们面临两种情况:要么是简单的解决方案 + 过度复杂的测试,要么是毫无必要的复杂解决方案 + 简单的测试。
实际上,我可以在不使用接口的情况下进行“简单”的测试,但这需要两次使用不同构建标签的测试运行,以及一些补充代码。
更多关于Golang中解决简单问题的最简单方法往往难以测试:过程式编程需要更多测试关注的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
结果是我们无法分割测试。在测试
GetByIDs时,需要一遍又一遍地测试getByID。getByID这个“较小”的函数越复杂,这就变得越烦人。
你在这里把我搞糊涂了。如果你已经为 getByID 设置了模拟,为什么不能测试 GetByIDs 呢?
// this will be called from within testingStruct.GetByIDs func (s *tesitngStruct) getByID(ctx context.Context, id string) (*Entity, error) { return s.mock.getByID(ctx, id) }你觉得这个怎么样?
(*Struct).GetByIDs 调用的是 (*Struct).getByID,而不是 (*testingStruct).getByID。嵌套的 Struct 并不知道包装它的类型;这不是真正的继承。Go 对具体类型使用静态函数分派,仅对接口使用虚拟分派。
如果我需要 GetByIDs 提供的功能,我会使用接口,以便可以像这样实现它:
type multiGetByID struct {
gbidFuncs []interface{ getByID(context.Context, string) (*Entity, error) }
}
func (m multiGetByID) GetByIDs(ctx context.Context, ids []string) (es []*Entity, err error) {
es = make([]*Entity, 0, len(ids))
for _, id := range ids {
for _, gbidFunc := range m.gbidFuncs {
e, err := gbidFunc(ctx, id)
if err != nil {
return nil, fmt.Errorf("get entity by id=%s: %w", id, err)
}
if e == nil {
logger.Ctx(ctx).Warn().EntID(id).Msg("no entity with such id")
continue
}
es = append(es, e)
}
}
return
}
在Go中,过程式代码的测试确实存在挑战。你描述的场景很典型:当提取辅助方法时,测试上层方法会重复测试下层逻辑。以下是几种解决方案:
1. 依赖注入模式
将getByID的逻辑提取为可替换的依赖:
type EntityFetcher interface {
GetByID(ctx context.Context, id string) (*Entity, error)
}
type multiSourceFetcher struct {
db1 repo1.Repo
db2 repo2.Repo
srv srv.Service
mq queue.Queue
}
func (f *multiSourceFetcher) GetByID(ctx context.Context, id string) (*Entity, error) {
// 你原来的getByID实现
defer func() {
if res == nil {
return
}
if err = f.mq.Send(ctx, constSubject, id, res.Source); err != nil {
err = fmt.Errorf("queue an item got from the source %s: %w", res.Source, err)
}
}()
v1, err := f.db1.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db1: %w", err)
}
if v1 != nil {
return convert1(v1), nil
}
v2, err := f.db2.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db2: %w", err)
}
if v2 != nil {
return convert2(v2), nil
}
v3, err := f.srv.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from service: %w", err)
}
if v3 != nil {
return convertSrv(v3), nil
}
return nil, nil
}
type Struct struct {
fetcher EntityFetcher
}
func (s *Struct) GetByIDs(ctx context.Context, ids []string) ([]*Entity, error) {
var res []*Entity
for _, id := range ids {
ent, err := s.fetcher.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get entity by id=%s: %w", id, err)
}
if ent == nil {
logger.Ctx(ctx).Warn().EntID(id).Msg("no entity with such id")
continue
}
res = append(res, ent)
}
return res, nil
}
2. 测试时使用嵌入和重写
你提到的嵌入方法可以这样实现:
type Struct struct {
db1 repo1.Repo
db2 repo2.Repo
srv srv.Service
mq queue.Queue
}
// 私有方法,但通过接口暴露给测试
func (s *Struct) getByID(ctx context.Context, id string) (*Entity, error) {
// 原始实现
}
func (s *Struct) GetByIDs(ctx context.Context, ids []string) ([]*Entity, error) {
var res []*Entity
for _, id := range ids {
ent, err := s.getByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get entity by id=%s: %w", id, err)
}
if ent == nil {
logger.Ctx(ctx).Warn().EntID(id).Msg("no entity with such id")
continue
}
res = append(res, ent)
}
return res, nil
}
// 测试专用结构
type testStruct struct {
Struct
mockGetByID func(ctx context.Context, id string) (*Entity, error)
}
func (ts *testStruct) getByID(ctx context.Context, id string) (*Entity, error) {
if ts.mockGetByID != nil {
return ts.mockGetByID(ctx, id)
}
return ts.Struct.getByID(ctx, id)
}
// 测试示例
func TestGetByIDs(t *testing.T) {
ts := &testStruct{
mockGetByID: func(ctx context.Context, id string) (*Entity, error) {
if id == "test1" {
return &Entity{ID: "test1", Source: "db1"}, nil
}
return nil, nil
},
}
entities, err := ts.GetByIDs(context.Background(), []string{"test1", "test2"})
// 测试逻辑
}
3. 使用函数字段
更简单的方法是使用函数字段:
type Struct struct {
db1 repo1.Repo
db2 repo2.Repo
srv srv.Service
mq queue.Queue
// 测试时可替换
getByIDFunc func(ctx context.Context, id string) (*Entity, error)
}
func (s *Struct) getByID(ctx context.Context, id string) (*Entity, error) {
if s.getByIDFunc != nil {
return s.getByIDFunc(ctx, id)
}
// 原始实现
defer func() {
if res == nil {
return
}
if err = s.mq.Send(ctx, constSubject, id, res.Source); err != nil {
err = fmt.Errorf("queue an item got from the source %s: %w", res.Source, err)
}
}()
v1, err := s.db1.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db1: %w", err)
}
if v1 != nil {
return convert1(v1), nil
}
v2, err := s.db2.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db2: %w", err)
}
if v2 != nil {
return convert2(v2), nil
}
v3, err := s.srv.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from service: %w", err)
}
if v3 != nil {
return convertSrv(v3), nil
}
return nil, nil
}
// 测试中
func TestGetByIDs(t *testing.T) {
s := &Struct{
getByIDFunc: func(ctx context.Context, id string) (*Entity, error) {
return &Entity{ID: id}, nil
},
}
result, err := s.GetByIDs(context.Background(), []string{"id1", "id2"})
// 验证结果
}
4. 表驱动测试配合完整集成
如果逻辑不复杂,直接测试完整流程:
func TestGetByIDs_Integration(t *testing.T) {
tests := []struct {
name string
ids []string
mockDB1 func(ctx context.Context, id string) (interface{}, error)
mockDB2 func(ctx context.Context, id string) (interface{}, error)
want []*Entity
wantErr bool
}{
{
name: "single id from db1",
ids: []string{"id1"},
mockDB1: func(ctx context.Context, id string) (interface{}, error) {
return &DB1Entity{ID: id}, nil
},
want: []*Entity{{ID: "id1", Source: "db1"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 设置所有模拟
// 创建Struct实例
// 调用GetByIDs
// 验证结果
})
}
}
这些方法都能解决测试时的重复问题。函数字段方法最简单直接,嵌入方法更符合Go的惯用法,依赖注入则提供了最好的解耦。


