Fungsi tiruan di Go

147

Saya sedang belajar Go dengan mengkode proyek pribadi kecil. Meskipun kecil, saya memutuskan untuk melakukan pengujian unit yang ketat untuk mempelajari kebiasaan baik di Go sejak awal.

Tes unit sepele semuanya baik-baik saja dan keren, tapi saya bingung dengan ketergantungan sekarang; Saya ingin dapat mengganti beberapa panggilan fungsi dengan yang tiruan. Berikut cuplikan kode saya:

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

Saya ingin dapat menguji pengunduh () tanpa benar-benar mendapatkan halaman melalui http - yaitu dengan mengejek get_page (lebih mudah karena hanya mengembalikan konten halaman sebagai string) atau http.Get ().

Saya menemukan utas ini: https://groups.google.com/forum/#!topic/golang-nuts/6AN1E2CJOxI yang tampaknya merupakan masalah serupa. Julian Phillips menyajikan perpustakaannya, Withmock ( http://github.com/qur/withmock ) sebagai solusi, tetapi saya tidak dapat membuatnya berfungsi. Inilah bagian yang relevan dari kode pengujian saya, yang sebagian besar adalah kode pemujaan kargo bagi saya, jujur:

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
}

Output tes berikut:

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

Apakah Withmock solusi untuk masalah pengujian saya? Apa yang harus saya lakukan untuk membuatnya bekerja?

GolDDranks
sumber
Karena Anda masuk ke pengujian unit Go, lihat GoConvey untuk mengetahui cara terbaik untuk melakukan pengujian yang didorong oleh perilaku ... dan penggoda: UI web yang diperbarui secara otomatis akan datang yang juga berfungsi dengan tes "go test" asli.
Matt

Jawaban:

192

Kudos kepada Anda untuk berlatih pengujian yang baik! :)

Secara pribadi, saya tidak menggunakan gomock(atau kerangka kerja mengejek dalam hal ini; mengejek di Go sangat mudah tanpa itu). Saya akan meneruskan dependensi ke downloader()fungsi sebagai parameter, atau saya akan membuat downloader()metode pada tipe, dan tipe tersebut dapat menahan get_pagedependensi:

Metode 1: Lulus get_page()sebagai parameterdownloader()

type PageGetter func(url string) string

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

Utama:

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

func main() {
    downloader(get_page)
}

Uji:

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

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

Method2: Buat download()metode tipe Downloader:

Jika Anda tidak ingin meneruskan ketergantungan sebagai parameter, Anda juga bisa membuat get_page()anggota tipe, dan membuat download()metode tipe itu, yang kemudian dapat menggunakan 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)
    //...
}

Utama:

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

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

Uji:

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

func TestDownloader() {
    d := NewDownloader(mock_get_page)
    d.download()
}
weberc2
sumber
4
Terima kasih banyak! Saya pergi dengan yang kedua. (ada beberapa fungsi lain yang ingin saya tiru, jadi lebih mudah untuk menetapkannya ke struct) Btw. Saya sedikit cinta di Go. Terutama fitur konkurensi yang rapi!
GolDDranks
150
Apakah saya satu-satunya yang menemukan bahwa demi pengujian kami harus mengubah kode utama / fungsi tanda tangan mengerikan?
Thomas
41
@ Thomas Saya tidak yakin apakah Anda satu-satunya, tapi itu sebenarnya alasan mendasar untuk pengembangan yang digerakkan oleh pengujian - pengujian Anda memandu cara Anda menulis kode produksi. Kode yang dapat diuji lebih modular. Dalam hal ini, perilaku 'get_page' objek Downloader sekarang dapat dicolokkan - kita dapat secara dinamis mengubah implementasinya. Anda hanya perlu mengubah kode utama Anda jika itu ditulis dengan buruk di tempat pertama.
weberc2
21
@ Thomas Saya tidak mengerti kalimat kedua Anda. TDD mendorong kode yang lebih baik. Kode Anda berubah agar dapat diuji (karena kode yang dapat diuji harus modular dengan antarmuka yang dipikirkan dengan matang), tetapi tujuan utamanya adalah untuk memiliki kode yang lebih baik - memiliki pengujian otomatis hanyalah keuntungan sekunder yang luar biasa. Jika kekhawatiran Anda adalah kode fungsional diubah hanya untuk menambah tes setelah fakta, saya tetap merekomendasikan untuk mengubahnya hanya karena ada kemungkinan bagus bahwa seseorang suatu hari nanti ingin membaca kode itu atau mengubahnya.
weberc2
6
@ Thomas tentu saja, jika Anda sedang menulis tes sambil berjalan, Anda tidak perlu berurusan dengan teka-teki itu.
weberc2
24

Jika Anda mengubah definisi fungsi Anda menggunakan variabel sebagai gantinya:

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

Anda dapat menimpanya dalam tes Anda:

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

Hati-hati, tes Anda yang lain mungkin gagal jika mereka menguji fungsi dari fungsi yang Anda timpa!

Penulis Go menggunakan pola ini di pustaka Go standar untuk memasukkan kait uji ke dalam kode untuk mempermudah pengujian:

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

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

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

Jake
sumber
8
Jika Anda ingin downvote, ini adalah pola yang dapat diterima untuk paket kecil untuk menghindari boilerplate yang terkait dengan DI. Variabel yang mengandung fungsi hanya "global" ke lingkup paket karena tidak diekspor. Ini adalah opsi yang valid, saya sebutkan downside, pilih petualangan Anda sendiri.
Jake
4
Satu hal yang perlu diperhatikan adalah bahwa fungsi yang didefinisikan dengan cara ini tidak dapat menjadi rekursif.
Ben Sandler
2
Saya setuju dengan @Jake bahwa pendekatan ini sesuai dengan tempatnya.
m.kocikowski
11

Saya menggunakan pendekatan yang sedikit berbeda di mana metode struct publik mengimplementasikan antarmuka tetapi logika mereka terbatas hanya membungkus fungsi pribadi (tidak diekspor) yang menggunakan antarmuka tersebut sebagai parameter. Ini memberi Anda perincian yang Anda perlukan untuk mengejek hampir semua ketergantungan namun memiliki API bersih untuk digunakan dari luar ruang uji Anda.

Untuk memahami ini, sangat penting untuk memahami bahwa Anda memiliki akses ke metode_test.go yang tidak diekspor dalam kasus pengujian Anda (yaitu dari dalam file Anda ) sehingga Anda menguji mereka alih-alih menguji yang diekspor yang tidak memiliki logika di dalam selain membungkus.

Untuk meringkas: uji fungsi yang tidak diekspor daripada menguji yang diekspor!

Mari kita buat contoh. Katakanlah kita memiliki struct Slack API yang memiliki dua metode:

  • itu SendMessage metode yang mengirimkan permintaan HTTP ke WebHook Slack
  • yang SendDataSynchronouslymetode yang diberikan sepotong string iterates atas mereka dan panggilan SendMessageuntuk setiap iterasi

Jadi untuk menguji SendDataSynchronouslytanpa membuat permintaan HTTP setiap kali kita harus mengejek SendMessage, kan?

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

Apa yang saya sukai dari pendekatan ini adalah bahwa dengan melihat metode yang tidak diekspor, Anda dapat melihat dengan jelas apa dependensinya. Pada saat yang sama API yang Anda ekspor jauh lebih bersih dan dengan lebih sedikit parameter untuk diteruskan karena ketergantungan sebenarnya di sini adalah hanya penerima induk yang mengimplementasikan semua antarmuka itu sendiri. Namun setiap fungsi berpotensi bergantung hanya pada satu bagian saja (satu, mungkin dua antarmuka) yang membuat refactor jauh lebih mudah. Sangat menyenangkan untuk melihat bagaimana kode Anda benar-benar digabungkan hanya dengan melihat tanda tangan fungsi, saya pikir itu membuat alat yang ampuh terhadap kode berbau.

Untuk mempermudah, saya meletakkan semuanya ke dalam satu file untuk memungkinkan Anda menjalankan kode di taman bermain di sini, tetapi saya sarankan Anda juga memeriksa contoh lengkapnya di GitHub, di sini adalah file slack.go dan di sini slack_test.go .

Dan di sini semuanya :)

Francesco Casula
sumber
Ini sebenarnya merupakan pendekatan yang menarik dan berita gembira tentang memiliki akses ke metode pribadi dalam file uji sangat berguna. Ini mengingatkan saya pada teknik jerawat di C ++. Namun, saya pikir harus dikatakan bahwa pengujian fungsi pribadi berbahaya. Anggota pribadi biasanya dianggap sebagai detail implementasi dan lebih cenderung berubah dari waktu ke waktu daripada antarmuka publik. Namun, selama Anda hanya menguji pembungkus pribadi di sekitar antarmuka publik, Anda akan baik-baik saja.
c1moore
Ya secara umum saya setuju dengan Anda. Dalam hal ini meskipun badan metode privat persis sama dengan yang publik sehingga Anda akan menguji hal yang persis sama. Satu-satunya perbedaan antara keduanya adalah argumen fungsi. Itulah trik yang memungkinkan Anda untuk menyuntikkan ketergantungan (diejek atau tidak) sesuai kebutuhan.
Francesco Casula
Ya saya setuju. Saya hanya mengatakan selama Anda membatasi itu pada metode pribadi yang membungkus yang publik, Anda harus baik untuk pergi. Hanya saja, jangan mulai menguji metode pribadi yang merupakan detail implementasi.
c1moore
7

Saya akan melakukan sesuatu seperti,

Utama

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

Uji

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 (....

Dan saya akan menghindari _di golang. Lebih baik gunakan camelCase

Jatuh
sumber
1
apakah mungkin untuk mengembangkan paket yang bisa melakukan ini untuk Anda. Aku berpikir sesuatu seperti: p := patch(mockGetPage, getPage); defer p.done(). Saya baru mulai, dan mencoba melakukan ini menggunakan unsafeperpustakaan, tetapi sepertinya tidak mungkin dilakukan dalam kasus umum.
vitiral
@Fallen ini hampir persis jawaban saya yang ditulis lebih dari setahun setelah saya.
Jake
1
1. Satu-satunya kesamaan adalah cara global var. @ Jake 2. Sederhana lebih baik daripada kompleks. weberc2
Fallen
1
@ Fallen Saya tidak menganggap contoh Anda lebih sederhana. Melewati argumen tidak lebih kompleks daripada bermutasi negara global, tetapi mengandalkan negara global menimbulkan banyak masalah yang tidak ada sebaliknya. Misalnya, Anda harus berurusan dengan kondisi balapan jika Anda ingin memparalelkan tes Anda.
weberc2
Hampir sama, tetapi tidak :). Dalam jawaban ini, saya melihat cara menetapkan fungsi ke var dan bagaimana ini memungkinkan saya untuk menetapkan implementasi yang berbeda untuk pengujian. Saya tidak dapat mengubah argumen pada fungsi yang saya uji, jadi ini adalah solusi yang bagus untuk saya. Alternatifnya adalah menggunakan Receiver dengan mock struct, saya belum tahu mana yang lebih sederhana.
alexbt
0

Peringatan: Ini mungkin sedikit mengembang ukuran file yang dapat dieksekusi dan biaya kinerja runtime sedikit. IMO, ini akan lebih baik jika golang memiliki fitur seperti penghias makro atau fungsi.

Jika Anda ingin mengolok-olok fungsi tanpa mengubah API-nya, cara termudah adalah dengan sedikit mengubah implementasinya:

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

Dengan cara ini kita benar-benar dapat mengejek satu fungsi dari yang lain. Untuk lebih nyaman kami dapat menyediakan boilerplate mengejek seperti:

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

Dalam file uji:

// 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
}
Penjahit Clite
sumber
-2

Mengingat unit test adalah domain dari pertanyaan ini, sangat disarankan Anda untuk menggunakan https://github.com/bouk/monkey . Paket ini membuat Anda untuk mengejek tes tanpa mengubah kode sumber asli Anda. Bandingkan dengan jawaban lain, ini lebih "tidak mengganggu"。

UTAMA

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

UJI MOCK

var a *AA

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

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

Sisi buruknya adalah:

- Diingatkan oleh Dave.C, Metode ini tidak aman. Jadi jangan menggunakannya di luar unit test.

- Apakah Go non-idiomatik.

Sisi baiknya adalah:

++ Tidak mengganggu. Membuat Anda melakukan sesuatu tanpa mengubah kode utama. Seperti yang dikatakan Thomas.

++ Membuat Anda mengubah perilaku paket (mungkin disediakan oleh pihak ketiga) dengan kode paling sedikit.

Frank Wang
sumber
1
Tolong jangan lakukan ini. Benar-benar tidak aman dan dapat merusak berbagai Go internal. Belum lagi itu bahkan Go tidak jauh idiomatik.
Dave C
1
@DaveC Saya menghargai pengalaman Anda tentang Golang, tetapi curiga pendapat Anda. 1. Keamanan tidak berarti semuanya untuk pengembangan perangkat lunak, kaya fitur dan kenyamanan. 2. Golang Idiomatik bukan Golang, adalah bagian darinya. Jika satu proyek adalah open-source, itu biasa bagi orang lain untuk bermain kotor di atasnya. Masyarakat harus mendorongnya setidaknya tidak menekannya.
Frank Wang
2
Bahasa ini disebut Go. Maksud saya tidak aman itu bisa memecahkan runtime Go, hal-hal seperti pengumpulan sampah.
Dave C
1
Bagi saya, tidak aman itu keren untuk tes unit. Jika kode refactoring dengan lebih banyak 'antarmuka' diperlukan setiap kali tes unit dilakukan. Ini lebih cocok untuk saya yang menggunakan cara yang tidak aman untuk menyelesaikannya.
Frank Wang
1
@DaveC Saya sepenuhnya setuju bahwa ini adalah ide yang sangat buruk (jawaban saya adalah jawaban yang paling banyak dipilih dan diterima), tetapi untuk menjadi terlalu tinggi saya tidak berpikir ini akan merusak GC karena Go GC konservatif dan dimaksudkan untuk menangani kasus-kasus seperti ini. Saya akan senang dikoreksi.
weberc2