Макет функції в 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])
    }
}

Я хотів би мати можливість протестувати завантажувач (), фактично не отримуючи сторінку через 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".
Метт

Відповіді:


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
Дуже дякую! Я поїхав із другим. (Були і деякі інші функції, над якими я хотів знущатися, тому було легше призначити їх структурі) Btw. Я трохи люблю в Go. Особливо його паралельні особливості акуратні!
GolDDranks

149
Я єдиний, хто виявив, що заради тестування нам потрібно змінити підпис основного коду / функції, це жахливо?
Томас

41
@Thomas Я не впевнений, чи ти єдиний, але це насправді основна причина розробки тестових програм - тестування визначає те, як ти пишеш свій виробничий код. Тестовий код є більш модульним. У цьому випадку поведінка об'єкта "Download_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бібліотеки, але це здається неможливим у загальному випадку.
витірал

@Fallen, це майже точно моя відповідь, написана через рік після мого.
Джейк

1
1. Єдина схожість - глобальний var-спосіб. @Jake 2. Просте краще, ніж складне. weberc2
Впав

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

Це майже те саме, але це не :). У цій відповіді я бачу, як призначити функцію вару і як це дозволяє мені призначити іншу реалізацію для тестів. Я не можу змінити аргументи функції, яку я тестую, тому це приємне рішення для мене. Альтернативою є використання приймача з макетною структурою, я ще не знаю, який з них простіший.
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.
Дейв C

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

2
Мова називається Go. Я маю на увазі, що я не маю на увазі, що це може порушити час роботи Go, такі речі, як збирання сміття.
Дейв C

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

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