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


147

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


Поскольку вы углубляетесь в модульное тестирование Go, найдите в GoConvey отличный способ проведения поведенческого тестирования ... и тизер: появится автоматически обновляемый веб-интерфейс, который также работает с собственными тестами "go test".
Мэтт

Ответы:


192

Слава вам за практику хорошего тестирования! :)

Лично я не использую 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

150
Неужели я единственный, кто обнаружил, что ради тестирования мы должны изменить сигнатуру основного кода / функций - это ужасно?
Томас,

41
@ Томас Я не уверен, что вы единственный, но на самом деле это фундаментальная причина разработки, основанной на тестировании - ваши тесты определяют способ написания вашего рабочего кода. Тестируемый код является более модульным. В этом случае поведение get_page объекта Downloader теперь является подключаемым - мы можем динамически изменять его реализацию. Вы должны изменить свой основной код, только если он был изначально плохо написан.
weberc2

21
@ Томас Я не понимаю твоего второго предложения. TDD лучше управляет кодом. Ваш код изменяется, чтобы быть тестируемым (потому что тестируемый код обязательно является модульным с хорошо продуманными интерфейсами), но основная цель состоит в том, чтобы иметь лучший код - автоматические тесты - это просто потрясающее вторичное преимущество. Если вы обеспокоены тем, что функциональный код изменяется просто для добавления тестов после факта, я все равно рекомендовал бы изменить его просто потому, что есть хорошая вероятность, что кто-нибудь когда-нибудь захочет прочитать этот код или изменить его.
weberc2

6
@ Томас, конечно, если ты пишешь свои тесты, тебе не придется иметь дело с этой загадкой.
weberc2

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. Переменная, содержащая функцию, является только «глобальной» для области видимости пакета, поскольку она не экспортируется. Это верный вариант, я упомянул минус, выбери свое собственное приключение.
Джейк

4
Следует отметить, что определенная таким образом функция не может быть рекурсивной.
Бен Сэндлер,

2
Я согласен с @Jake, что этот подход имеет свое место.
m.kocikowski

11

Я использую немного другой подход, где публичные методы структуры реализуют интерфейсы, но их логика ограничивается просто упаковкой частных (не экспортируемых) функций, которые принимают эти интерфейсы в качестве параметров. Это дает вам степень детализации, которая вам понадобится для моделирования практически любой зависимости, и в то же время иметь чистый 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'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 .

И тут все дело :)


На самом деле это интересный подход, и небольшая часть информации о доступе к закрытым методам в тестовом файле действительно полезна. Это напоминает мне технику pimpl в C ++. Однако, я думаю, следует сказать, что тестирование частных функций опасно. Частные участники обычно считаются деталями реализации и с большей вероятностью будут меняться со временем, чем общедоступный интерфейс. Тем не менее, пока вы проверяете только закрытые оболочки вокруг открытого интерфейса, все будет в порядке.
c1moore

Да, вообще говоря, я бы с тобой согласился. В этом случае частные тела методов точно такие же, как и публичные, поэтому вы будете тестировать точно то же самое. Единственная разница между ними - аргументы функции. Это хитрость, которая позволяет вам вводить любую зависимость (по насмешке или нет) по мере необходимости.
Франческо Касула

Да я согласен. Я просто говорил, что пока вы ограничиваете это частными методами, которые обертывают эти общедоступные, вы должны быть в порядке. Только не начинайте тестировать частные методы, которые являются деталями реализации.
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 (....

И я бы избежал _в Голанге. Лучше использовать CamelCase


1
Будет ли возможно пойти и разработать пакет, который может сделать это для вас. Я имею в виду что - то вроде: p := patch(mockGetPage, getPage); defer p.done(). Я новичок и пытался сделать это с помощью unsafeбиблиотеки, но в общем случае это невозможно.
vitiral

@ Падал, это почти точно мой ответ, написанный через год после моего.
Джейк,

1
1. Единственное сходство - это глобальный путь. @ Джейк 2. Простое лучше, чем сложное. weberc2
Fallen

1
@ упал Я не считаю ваш пример более простым. Передача аргументов не более сложна, чем изменение глобального состояния, но опора на глобальное состояние создает множество проблем, которых не существует в противном случае. Например, вам придется иметь дело с условиями гонки, если вы хотите распараллелить свои тесты.
weberc2

Это почти то же самое, но это не так :). В этом ответе я вижу, как назначить функцию для переменной и как это позволяет мне назначить другую реализацию для тестов. Я не могу изменить аргументы функции, которую я тестирую, так что это хорошее решение для меня. Альтернатива - использовать 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.
Дейв С

1
@DaveC Я уважаю ваш опыт насчет Голанга, но подозреваю ваше мнение. 1. Безопасность не означает все для разработки программного обеспечения, многофункциональность и удобство имеют значение. 2. Идиоматический Голанг - это не Голанг, а его часть. Если один проект с открытым исходным кодом, другие люди часто играют в него грязно. Сообщество должно поощрять это, по крайней мере, не подавлять это.
Фрэнк Ван

2
Язык называется Go. Под небезопасным я подразумеваю, что это может нарушить среду выполнения Go, такие как сборка мусора.
Дейв C

1
Для меня небезопасно это круто для юнит-теста. Если рефакторинг кода с большим количеством «интерфейса» необходим каждый раз, когда проводится модульный тест. Мне больше подходит тот, который использует небезопасный способ ее решения.
Фрэнк Ван

1
@DaveC Я полностью согласен с тем, что это ужасная идея (мой ответ - голос с наибольшим количеством голосов и принятый ответ), но, чтобы быть педантичным, я не думаю, что это сломает GC, потому что Go GC консервативен и предназначен для обработки подобных случаев. Я был бы счастлив, чтобы быть исправленным, как бы то ни было.
weberc2
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.