Макетные функции в Go

Я учусь, программируя небольшой персональный проект. Несмотря на то, что он небольшой, я решил сделать строгий unit тест, чтобы хорошо изучить привычки на 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, которая, похоже, похожа на аналогичную проблему. Джулиан Филлипс представляет свою библиотеку 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 решением моей проблемы с тестированием? Что я должен сделать, чтобы заставить его работать?

Ответ 1

Престижность к вам за хорошее тестирование!:)

Лично я не использую gomock (или какую-то насмешливую фреймворку), издеваться над Go очень легко без нее. Я либо передал бы зависимость от функции downloader() в качестве параметра, либо я бы сделал метод downloader() для типа, и тип может содержать зависимость get_page:

Способ 1: передать get_page() в качестве параметра downloader()

type PageGetter func(url string) string

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

Main:

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)
    //...
}

Main:

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()
}

Ответ 2

Если вы измените определение своей функции вместо переменной:

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

Ответ 3

Я использую немного другой подход, в котором публичные методы struct реализуют интерфейсы, но их логика ограничивается просто упаковкой частных (неэкспортируемых) функций, которые принимают эти интерфейсы в качестве параметров. Это дает вам степень детализации, которая вам потребуется для моделирования практически любой зависимости, и в то же время иметь чистый API для использования вне вашего набора тестов.

Чтобы понять это, необходимо понимать, что у вас есть доступ к неэкспортированным методам в вашем тестовом примере (т.е. из ваших файлов _test.go), поэтому вы тестируете те, а не тестируете экспортированные, которые не имеют логики внутри рядом с упаковкой.

Подводя итог: протестируйте неэкспортированные функции вместо того, чтобы тестировать экспортированные!

Давай сделаем пример. Скажем, у нас есть структура Slack API, которая имеет два метода:

  • метод SendMessage, который отправляет HTTP-запрос Slack webhook
  • метод 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 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 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.

И здесь все это :)

Ответ 4

Я бы сделал что-то вроде

Главная

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])
    }
}

Test

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

Ответ 5

Предупреждение: это может немного увеличить размер исполняемого файла и немного снизить производительность во время выполнения. 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
}

Ответ 6

Учитывая, что предметом этого вопроса является юнит-тестирование, настоятельно рекомендуем использовать https://github.com/bouk/monkey. Этот пакет позволяет вам тестировать макет без изменения исходного кода. Сравните с другим ответом, он более "ненавязчивый"。

ОСНОВНЫЕ

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

MOCK TEST

var a *AA

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

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

Плохая сторона:

- Напомним Dave.C, этот метод небезопасен. Поэтому не используйте его вне модульного теста.

- Это не идиоматический Go.

Хорошая сторона:

++ Ненавязчив. Заставить вас делать вещи без изменения основного кода. Как сказал Томас.

++ Заставить вас изменить поведение пакета (может быть предоставлено третьей стороной) с наименьшим количеством кода.