如何在Golang单元测试中测试goroutine是否已被调用?

30
假设我们有一个以下的方法:

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

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

在单元测试中,我们希望断言method已经被调用一次且仅一次;因此,在测试中,我们可以使用另一个模拟结构(mock struct)来模拟它,这将为我们提供检查它是否被调用的功能:
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)
    }
}

现在的问题是,由于使用了go关键字调用了intr.exec,我们无法确定当我们在测试中达到断言时,它是否已被调用。

可能的解决方案1:

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

可能的解决方案2:

在测试中在断言之前添加相对较小的延迟可能会让我们有一些保证,在延迟结束之前goroutine将被调用:

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将被同步调用,我们可以确保在断言之前调用我们的模拟方法。
这种解决方案唯一的问题是它覆盖了golang的基本结构,这不是正确的做法。
这些是我能找到的解决方案,但在我看来都不令人满意。哪个是最好的解决方案?

短暂的休眠通常足够,不应对测试执行时间产生重大影响。还有runtime.Gosched(),它可以在没有任何睡眠延迟的情况下让出给另一个goroutine。 - Adrian
1
在测试中使用sleep进行同步是一种代码异味,未来往往会失败。创建某种同步副作用,以便您可以在测试中观察到它。 - JimB
@JimB 我的代码(我的业务逻辑)不需要任何同步副作用。我更喜欢不仅仅因为一些测试问题而添加它们。 - sazary
@Adrian,那是个好主意,但是Gosched不能让我选择哪个goroutine被选中,我是对的吧?我们正在并行地运行测试,我担心这会带来问题。 - sazary
1
睡眠也不能解决问题。无论你使用什么,都无法控制调度程序的行为。 - Adrian
显示剩余2条评论
3个回答

26

在模拟中使用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
}

2
它在使用同步技术方面类似。也许我的回答中有些不清楚的地方,但是上述描述的方法将所有同步封装在模拟对象内部,不会污染生产代码。 - Zak
这个解决方案存在几个问题:1)如果未调用goroutine,则测试将永远挂起,2)如果已经调用了goroutine超过一次,则测试将会出现panic。 - Maxim
3)m.CallCount += 1 不安全,因为如果在多个 goroutine 中调用 exec 方法就可能会出现竞争。 - Maxim
@Zak,你说得对,这比我的第一个解决方案更好。 - sazary
@Maxim,你的观点是正确的,我认为Zak已经为了简洁而删除了它们。 - sazary
显示剩余2条评论

4

与上面提出的使用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)。

添加超时时间不会强制执行goroutine的调度,因此也不会发生对exce()的调用。它只是增加了发生这种情况的机会。超时始终是错误的解决方案,因为优化不同平台、CPU、执行时间等的超时时间是不可能的。 - Zak
是的,你希望上面的例子测试能够成功。从逻辑上讲,我们无法假设wg.Wait会返回,对吧?在这里合理的超时时间只是为了防止测试永远挂起。在我的情况下,它将挂起一秒钟,失败并继续执行剩余的测试。 - Maxim
1
在我之前回答中,并且在你这里描述的场景中;在我的情况下,这是一个编程错误。但是行为是明确定义和确定性的。在你的情况下,是调度程序有问题,而且行为是未定义和不确定性的。你自己选择吧! - Zak
1
实际上,你的回答会发生什么事情:你会被卡在等待失败测试的过程中。是的,这是一个编程错误,但是你必须自己找到它,因为 go test 无法提供清晰的输出。在我的情况下,有一个实际的 1 秒截止时间,只适用于失败的测试,在测试达到此截止时间后,go test 命令将提供清晰的输出。在实践中,我从未遇到过任何问题,因为 1 秒已经足够了。但是,理论上它不是确定性的。所以继续理论化吧。 - Maxim
如果使用的Go运行时不能在99.999%的时间内在充足资源的明确定义的测试机器上安排简单例程的功能,那么它就不适合大多数实际需求(即使规格理论上仍然称此运行时符合标准)。 - Falco
显示剩余3条评论

-1
首先,我会使用模拟生成器,例如 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)

等待所有模拟方法被调用。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接