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

9 ответов

Решение

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

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

Если вы измените определение своей функции, чтобы использовать вместо нее переменную:

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

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

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

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

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

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

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

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

Главный

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

самый простой способ - установить функцию в глобальную переменную, а перед проверкой установить свой собственный метод

      // package base36

func GenerateRandomString(length int) string {
    // your real code
}


// package teamManager

var RandomStringGenerator = base36.GenerateRandomString

func (m *TeamManagerService) CreateTeam(ctx context.Context) {
 
    // we are using the global variable
    code = RandomStringGenerator(5)
 
    // your application logic

    return  nil
}

и в своем тесте вы должны сначала смоделировать эту глобальную переменную

          teamManager.RandomStringGenerator = func(length int) string {
        return "some string"
    }
    
   service := &teamManager.TeamManagerService{}
   service.CreateTeam(context.Background())
   // now when we call any method that user teamManager.RandomStringGenerator, it will call our mocked method

другой способ - передать RandomStringGenerator как зависимость и сохранить его внутри TeamManagerServiceи используйте его так:

      // package teamManager

type TeamManagerService struct {
   RandomStringGenerator func(length int) string
}

// in this way you don't need to change your main/where this code is used
func NewTeamManagerService() *TeamManagerService {
    return &TeamManagerService{RandomStringGenerator: base36.GenerateRandomString}
}

func (m *TeamManagerService) CreateTeam(ctx context.Context) {
 
    // we are using the struct field variable
    code = m.RandomStringGenerator(5)
 
    // your application logic

    return  nil
}

и в своем тесте вы можете использовать свою собственную пользовательскую функцию

          myGenerator = func(length int) string {
        return "some string"
    }
    
   service := &teamManager.TeamManagerService{RandomStringGenerator: myGenerator}
   service.CreateTeam(context.Background())

вы используете свидетельство, как и я: D, вы можете сделать это

      // this is the mock version of the base36 file
package base36_mock

import "github.com/stretchr/testify/mock"

var Mock = mock.Mock{}

func GenerateRandomString(length int) string {
    args := Mock.Called(length)
    return args.String(0)
}

и в своем тесте вы можете использовать свою собственную пользовательскую функцию

         base36_mock.Mock.On("GenerateRandomString", 5).Return("my expmle code for this test").Once()
    
   service := &teamManager.TeamManagerService{RandomStringGenerator: base36_mock.GenerateRandomString}
   service.CreateTeam(context.Background())

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

Я новичок в Golang, но потратил несколько дней, пытаясь найти способ имитировать функции сторонних пакетов, таких какhttp.Get, без изменения исходного кода. На данный момент я вынужден прийти к выводу, что в Golang это невозможно, и это огромное разочарование.

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

Метод 1: Объявление функции, которую вы хотите имитировать, как глобальную переменную


один из вариантов - объявить глобальную переменную (имеет некоторые подводные камни).

например:

      package abc

var getFunction func(s string) (string, error) := http.Get

func get_page(url string) string {
  ....
  resp, err := getFunction(url)
  ....
}

func downloader() {
  .....
}

и тестовая функция будет следующей:

      package abc

func testFunction(t *testing.T) {
  actualFunction := getFunction
  getFunction := func(s string) (string, error) { 
     //mock implementation 
  }
  defer getFunction = actualFunction
  .....
  //your test
  ......
}

ПРИМЕЧАНИЕ. Тестовая и реальная реализация находятся в одном пакете.

есть некоторые ограничения с вышеуказанным методом!

  1. проведение параллельных тестов невозможно из-за риска возникновения условий гонки.
  2. делая функцию переменной, мы подвергаем небольшому риску изменение ссылки будущими разработчиками, работающими в том же пакете.

Способ 2: создание обернутой функции


другой метод — передать методы, которые вы хотите имитировать, в качестве аргументов функции, чтобы обеспечить возможность тестирования. В моем случае у меня уже было множество клиентов, вызывающих этот метод, и поэтому я хотел избежать нарушения существующих контрактов. Итак, я создал обернутую функцию.

например:

      package abc

type getOperation func(s string) (string, error)

func get_page(url string, op getOperation) string {
  ....
  resp, err := op(url)
  ....
}

//contains only 2 lines of code
func downloader(get httpGet) {
  op := http.Get
  content := wrappedDownloader(get, op)
}

//wraps all the logic that was initially in downloader()
func wrappedDownloader(get httpGet, op getOperation) {
  ....
  content := get_page(BASE_URL, op)
  ....
}

теперь для проверки фактической логики вы будете тестировать вызовы wrappedDownloaderвместо Downloaderи вы бы передали это издевательски getOperation. это позволяет вам протестировать всю бизнес-логику, не нарушая ваш контракт API с текущими клиентами метода.

Учитывая, что модульный тест является предметом этого вопроса, настоятельно рекомендуется использовать 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.

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

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

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

Другие вопросы по тегам