Go中的模拟功能


147

我正在通过编写一个小型个人项目来学习Go。尽管体积很小,但我还是决定进行严格的单元测试,以便从一开始就学习Go的良好习惯。

琐碎的单元测试都很好,很花哨,但是现在我对依赖项感到困惑;我希望能够用模拟函数替换一些函数调用。这是我的代码片段:

func get_page(url string) string {
    get_dl_slot(url)
    defer free_dl_slot(url)

    resp, err := http.Get(url)
    if err != nil { return "" }
    defer resp.Body.Close()

    contents, err := ioutil.ReadAll(resp.Body)
    if err != nil { return "" }
    return string(contents)
}

func downloader() {
    dl_slots = make(chan bool, DL_SLOT_AMOUNT) // Init the download slot semaphore
    content := get_page(BASE_URL)
    links_regexp := regexp.MustCompile(LIST_LINK_REGEXP)
    matches := links_regexp.FindAllStringSubmatch(content, -1)
    for _, match := range matches{
        go serie_dl(match[1], match[2])
    }
}

我希望能够测试downloader()而无需通过http实际获取页面-即通过模拟get_page(更轻松,因为它仅以字符串形式返回页面内容)或http.Get()。

我找到了这个线程:https : //groups.google.com/forum/#!topic/golang- nuts/ 6AN1E2CJOxI,这似乎与一个类似的问题有关。朱利安·菲利普斯(Julian Phillips)提出了他的图书馆Withmock(http://github.com/qur/withmock)作为解决方案,但我无法使其正常工作。老实说,这是我的测试代码的相关部分,对我来说,这基本上是对货物的崇拜代码:

import (
    "testing"
    "net/http" // mock
    "code.google.com/p/gomock"
)
...
func TestDownloader (t *testing.T) {
    ctrl := gomock.NewController()
    defer ctrl.Finish()
    http.MOCK().SetController(ctrl)
    http.EXPECT().Get(BASE_URL)
    downloader()
    // The rest to be written
}

测试输出如下:

ERROR: Failed to install '_et/http': exit status 1
output:
can't load package: package _et/http: found packages http (chunked.go) and main (main_mock.go) in /var/folders/z9/ql_yn5h550s6shtb9c5sggj40000gn/T/withmock570825607/path/src/_et/http

Withmock是否可以解决我的测试问题?我应该怎么做才能使其正常工作?


由于您正在研究Go单元测试,因此请查看GoConvey,这是一种进行行为驱动的测试的好方法...和预告片:即将出现一种自动更新的Web UI,它也可以与本机“进行测试”测试一起使用。
马特

Answers:


193

感谢您练习良好的测试!:)

就我个人而言,我不使用gomock(或任何模拟框架;没有它,Go中的模拟非常容易)。我要么将依赖项downloader()作为参数传递给函数,要么downloader()在类型上创建方法,并且该类型可以容纳该get_page依赖项:

方法1:get_page()作为参数传递downloader()

type PageGetter func(url string) string

func downloader(pageGetterFunc PageGetter) {
    // ...
    content := pageGetterFunc(BASE_URL)
    // ...
}

主要:

func get_page(url string) string { /* ... */ }

func main() {
    downloader(get_page)
}

测试:

func mock_get_page(url string) string {
    // mock your 'get_page()' function here
}

func TestDownloader(t *testing.T) {
    downloader(mock_get_page)
}

方法2:制作download()一种类型的方法Downloader

如果您不想将依赖项作为参数传递,则还可以使get_page()一个类型成为成员,并使其download()成为该类型的方法,然后可以使用get_page

type PageGetter func(url string) string

type Downloader struct {
    get_page PageGetter
}

func NewDownloader(pg PageGetter) *Downloader {
    return &Downloader{get_page: pg}
}

func (d *Downloader) download() {
    //...
    content := d.get_page(BASE_URL)
    //...
}

主要:

func get_page(url string) string { /* ... */ }

func main() {
    d := NewDownloader(get_page)
    d.download()
}

测试:

func mock_get_page(url string) string {
    // mock your 'get_page()' function here
}

func TestDownloader() {
    d := NewDownloader(mock_get_page)
    d.download()
}

4
非常感谢!我去了第二个。(还有一些我想模拟的其他函数,因此将它们分配给一个结构更容易)。我对Go有点爱。特别是它的并发功能很简洁!
GolDDranks

149
我是唯一一个为了测试而不得不更改主代码/功能签名的发现吗?
托马斯

41
@Thomas我不确定您是否是唯一的一个,但这实际上是测试驱动开发的根本原因-您的测试将指导您编写生产代码的方式。可测试的代码更具模块化。在这种情况下,Downloader对象的“ get_page”行为现在可插入-我们可以动态更改其实现。如果一开始写的不好,您只需要更改主代码即可。
weberc2 2014年

21
@托马斯,我听不懂你的第二句话。TDD驱动更好的代码。您的代码会更改为可测试(因为可测试的代码必须具有经过深思熟虑的接口进行模块化),但是主要目的是拥有更好的代码-拥有自动化测试只是一个令人敬畏的次要好处。如果您担心要更改功能代码只是为了在事后添加测试,我仍然建议仅更改它,因为某天很可能有人会想要阅读或更改该代码。
weberc2 2014年

6
@Thomas当然,如果您在进行测试时正在编写测试,则不必处理这个难题。
weberc2 2014年

24

如果将函数定义更改为使用变量:

var get_page = func(url string) string {
    ...
}

您可以在测试中覆盖它:

func TestDownloader(t *testing.T) {
    get_page = func(url string) string {
        if url != "expected" {
            t.Fatal("good message")
        }
        return "something"
    }
    downloader()
}

但是请小心,如果其他测试测试您覆盖的功能的功能,则可能会失败!

Go作者使用Go标准库中的这种模式将测试钩子插入代码中,以使测试变得更容易:

https://golang.org/src/net/hook.go

https://golang.org/src/net/dial.go#L248

https://golang.org/src/net/dial_test.go#L701


8
如果需要,可以向下投票,这对于小包装而言是可以接受的模式,以避免与DI关联的样板。包含该函数的变量在包范围内仅是“全局”的,因为未导出。这是一个有效的选择,我提到了缺点,选择自己的冒险方式。
2014年

4
需要注意的一件事是,以这种方式定义的函数不能是递归的。
Ben Sandler

2
我同意@Jake所说的这种方法。
m.kocikowski

11

我使用的方法略有不同,其中公共结构方法实现接口,但是它们的逻辑仅限于包装以这些接口为参数的私有(未导出)函数。这为您提供了模拟几乎任何依赖关系所需的粒度,并且具有可从测试套件外部使用的干净API。

要理解这一点,必须了解您可以在测试用例中访问未导出的方法(即从_test.go文件内部),因此您可以测试这些方法,而不是测试除了包装之外没有逻辑的导出方法。

总结一下:测试未导出的功能,而不是测试已导出的功能!

让我们举个例子。假设我们有一个Slack API结构,其中有两个方法:

  • SendMessage其中将HTTP请求发送到松弛网络挂接方法
  • SendDataSynchronously给定一片字符串的方法对其进行迭代,并要求SendMessage每次迭代

因此,为了在SendDataSynchronously每次都没有发出HTTP请求的情况下进行测试,我们必须进行模拟SendMessage,对吗?

package main

import (
    "fmt"
)

// URI interface
type URI interface {
    GetURL() string
}

// MessageSender interface
type MessageSender interface {
    SendMessage(message string) error
}

// This one is the "object" that our users will call to use this package functionalities
type API struct {
    baseURL  string
    endpoint string
}

// Here we make API implement implicitly the URI interface
func (api *API) GetURL() string {
    return api.baseURL + api.endpoint
}

// Here we make API implement implicitly the MessageSender interface
// Again we're just WRAPPING the sendMessage function here, nothing fancy 
func (api *API) SendMessage(message string) error {
    return sendMessage(api, message)
}

// We want to test this method but it calls SendMessage which makes a real HTTP request!
// Again we're just WRAPPING the sendDataSynchronously function here, nothing fancy
func (api *API) SendDataSynchronously(data []string) error {
    return sendDataSynchronously(api, data)
}

// this would make a real HTTP request
func sendMessage(uri URI, message string) error {
    fmt.Println("This function won't get called because we will mock it")
    return nil
}

// this is the function we want to test :)
func sendDataSynchronously(sender MessageSender, data []string) error {
    for _, text := range data {
        err := sender.SendMessage(text)

        if err != nil {
            return err
        }
    }

    return nil
}

// TEST CASE BELOW

// Here's our mock which just contains some variables that will be filled for running assertions on them later on
type mockedSender struct {
    err      error
    messages []string
}

// We make our mock implement the MessageSender interface so we can test sendDataSynchronously
func (sender *mockedSender) SendMessage(message string) error {
    // let's store all received messages for later assertions
    sender.messages = append(sender.messages, message)

    return sender.err // return error for later assertions
}

func TestSendsAllMessagesSynchronously() {
    mockedMessages := make([]string, 0)
    sender := mockedSender{nil, mockedMessages}

    messagesToSend := []string{"one", "two", "three"}
    err := sendDataSynchronously(&sender, messagesToSend)

    if err == nil {
        fmt.Println("All good here we expect the error to be nil:", err)
    }

    expectedMessages := fmt.Sprintf("%v", messagesToSend)
    actualMessages := fmt.Sprintf("%v", sender.messages)

    if expectedMessages == actualMessages {
        fmt.Println("Actual messages are as expected:", actualMessages)
    }
}

func main() {
    TestSendsAllMessagesSynchronously()
}

我喜欢这种方法的地方在于,通过查看未导出的方法,您可以清楚地看到依赖关系。同时,您导出的API更加整洁,传递的参数更少,因为真正的依赖关系只是父接收器,它自己实现了所有这些接口。但是,每个功能都可能仅依赖于它的一部分(一个,也许是两个接口),这使得重构变得容易得多。很高兴仅通过查看函数签名就可以看到代码是如何真正耦合的,我认为它是防止气味代码的强大工具。

为了使事情变得容易,我将所有内容放到一个文件中,以允许您在此处操场上运行代码,但我建议您也查看GitHub上的完整示例,这里是slack.go文件,这里是slack_test.go

在这里,整个事情:)


这实际上是一种有趣的方法,有关访问测试文件中私有方法的小知识非常有用。它使我想起了C ++中的pimpl技术。但是,我认为应该说测试私有功能是危险的。私有成员通常被认为是实现细节,并且比公共接口更可能随时间而变化。但是,只要您仅在公共接口周围测试私有包装器,就可以了。
c1moore

是的,总的来说,我会同意你的看法。在这种情况下,尽管私有方法主体与公共方法主体完全相同,所以您将测试完全相同的事物。两者之间的唯一区别是函数参数。这是使您能够根据需要注入任何依赖项(无论是否模拟)的技巧。
Francesco Casula

是的,我同意。我只是说,只要您将其限制为包装那些公共方法的私有方法,您就应该很好。只是不要开始测试作为实现细节的私有方法。
c1moore

7

我会做类似的事情,

主要

var getPage = get_page
func get_page (...

func downloader() {
    dl_slots = make(chan bool, DL_SLOT_AMOUNT) // Init the download slot semaphore
    content := getPage(BASE_URL)
    links_regexp := regexp.MustCompile(LIST_LINK_REGEXP)
    matches := links_regexp.FindAllStringSubmatch(content, -1)
    for _, match := range matches{
        go serie_dl(match[1], match[2])
    }
}

测试

func TestDownloader (t *testing.T) {
    origGetPage := getPage
    getPage = mock_get_page
    defer func() {getPage = origGatePage}()
    // The rest to be written
}

// define mock_get_page and rest of the codes
func mock_get_page (....

而且我会避免_使用golang。更好地使用camelCase


1
有可能去开发一个可以为您做到这一点的软件包。我想是这样的:p := patch(mockGetPage, getPage); defer p.done()。我是新手,正尝试使用unsafe库来执行此操作,但在一般情况下似乎无法执行。
2015年

@Fallen这几乎是我在我的一年后写的答案。
杰克2016年

1
1.唯一的相似之处是全局变量方式。@Jake 2.简单胜于复杂。weberc2
堕落

1
@fallen我认为您的示例不那么简单。传递参数并不比改变全局状态复杂,但是依靠全局状态会带来很多其他问题。例如,如果要并行化测试,则必须处理竞争条件。
weberc2 '16

几乎相同,但不是:)。在此答案中,我看到了如何为var分配函数,以及如何使我为测试分配不同的实现。我无法更改正在测试的函数的参数,因此这对我来说是一个不错的解决方案。另一种方法是将Receiver与模拟结构一起使用,我还不知道哪一个更简单。
alexbt

0

警告:这可能会增加可执行文件的大小,并降低运行时性能。IMO,如果golang具有宏或函数装饰器之类的功能,则更好。

如果要在不更改其API的情况下模拟函数,最简单的方法是对实现进行一些更改:

func getPage(url string) string {
  if GetPageMock != nil {
    return GetPageMock()
  }

  // getPage real implementation goes here!
}

func downloader() {
  if GetPageMock != nil {
    return GetPageMock()
  }

  // getPage real implementation goes here!
}

var GetPageMock func(url string) string = nil
var DownloaderMock func() = nil

这样,我们实际上可以从另一个函数中模拟一个函数。为了更加方便,我们可以提供这样的模拟样板:

// download.go
func getPage(url string) string {
  if m.GetPageMock != nil {
    return m.GetPageMock()
  }

  // getPage real implementation goes here!
}

func downloader() {
  if m.GetPageMock != nil {
    return m.GetPageMock()
  }

  // getPage real implementation goes here!
}

type MockHandler struct {
  GetPage func(url string) string
  Downloader func()
}

var m *MockHandler = new(MockHandler)

func Mock(handler *MockHandler) {
  m = handler
}

在测试文件中:

// download_test.go
func GetPageMock(url string) string {
  // ...
}

func TestDownloader(t *testing.T) {
  Mock(&MockHandler{
    GetPage: GetPageMock,
  })

  // Test implementation goes here!

  Mock(new(MockHandler)) // Reset mocked functions
}

-2

考虑到单元测试是这个问题的领域,强烈建议您使用https://github.com/bouk/monkey。该软件包使您可以在不更改原始源代码的情况下进行模拟测试。与其他答案相比,它更具“非侵入性”。

主要

type AA struct {
 //...
}
func (a *AA) OriginalFunc() {
//...
}

模拟测试

var a *AA

func NewFunc(a *AA) {
 //...
}

monkey.PatchMethod(reflect.TypeOf(a), "OriginalFunc", NewFunc)

不好的一面是:

-由Dave.C提醒,此方法不安全。因此,请勿在单元测试之外使用它。

-是非惯用语Go。

好的一面是:

++是非侵入性的。使您无需更改主代码即可执行操作。就像托马斯所说。

++使您以最少的代码更改软件包的行为(可能由第三方提供)。


1
请不要这样做。这是完全不安全的,并且可能破坏各种Go内部组件。更不用说它甚至不是远程惯用的Go。
Dave C

1
@DaveC我尊重您对Golang的经验,但怀疑您的意见。1.安全并不意味着软件开发全部,功能丰富和便捷至关重要。2.成语的Golang不是Golang,而是它的一部分。如果一个项目是开源的,那么其他人通常会对此不屑一顾。社区应该鼓励至少不要压制它。
Frank Wang

2
该语言称为Go。所谓不安全,是指它可能破坏Go运行时,例如垃圾回收。
Dave C

1
对我来说,不安全对于单元测试是很酷的。如果每次进行单元测试时都需要使用更多“接口”重构代码。使用不安全的方法来解决它更适合我。
Frank Wang

1
@DaveC我完全同意这是一个非常糟糕的主意(我的回答是最受投票和接受的答案),但要腐,我认为这不会破坏GC,因为Go GC是保守的,旨在处理此类情况。但是,我很乐意得到纠正。
weberc2
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.