Golang依赖注入与如何实现100%测试覆盖率

Golang依赖注入与如何实现100%测试覆盖率 大家好,有一个关于依赖注入和100%测试覆盖率的问题。

假设你有一个函数,它本身调用了某些外部包中的函数,而你绝对不希望在测试中调用这些函数。为了方便起见,我们称它们为“邪恶”函数。 这个外部包没有为这些“邪恶”函数提供任何结构体或接口。 因此,你必须创建自己的接口,其方法作为这些“邪恶”函数的包装器。 然后,你构建这个接口的两个实现:一个用于正常构建,它实际上包装了那些“邪恶”函数;另一个是用于测试的模拟版本,它不会造成任何危害。

到目前为止,这似乎是常规做法(至少根据我目前所读到的内容), 但是测试覆盖率呢?你可以在使用该接口的方法上实现100%的测试覆盖率, 但你无法在用于实际构建的接口实现上达到100%,因为它们调用了那些该死的“邪恶”函数。你根本无法测试那些函数。遗憾的是,我读过的所有博客文章都没有涉及这个问题。

你们是如何处理这类情况的?


更多关于Golang依赖注入与如何实现100%测试覆盖率的实战教程也可以访问 https://www.itying.com/category-94-b0.html

6 回复

嘿,Stereodude!

我也曾为同样的问题而困扰。我很好奇,是什么让这些函数变得“邪恶”?在我的案例中,这些“邪恶”函数连接的是没有“沙盒”环境的第三方服务,因此除非是在生产构建中,否则调用它们是不好的。

我最终尽我所能地按照你上面描述的方法包装了这些函数,但到某个阶段,我无法再进一步包装和测试了。我试图让未测试的代码尽可能少,但最终我不得不停下来,留下一些未经测试的代码。

我不确定是否有办法解决这个问题,或者这只是这些“邪恶”函数固有的问题。

更多关于Golang依赖注入与如何实现100%测试覆盖率的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


它们并非真的邪恶,只是在您提到的这类单元测试中不受欢迎。我用这个术语来区分可测试和不可测试的函数,但这可能有点误导人 😅。

在我的具体案例中,这些是来自 chromedp 包的函数,它们会启动 Chrome 进程,而我不希望在单元测试中发生这种情况。

我想到的一个可能但非常不优雅的解决方案是,将包装器实现放到一个单独的包中,并在包模式下使用 go test,同时列出除该包之外的所有其他包。但这并不太方便。理想情况下,我希望 go test ./... 能覆盖我 100% 的代码。

哈哈,我明白了 😄

嗯,是的,这看起来像是一个变通方案。我研究了一下如何跳过那些你不想运行的测试,发现这篇 Stack Overflow 帖子似乎很有帮助。

stackoverflow.com

看起来你可以结合使用环境变量和 testing.Skip() 函数来跳过这些测试,或者使用 testing -short 标志。

这样一来,你仍然可以运行 go test ./...,它会忽略你不想运行的测试,除非你特意更改了环境变量或提供了 -short 标志。

根据这篇博客所述,如果你正在测试一个包,那么跨包的内容本身不会被包含在覆盖率统计中。

另一个可以考虑的方向(如果你还没试过的话)是构建模式。例如“plugin”模式。

另一个方面是你如何实现依赖注入。如果你能解释一下你是如何实现依赖注入的,那么对其进行调整或许能找到一个有效的解决方案。例如:我可以动态生成我的“import”语句(编译时依赖注入)。我认为,无法被导入的内容自然也就无法被计入覆盖率。

只是分享一下研究过的选项。如果由于我对你的问题缺乏更深入的理解而导致这些都不适用,我表示歉意,但我很好奇!

感谢你的意见 @connerj70@ArjunDhar! 抱歉我之前没有包含任何示例代码,因为我觉得描述这个问题可能有点冗长,但让我试试看(或多或少是伪代码):

假设你想测试 someMethod()

type SomeStruct struct {}

func (s *SomeStruct) SomeMethod() {
    doSomeThings()
    someimport.DoThingsYouDontWantInTests()
    doSomeOtherThings()
}

为了让 someimport.DoThingsYouDontWantInTests() 在测试中不被执行,你创建了一个接口和一个包装这些函数的实现:

type Wrapper interface {
    DoThingsYouDontWantInTests()
}

type ImplementedWrapper struct {}

func (w *ImplementedWrapper) DoThingsYouDontWantInTests() {
    someimport.DoThingsYouDontWantInTests()
}

type SomeStruct struct {
    w Wrapper
}

func (s *SomeStruct) SomeMethod() {
    doSomeThings()
    w.DoThingsYouDontWantInTests()
    doSomeOtherThings()
}

这样在测试中,你可以对 SomeStruct.w 进行依赖注入:

type MockedWrapper struct {}

func (w *MockedWrapper) DoThingsYouDontWantInTests() {
    fmt.Println("I don't do anything")
}

func TestSomeStruct(t *testing.T) {
    s := SomeStruct{w: &MockedWrapper{}}
    s.SomeMethod()
    // 在这里进行断言
}

当然,实际的模拟会使用某种模拟工具,所以会有点不同。 问题在于 func (w *ImplementedWrapper) DoThingsYouDontWantInTests() 无法被测试,导致这部分代码未被覆盖。我希望这能稍微说明我的意思。

@connerj70 你的意思是我可以编写一些测试,这些测试总是会被跳过,但会算入覆盖率吗? @ArjunDhar 很有趣的阅读!你介意详细解释一下如何实现编译时导入吗?你是为此使用构建标签吗?

与此同时,我发现 go test -cover 不会计算不包含任何测试文件的包的覆盖率。所以,测试了一堆包,而留下一个包(仅包含像 func (w *ImplementedWrapper) DoThingsYouDontWantInTests() 这样的包装函数)没有测试文件,不会对代码覆盖率产生负面影响。这实际上就是正确的做法吗?

在Go中实现依赖注入并追求100%测试覆盖率时,确实会遇到外部依赖难以完全覆盖的情况。以下是具体解决方案和示例代码:

1. 定义接口和实现

首先创建接口来抽象外部依赖,并提供生产实现和测试模拟实现。

// 定义接口
type EvilService interface {
    EvilFunction() error
}

// 生产实现(包装"邪恶"函数)
type RealEvilService struct{}

func (s *RealEvilService) EvilFunction() error {
    // 调用外部包的实际函数
    return external.EvilFunction()
}

// 测试模拟实现
type MockEvilService struct{}

func (m *MockEvilService) EvilFunction() error {
    // 模拟行为,不调用实际函数
    return nil
}

2. 业务逻辑使用依赖注入

type MyService struct {
    evilService EvilService
}

func NewMyService(es EvilService) *MyService {
    return &MyService{evilService: es}
}

func (s *MyService) Process() error {
    // 使用注入的依赖
    return s.evilService.EvilFunction()
}

3. 测试业务逻辑(达到100%覆盖率)

func TestMyService_Process(t *testing.T) {
    mockService := &MockEvilService{}
    service := NewMyService(mockService)
    
    err := service.Process()
    
    if err != nil {
        t.Errorf("expected no error, got %v", err)
    }
}

4. 处理生产实现的测试覆盖

对于生产实现(RealEvilService),可以采用以下策略:

方案A:使用构建标签隔离测试

// real_evil_service.go
// +build !test

package mypackage

type RealEvilService struct{}

func (s *RealEvilService) EvilFunction() error {
    return external.EvilFunction()
}
// real_evil_service_test.go
// +build test

package mypackage

type RealEvilService struct{}

func (s *RealEvilService) EvilFunction() error {
    // 测试版本,返回可控结果
    return nil
}

方案B:使用接口适配器测试核心逻辑

// 测试适配器实现
func TestRealEvilService_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过集成测试")
    }
    
    service := &RealEvilService{}
    err := service.EvilFunction()
    
    // 验证函数签名和行为,不验证具体实现
    _ = err // 根据实际情况添加断言
}

方案C:使用可配置的包装器

type ConfigurableEvilService struct {
    CallRealFunction bool
}

func (s *ConfigurableEvilService) EvilFunction() error {
    if s.CallRealFunction {
        return external.EvilFunction()
    }
    return nil
}

// 测试中
func TestConfigurableEvilService(t *testing.T) {
    // 测试模拟模式
    service := &ConfigurableEvilService{CallRealFunction: false}
    err := service.EvilFunction()
    // 断言模拟行为
    
    // 生产代码使用true
    prodService := &ConfigurableEvilService{CallRealFunction: true}
    _ = prodService
}

5. 覆盖率报告处理

在生成覆盖率报告时,可以通过以下方式排除特定文件:

# 排除生产实现文件
go test -coverprofile=coverage.out ./... -coverpkg=./...
go tool cover -html=coverage.out -o coverage.html

或者在代码中指定排除:

// real_evil_service.go 文件开头添加
// Code generated by go generate; DO NOT EDIT.
// This file is excluded from test coverage.

6. 实际项目中的实践

// 使用wire进行依赖注入
var ProviderSet = wire.NewSet(
    wire.Struct(new(RealEvilService), "*"),
    wire.Bind(new(EvilService), new(*RealEvilService)),
    wire.Struct(new(MyService), "*"),
)

// 测试中使用
func TestSuite(t *testing.T) {
    suite.Run(t, new(MyServiceTestSuite))
}

type MyServiceTestSuite struct {
    suite.Suite
    mockEvilService *MockEvilService
    service         *MyService
}

func (s *MyServiceTestSuite) SetupTest() {
    s.mockEvilService = &MockEvilService{}
    s.service = NewMyService(s.mockEvilService)
}

func (s *MyServiceTestSuite) TestProcess() {
    err := s.service.Process()
    s.NoError(err)
}

关键点总结:

  1. 接口隔离:通过接口将外部依赖抽象化
  2. 依赖注入:通过构造函数或方法注入依赖
  3. 测试覆盖:业务逻辑可达100%,生产适配器通过构建标签或配置控制
  4. 务实态度:100%覆盖率是理想目标,但某些外部依赖包装器可合理排除

这种方法既保持了代码的可测试性,又对难以测试的外部依赖进行了合理处理。覆盖率工具可以配置为排除这些特定的包装器实现,专注于核心业务逻辑的覆盖。

回到顶部