Saat Menulis Tes Unit, Jangan Gunakan Ejekan
Diterbitkan: 2018-05-02Catatan: Ini adalah posting teknik teknis terbaru kami yang ditulis oleh insinyur utama, Seth Ammons. Terima kasih khusus kepada Sam Nguyen, Kane Kim, Elmer Thomas, dan Kevin Gillette untuk peer review posting ini . Dan untuk postingan lainnya seperti ini, lihat blog roll teknis kami.
Saya sangat menikmati menulis tes untuk kode saya, terutama tes unit. Rasa percaya diri yang diberikannya kepada saya sangat besar. Mengambil sesuatu yang sudah lama tidak saya kerjakan dan mampu menjalankan unit dan tes integrasi memberi saya pengetahuan bahwa saya dapat melakukan refactor dengan kejam jika diperlukan dan, selama tes saya memiliki cakupan yang baik dan bermakna dan terus lulus , Saya akan tetap memiliki perangkat lunak yang berfungsi setelahnya.
Tes unit memandu desain kode dan memungkinkan kami memverifikasi dengan cepat bahwa mode kegagalan dan alur logika berfungsi sebagaimana dimaksud. Dengan itu, saya ingin menulis tentang sesuatu yang mungkin sedikit lebih kontroversial: saat menulis unit test, jangan gunakan ejekan.
Mari kita dapatkan beberapa definisi di atas meja
Apa perbedaan antara tes unit dan integrasi? Apa yang saya maksud dengan mengejek dan apa yang harus Anda gunakan sebagai gantinya? Posting ini berfokus pada bekerja di Go, jadi kemiringan saya pada kata-kata ini adalah dalam konteks Go.
Ketika saya mengatakan unit test , saya mengacu pada tes yang memastikan penanganan kesalahan yang tepat dan memandu desain sistem dengan menguji unit kecil kode. Dengan unit, kita bisa merujuk ke seluruh paket, antarmuka, atau metode individual.
Pengujian integrasi adalah tempat Anda benar-benar berinteraksi dengan sistem dan/atau perpustakaan yang bergantung. Ketika saya mengatakan "mengolok-olok," saya secara khusus mengacu pada istilah "Objek Mock," di mana kita "mengganti kode domain dengan implementasi dummy yang meniru fungsi nyata dan menegakkan pernyataan tentang perilaku kode kita [1]" (penekanan Milikku).
Dinyatakan sedikit lebih pendek: mengolok-olok perilaku tegas, seperti:
MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")
Saya menganjurkan untuk "Palsu daripada Mengolok-olok."
Palsu adalah semacam tes ganda yang mungkin mengandung perilaku bisnis [2]. Palsu hanyalah struct yang sesuai dengan antarmuka dan merupakan bentuk injeksi ketergantungan tempat kami mengontrol perilaku. Manfaat utama dari palsu adalah bahwa mereka mengurangi kopling dalam kode, di mana mengejek meningkatkan kopling, dan kopling membuat refactoring lebih sulit [3].
Dalam posting ini, saya bermaksud untuk menunjukkan bahwa palsu memberikan fleksibilitas dan memungkinkan pengujian dan refactoring yang mudah. Mereka mengurangi ketergantungan dibandingkan dengan tiruan, dan mudah dirawat.
Mari selami dengan contoh yang sedikit lebih maju daripada "menguji fungsi penjumlahan" seperti yang mungkin Anda lihat di pos tipikal seperti ini. Namun, saya perlu memberi Anda beberapa konteks sehingga Anda dapat lebih mudah memahami kode yang mengikuti dalam posting ini.
Di SendGrid, salah satu sistem kami secara tradisional memiliki file di sistem file lokal, tetapi karena kebutuhan akan ketersediaan yang lebih tinggi dan throughput yang lebih baik, kami memindahkan file-file ini ke S3.
Kami memiliki aplikasi yang perlu dapat membaca file-file ini dan kami memilih aplikasi yang dapat berjalan dalam dua mode "lokal" atau "jarak jauh", tergantung pada konfigurasi. Peringatan yang dihilangkan dalam banyak contoh kode adalah bahwa dalam kasus kegagalan jarak jauh, kita kembali membaca file secara lokal.
Dengan itu, aplikasi ini memiliki pengambil paket. Kita perlu memastikan bahwa pengambil paket bisa mendapatkan file baik dari sistem file jarak jauh atau sistem file lokal.
Pendekatan Naif: panggil saja perpustakaan dan panggilan tingkat sistem
Pendekatan naifnya adalah bahwa paket implementasi kami akan memanggil getter.New(...) dan menyebarkannya informasi yang diperlukan untuk menyiapkan baik pengambilan file jarak jauh atau lokal dan akan mengembalikan Getter . Nilai yang dikembalikan kemudian akan dapat memanggil MyGetter.GetFile(...) dengan parameter yang diperlukan untuk menemukan file jarak jauh atau lokal.
Ini akan memberi kita struktur dasar kita. Saat kami membuat Getter baru, kami menginisialisasi parameter yang diperlukan untuk setiap kemungkinan pengambilan file jarak jauh (kunci akses dan rahasia) dan kami juga meneruskan beberapa nilai yang berasal dari konfigurasi aplikasi kami, seperti useRemoteFS yang akan memberi tahu kode untuk mencoba sistem file jarak jauh.
Kita perlu menyediakan beberapa fungsi dasar. Lihat kode naif di sini [4]; di bawah ini adalah versi yang dikurangi. Catatan, ini adalah contoh yang belum selesai dan kami akan melakukan refactoring.
Ide dasarnya di sini adalah jika kita dikonfigurasi untuk membaca dari sistem file jarak jauh dan kita mendapatkan detail sistem file jarak jauh (host, bucket, dan kunci), maka kita harus mencoba membaca dari sistem file jarak jauh. Setelah kami yakin dengan pembacaan sistem dari jarak jauh, kami akan memindahkan semua pembacaan file ke sistem file jarak jauh dan menghapus referensi untuk membaca dari sistem file lokal.
Kode ini tidak terlalu ramah untuk pengujian unit; perhatikan bahwa untuk memverifikasi cara kerjanya, kita sebenarnya perlu menekan tidak hanya sistem file lokal, tetapi juga sistem file jarak jauh. Sekarang, kita bisa melakukan tes integrasi dan menyiapkan beberapa keajaiban Docker untuk memiliki instance s3 yang memungkinkan kita memverifikasi jalur bahagia dalam kode.
Hanya memiliki pengujian integrasi kurang dari ideal karena pengujian unit membantu kami merancang perangkat lunak yang lebih kuat dengan menguji kode alternatif dan jalur kegagalan dengan mudah. Kita harus menyimpan tes integrasi untuk jenis tes "apakah itu benar-benar berfungsi" yang lebih besar. Untuk saat ini, mari kita fokus pada unit test.
Bagaimana kita bisa membuat kode ini lebih dapat diuji unit? Ada dua aliran pemikiran. Salah satunya adalah dengan menggunakan generator tiruan (seperti https://github.com/vektra/mockery atau https://github.com/golang/mock) yang membuat kode boilerplate untuk digunakan saat menguji tiruan.
Anda dapat mengikuti rute ini dan menghasilkan panggilan sistem file dan panggilan klien Minio. Atau mungkin Anda ingin menghindari ketergantungan, jadi Anda membuat tiruan dengan tangan. Ternyata mengejek klien Minio tidak mudah karena Anda memiliki klien yang diketik secara konkret yang mengembalikan objek yang diketik secara konkret.
Saya mengatakan bahwa ada cara yang lebih baik daripada mengejek. Jika kami merestrukturisasi kode kami agar lebih dapat diuji, kami tidak memerlukan impor tambahan untuk tiruan dan produk terkait dan tidak perlu mengetahui DSL pengujian tambahan untuk menguji antarmuka dengan percaya diri. Kami dapat mengatur kode kami agar tidak terlalu digabungkan dan kode pengujian hanya akan menjadi kode Go biasa menggunakan antarmuka Go. Ayo lakukan!
Pendekatan Antarmuka: Abstraksi yang lebih besar, pengujian yang lebih mudah
Apa yang perlu kita uji? Di sinilah beberapa Gopher baru melakukan kesalahan. Saya telah melihat orang-orang memahami nilai memanfaatkan antarmuka, tetapi merasa mereka membutuhkan antarmuka yang cocok dengan implementasi konkret dari paket yang mereka gunakan.
Mereka mungkin melihat kami memiliki klien Minio, jadi mereka mungkin mulai dengan membuat antarmuka yang cocok dengan SEMUA metode dan penggunaan klien Minio (atau klien s3 lainnya). Mereka melupakan Pepatah Go [5][6] tentang “Semakin besar antarmuka, semakin lemah abstraksinya.”
Kami tidak perlu menguji klien Minio. Kita perlu menguji apakah kita bisa mendapatkan file dari jarak jauh atau lokal (dan memverifikasi beberapa jalur kegagalan, seperti kegagalan jarak jauh). Mari kita refactor pendekatan awal itu dan keluarkan klien Minio ke pengambil jarak jauh. Saat kita melakukannya, mari lakukan hal yang sama pada kode kita untuk membaca file lokal, dan buat pengambil lokal. Berikut adalah antarmuka dasar, dan kami akan memiliki tipe untuk mengimplementasikan masing-masing:
Dengan abstraksi ini, kami dapat memperbaiki implementasi awal kami. Kita akan menempatkan localFetcher dan remoteFetcher ke struct Getter dan refactor GetFile untuk menggunakannya. Lihat versi lengkap dari kode refactored di sini [7]. Di bawah ini adalah cuplikan yang sedikit disederhanakan menggunakan versi antarmuka baru:
Kode refactored baru ini jauh lebih dapat diuji unit karena kami menggunakan antarmuka sebagai parameter pada struct Getter dan kami dapat mengubah tipe konkret untuk palsu. Alih-alih mengejek panggilan OS atau membutuhkan ejekan penuh dari klien Minio atau antarmuka besar, kita hanya perlu dua pemalsuan sederhana: fakeLocalFetcher dan fakeRemoteFetcher .
Palsu ini memiliki beberapa properti yang memungkinkan kami menentukan apa yang mereka kembalikan. Kami akan dapat mengembalikan data file atau kesalahan apa pun yang kami suka dan kami dapat memverifikasi bahwa metode GetFile memanggil menangani data dan kesalahan seperti yang kami inginkan.
Dengan mengingat hal ini, inti dari tes menjadi:
Dengan struktur dasar ini, kita dapat membungkus semuanya dalam pengujian berbasis tabel [8]. Setiap kasus dalam tabel pengujian akan menguji akses file lokal atau jarak jauh. Kami akan dapat menyuntikkan kesalahan pada akses file jarak jauh atau lokal. Kami dapat memverifikasi kesalahan yang disebarkan, bahwa konten file dilewatkan, dan bahwa entri log yang diharapkan ada.
Saya melanjutkan dan menyertakan semua kasus uji dan permutasi potensial dalam pengujian satu tabel yang tersedia di sini [9] (Anda mungkin memperhatikan bahwa beberapa tanda tangan metode sedikit berbeda—ini memungkinkan kita untuk melakukan hal-hal seperti menyuntikkan logger dan menegaskan terhadap pernyataan log ).
Bagus, kan? Kami memiliki kendali penuh tentang bagaimana kami ingin GetFile berperilaku, dan kami dapat menegaskan terhadap hasilnya. Kami telah merancang kode kami agar ramah pengujian unit dan sekarang dapat memverifikasi jalur sukses dan kesalahan yang diterapkan dalam metode GetFile .
Kode digabungkan secara longgar dan refactoring di masa mendatang akan sangat mudah. Kami melakukan ini dengan menulis kode Go biasa yang harus dipahami dan diperluas oleh pengembang mana pun yang akrab dengan Go saat dibutuhkan.
Mocks: bagaimana dengan detail implementasi yang rumit dan rumit?
Apa yang akan diolok-olok membeli kita yang tidak kita dapatkan dalam solusi yang diusulkan? Pertanyaan bagus yang menunjukkan manfaat dari tiruan tradisional adalah, “bagaimana Anda tahu Anda memanggil klien s3 dengan parameter yang benar? Dengan ejekan, saya dapat memastikan bahwa saya meneruskan nilai kunci ke parameter kunci, dan bukan parameter ember.”
Ini adalah masalah yang valid dan harus dicakup dalam pengujian di suatu tempat . Pendekatan pengujian yang saya anjurkan di sini tidak memverifikasi bahwa Anda memanggil klien Minio dengan bucket dan parameter kunci dalam urutan yang benar.
Sebuah kutipan hebat yang baru-baru ini saya baca mengatakan, “Mengejek memperkenalkan asumsi, yang menimbulkan risiko [10]”. Anda mengasumsikan perpustakaan klien diimplementasikan dengan benar, Anda mengasumsikan semua batasan solid, Anda berasumsi Anda tahu bagaimana perpustakaan sebenarnya berperilaku.
Mengejek perpustakaan hanya mengolok-olok asumsi dan membuat pengujian Anda lebih rapuh dan dapat berubah ketika Anda memperbarui kode (yang disimpulkan Martin Fowler dalam Mocks Are't Stubs [3]). Ketika karet memenuhi jalan, kita harus memverifikasi bahwa kita benar-benar menggunakan klien Minio dengan benar dan ini berarti tes integrasi (ini mungkin hidup dalam pengaturan Docker atau lingkungan pengujian). Karena kami akan memiliki pengujian unit dan integrasi, pengujian unit tidak diperlukan untuk mencakup implementasi yang tepat karena pengujian integrasi akan mencakupnya.
Dalam contoh kami, pengujian unit memandu desain kode kami dan memungkinkan kami untuk dengan cepat menguji bahwa kesalahan dan alur logika berfungsi seperti yang dirancang, melakukan persis apa yang perlu mereka lakukan.
Bagi sebagian orang, mereka merasa bahwa cakupan unit test ini tidak cukup. Mereka khawatir tentang poin di atas. Beberapa akan bersikeras pada antarmuka gaya boneka Rusia di mana satu antarmuka mengembalikan antarmuka lain yang mengembalikan antarmuka lain, mungkin seperti berikut:
Dan kemudian mereka mungkin menarik keluar setiap bagian dari klien Minio ke dalam setiap pembungkus dan kemudian menggunakan generator tiruan (menambahkan dependensi untuk membangun dan menguji, meningkatkan asumsi, dan membuat segalanya lebih rapuh). Pada akhirnya, mockist akan dapat mengatakan sesuatu seperti:
myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) – dan itu jika Anda dapat mengingat mantra yang benar untuk DSL spesifik ini.
Ini akan menjadi banyak abstraksi ekstra yang terkait langsung dengan pilihan implementasi menggunakan klien Minio. Ini akan menyebabkan pengujian rapuh ketika kami mengetahui bahwa kami perlu mengubah asumsi kami tentang klien, atau membutuhkan klien yang sama sekali berbeda.
Ini menambah waktu pengembangan kode ujung-ke-ujung sekarang dan di masa depan, menambah kompleksitas kode dan mengurangi keterbacaan, berpotensi meningkatkan ketergantungan pada generator tiruan, dan memberi kita nilai tambahan yang meragukan untuk mengetahui apakah kita mencampur parameter bucket dan kunci yang akan kami temukan dalam pengujian integrasi.
Karena semakin banyak objek yang diperkenalkan, kopling menjadi semakin erat. Kami mungkin telah membuat tiruan logger dan kemudian kami mulai membuat tiruan metrik. Sebelum Anda menyadarinya, Anda menambahkan entri log atau metrik baru dan Anda baru saja memecahkan beberapa tes yang tidak mengharapkan metrik tambahan untuk dilakukan.
Terakhir kali saya digigit oleh ini di Go, kerangka kerja mengejek bahkan tidak akan memberi tahu saya tes atau file apa yang gagal karena panik dan mati dengan kematian yang mengerikan karena menemukan metrik baru (ini membutuhkan pencarian biner tes dengan mengomentarinya untuk dapat menemukan di mana kami perlu mengubah perilaku tiruan). Bisakah ejekan menambah nilai? Tentu. Apakah itu sepadan dengan biayanya? Dalam kebanyakan kasus, saya tidak yakin.
Antarmuka: kesederhanaan dan pengujian unit untuk menang
Kami telah menunjukkan bahwa kami dapat memandu desain dan memastikan kode yang tepat dan jalur kesalahan diikuti dengan penggunaan antarmuka yang sederhana di Go. Dengan menulis palsu sederhana yang mematuhi antarmuka, kita dapat melihat bahwa kita tidak memerlukan tiruan, kerangka kerja tiruan, atau generator tiruan untuk membuat kode yang dirancang untuk pengujian. Kami juga mencatat bahwa pengujian unit bukanlah segalanya, dan Anda harus menulis pengujian integrasi untuk memastikan bahwa sistem terintegrasi dengan benar satu sama lain.
Saya berharap mendapatkan posting tentang beberapa cara yang rapi untuk menjalankan tes integrasi di masa mendatang; Pantau terus!
Referensi
1: Endo-Testing: Pengujian Unit dengan Objek Mock (2000): Lihat pengantar untuk definisi objek tiruan
2: The Little Mocker: Lihat bagian tentang pemalsuan, khususnya, “Seorang Palsu memiliki perilaku bisnis. Anda dapat mendorong pemalsuan untuk berperilaku dengan cara yang berbeda dengan memberikan data yang berbeda.”
3: Mengolok-olok Bukankah Rintisan: Lihat bagian, “Jadi, haruskah saya menjadi seorang klasikis atau seorang mockist?” Martin Fowler menyatakan, "Saya tidak melihat manfaat menarik untuk TDD mockist, dan saya khawatir tentang konsekuensi dari tes kopling untuk implementasi."
4: Pendekatan Naif: versi kode yang disederhanakan. Lihat [7].
5: https://go-proverbs.github.io/: Daftar Peribahasa Go dengan tautan ke pembicaraan.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: Tautan langsung untuk berbicara oleh Rob Pike sehubungan dengan ukuran dan abstraksi antarmuka.
7: Versi lengkap kode demo: Anda dapat mengkloning repo dan menjalankan `go test`.
8: Tes berbasis tabel: Strategi pengujian untuk mengatur kode pengujian untuk mengurangi duplikasi.
9: Tes untuk versi lengkap dari kode demo. Anda dapat menjalankannya dengan `go test`.
10: Pertanyaan Untuk Diri Sendiri Saat Menulis Tes oleh Michal Charemza: Mengejek memperkenalkan asumsi, dan asumsi memperkenalkan risiko.