Golang中解决简单问题的最简单方法往往难以测试:过程式编程需要更多测试关注

Golang中解决简单问题的最简单方法往往难以测试:过程式编程需要更多测试关注 我们都在编写代码时使用“分而治之”的方法。实际的“分解”方式可能因任务而异,但在许多情况下,我们只是从需要实现的方法中提取一个“更小”的方法。

例如,在实现某些 gRPC 服务时,这是一种非常典型的方法,我们需要实现某个接口。

那么,它可能看起来像这样:

  1. 我们有一个需要编写的方法。假设它是 GetByIDs
  2. 我们需要使用不同的子系统(数据库、其他服务、队列等)来通过这些 ID 获取实体。
  3. 目前,我们只能通过 ID 获取单个项目。这只是一个示例,用以表达想法。因此,最清晰的解决方案是引入一个 getByID 方法,该方法将被 GetByIDs 给定的任何 ID 调用。

想象一下我们需要:

  1. 检查给定 ID 的实体是否存在于 db1 中。如果存在,则转换并返回它。
  2. 如果实体存在于 db2 中,则像之前一样检索、转换并返回它。
  3. 如果上述两种方法都未获取到实体,则尝试从另一个服务获取。

需要发送关于成功获取实体的通知。

这可能看起来像这样:

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

4 回复

你在这里把我搞糊涂了。如果你已经为 getByID 设置了模拟,为什么不能测试 GetByIDs 呢?

因为 getByID 只是一个方法,无法被模拟。

(*Struct).GetByIDs 调用的是 (*Struct).getByID,而不是 (*testingStruct).getByID。嵌套的 Struct 并不知道包装它的类型;这不是真正的继承。Go 对具体类型使用静态函数分派,仅对接口使用虚拟分派。

我知道。这就是为什么我希望有一种特殊的测试魔法来模拟这一点。

如果我需要 GetByIDs 提供的功能,我会使用接口,这样我就可以像这样实现它:

仅仅为了测试而引入接口。这正是我想要避免的:有一个直接的解决方案,如果语言也能提供支持来轻松测试它,那就太好了。

现在我们面临两种情况:要么是简单的解决方案 + 过度复杂的测试,要么是毫无必要的复杂解决方案 + 简单的测试。

实际上,我可以在不使用接口的情况下进行“简单”的测试,但这需要两次使用不同构建标签的测试运行,以及一些补充代码。

更多关于Golang中解决简单问题的最简单方法往往难以测试:过程式编程需要更多测试关注的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


结果是我们无法分割测试。在测试 GetByIDs 时,需要一遍又一遍地测试 getByIDgetByID 这个“较小”的函数越复杂,这就变得越烦人。

你在这里把我搞糊涂了。如果你已经为 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
}

我不太确定我是否理解了这个问题:根据你最初描述“分而治之”的帖子,我的印象是 GetByIDs 曾经是一个单一的函数,而你将其重构为 GetByIDsgetByID。如果这是真的,那么我不明白你为什么要测试 getByID 本身,因为它是 GetByIDs 的一个实现细节。

你当前的实现是循环遍历 ID,然后在概念上“循环”遍历底层的数据库/服务/队列等,并且是串行执行的。假设你进行重构,本质上翻转循环:不再遍历 ID 并尝试用每个底层实现获取该 ID,而是遍历实现,并在每个实现中传入所有 ID(也许这样更快,因为其中一些实现支持某种 IN.contains() 操作符)。现在,即使 GetByIDs 的结果应该相同,你也必须更改测试,因为你不再有 getByID 函数了;而是 getByIDs,并且它接收与 GetByIDs 相同的 ids []string 参数。

引入仅用于测试的接口。这正是我希望避免的:有一个直接的解决方案,如果语言也能提供对轻松测试它的支持,那就太好了。

现在我们要么是简单的解决方案 + 过度复杂的测试,要么是不必要的复杂解决方案加上简单的测试。

听起来,你不想使用接口(Go 中一个现有的、被广泛使用和理解的概念,而且似乎能解决你的问题),而是想要一个新的语言特性来实现继承(但也许只是为了测试?)。我理解得对吗?如果是这样,我认为这个功能请求极不可能被实现。

我要说明的是,我只是另一个 Go 用户,与官方的 Go 语言团队没有任何关系,所以我的意见无足轻重。但我建议,如果你希望这个功能被实现并添加到 Go 语言规范中,你需要提出一个更具体的理由来说明为什么这个功能是必要的。我认为他们不会接受“接口太复杂”这个理由。

在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的惯用法,依赖注入则提供了最好的解耦。

回到顶部