【问题标题】:How to test if a goroutine has been called while unit testing in Golang?如何在 Golang 中进行单元测试时测试是否调用了 goroutine?
【发布时间】:2018-12-06 12:12:46
【问题描述】:

假设我们有这样一个方法:

func method(intr MyInterface) {
    go intr.exec()
} 

在单元测试method 中,我们想要断言inter.exec 已被调用一次且仅一次;所以我们可以在测试中使用另一个模拟结构来模拟它,这将为我们提供检查它是否被调用的功能:

type mockInterface struct{
    CallCount int
}

func (m *mockInterface) exec() {
    m.CallCount += 1
}

在单元测试中:

func TestMethod(t *testing.T) {
    var mock mockInterface{}
    method(mock)
    if mock.CallCount != 1 {
        t.Errorf("Expected exec to be called only once but it ran %d times", mock.CallCount)
    }
}

现在,问题是因为intr.exec 是用go 关键字调用的,所以我们不能确定当我们在测试中达到我们的断言时,它是否被调用了。

可能的解决方案 1:

intr.exec 的参数添加一个通道可以解决这个问题:我们可以在测试中等待从它接收任何对象,并且在从它接收到一个对象之后,我们可以继续断言它被调用。此通道将在生产(非测试)代码中完全未使用。 这会起作用,但它会给非测试代码增加不必要的复杂性,并且可能使大型代码库难以理解。

可能的解决方案 2:

在断言之前为测试添加一个相对较小的 sleep 可以让我们确保 goroutine 将在 sleep 完成之前被调用:

func TestMethod(t *testing.T) {
    var mock mockInterface{}
    method(mock)

    time.sleep(100 * time.Millisecond)

    if mock.CallCount != 1 {
        t.Errorf("Expected exec to be called only once but it ran %d times", mock.CallCount)
    }
}

这将使非测试代码保持原样。
问题是它会使测试变慢,并且会使它们变得不稳定,因为它们可能会在某些随机情况下中断。

可能的解决方案 3:

像这样创建一个实用函数:

var Go = func(function func()) {
    go function()
} 

并像这样重写method

func method(intr MyInterface) {
    Go(intr.exec())
} 

在测试中,我们可以将Go 更改为:

var Go = func(function func()) {
    function()
} 

所以,当我们运行测试时,intr.exec 将被同步调用,我们可以确定我们的 mock 方法在断言之前被调用。
这个解决方案的唯一问题是它覆盖了 golang 的基本结构,这是不正确的做法。


这些是我能找到的解决方案,但据我所知,没有一个是令人满意的。什么是最好的解决方案?

【问题讨论】:

  • 少量睡眠通常就足够了,不会对测试执行时间产生重大影响。还有runtime.Gosched(),它让给另一个goroutine,根本没有任何睡眠延迟。
  • IMO 在测试中使用睡眠进行同步是一种代码异味,将来经常会失败。创建某种可以在测试中观察到的同步副作用。
  • @JimB 我的代码(我的业务逻辑)不需要任何同步的副作用。我更不想仅仅因为一些测试问题而添加它们。
  • @Adrian 这是个好主意,但Gosched 不允许我选择选择哪个goroutine,对吗?我们正在并行运行我们的测试,我担心这会产生问题
  • 也不睡觉。无论你使用什么,你都无法控制调度器的行为。

标签: unit-testing go goroutine


【解决方案1】:

在模拟中使用sync.WaitGroup

你可以扩展 mockInterface 让它等待另一个 goroutine 完成

type mockInterface struct{
    wg sync.WaitGroup // create a wait group, this will allow you to block later
    CallCount int
}

func (m *mockInterface) exec() {
    m.wg.Done() // record the fact that you've got a call to exec
    m.CallCount += 1
}

func (m *mockInterface) currentCount() int {
    m.wg.Wait() // wait for all the call to happen. This will block until wg.Done() is called.
    return m.CallCount
}

在测试中你可以做到:

mock := &mockInterface{}
mock.wg.Add(1) // set up the fact that you want it to block until Done is called once.

method(mock)

if mock.currentCount() != 1 {  // this line with block
    // trimmed
}

【讨论】:

  • 感谢您的评论,但据我所知,它与我编写的第一个解决方案没有太大区别,而且它们也有相同的缺点,不是吗?跨度>
  • 在您使用同步技术的意义上是相似的。可能我的回答不清楚,但是上面介绍的方法把所有的同步都封装在了mock里面,不会污染生产代码。
  • 这个解决方案有几个问题:1) 如果 goroutine 没有被调用,test 将永远挂起,2) 如果 goroutine 被多次调用,test 会崩溃
  • 3) m.CallCount += 1 不安全,因为如果在多个 goroutine 中调用 exec 方法可能会发生竞争
  • @Zak 你是对的,这比我的第一个解决方案要好
【解决方案2】:

首先我会使用一个模拟生成器,即 github.com/gojuno/minimock 而不是自己编写模拟:

minimock -f example.go -i MyInterface -o my_interface_mock_test.go

那么您的测试可以如下所示(顺便说一句,测试存根也是使用 github.com/hexdigest/gounit 生成的)

func Test_method(t *testing.T) {
    type args struct {
        intr MyInterface
    }
    tests := []struct {
        name string
        args func(t minimock.Tester) args
    }{
        {
            name: "check if exec is called",
            args: func(t minimock.Tester) args {
                return args{
                    intr: NewMyInterfaceMock(t).execMock.Return(),
                }
            },
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mc := minimock.NewController(t)
            defer mc.Wait(time.Second)

            tArgs := tt.args(mc)

            method(tArgs.intr)
        })
    }
}

在这个测试中

defer mc.Wait(time.Second)

等待调用所有模拟方法。

【讨论】:

    【解决方案3】:

    此测试不会像上面提出的 sync.WaitGroup 解决方案那样永远挂起。如果没有调用 mock.exec,它将挂起一秒钟(在此特定示例中):

    package main
    
    import (
        "testing"
        "time"
    )
    
    type mockInterface struct {
        closeCh chan struct{}
    }
    
    func (m *mockInterface) exec() {
        close(closeCh)
    }
    
    func TestMethod(t *testing.T) {
        mock := mockInterface{
            closeCh: make(chan struct{}),
        }
    
        method(mock)
    
        select {
        case <-closeCh:
        case <-time.After(time.Second):
            t.Fatalf("expected call to mock.exec method")
        }
    }
    

    这基本上就是我上面回答中的 mc.Wait(time.Second) 。

    【讨论】:

    • 添加超时不会强制执行 goroutines 被调度,因此对 exce() 的调用会发生。它只会增加它发生的机会。超时总是错误的解决方案,因为针对不同平台、CPU、执行时间等优化超时是不可能的
    • 在这种情况下超时无关紧要,因为这里我们从强制安排 goroutines 的通道读取。
    • 当然可以,但没有什么能迫使对exec 的调用在不到一秒的时间内发生。你只是希望我可能会发生。从逻辑上讲,没有办法假设它会。
    • 在我给出的答案中,以及你在这里描述的场景;就我而言,这是一个编程错误。但行为是明确定义和确定的。在您的情况下,应该归咎于调度程序,并且行为是未定义且不确定的。任君挑选!
    • 实际上,你会发生什么回答:你将永远等待失败的测试。是的,这是一个编程错误,但你必须自己找到它,因为 go test 不会有明确的输出。在我的情况下,实际的 1 秒截止日期仅适用于 FAILED 测试,在测试达到此截止日期后,go test 命令将有明确的输出。在实践中,我从未遇到过这种方法的任何问题,因为 1 秒就足够了。但是,是的,理论上它不是确定性的。所以继续理论化。
    猜你喜欢
    • 2017-04-14
    • 2015-08-21
    • 2012-01-08
    • 2021-12-30
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多