Tes Unit, Cara Menulis Kode yang Dapat Diuji dan Mengapa Itu Penting

Diterbitkan: 2022-03-11

Pengujian unit adalah instrumen penting dalam kotak peralatan dari setiap pengembang perangkat lunak yang serius. Namun, terkadang cukup sulit untuk menulis unit test yang baik untuk bagian kode tertentu. Mengalami kesulitan menguji kode mereka sendiri atau orang lain, pengembang sering berpikir bahwa perjuangan mereka disebabkan oleh kurangnya pengetahuan pengujian mendasar atau teknik pengujian unit rahasia.

Dalam tutorial pengujian unit ini, saya bermaksud untuk menunjukkan bahwa pengujian unit cukup mudah; masalah nyata yang memperumit pengujian unit, dan menimbulkan kompleksitas yang mahal, adalah hasil dari kode yang dirancang dengan buruk dan tidak dapat diuji . Kami akan membahas apa yang membuat kode sulit untuk diuji, anti-pola dan praktik buruk mana yang harus kami hindari untuk meningkatkan kemampuan pengujian, dan manfaat lain apa yang dapat kami capai dengan menulis kode yang dapat diuji. Kita akan melihat bahwa menulis pengujian unit dan menghasilkan kode yang dapat diuji bukan hanya tentang membuat pengujian tidak terlalu merepotkan, tetapi tentang membuat kode itu sendiri lebih kuat, dan lebih mudah untuk dipelihara.

Tutorial pengujian unit: ilustrasi sampul

Apa itu Pengujian Unit?

Pada dasarnya, pengujian unit adalah metode yang membuat instance sebagian kecil dari aplikasi kita dan memverifikasi perilakunya secara independen dari bagian lain . Tes unit tipikal berisi 3 fase: Pertama, inisialisasi bagian kecil dari aplikasi yang ingin diuji (juga dikenal sebagai sistem yang sedang diuji, atau SUT), kemudian menerapkan beberapa stimulus ke sistem yang diuji (biasanya dengan memanggil a metode di atasnya), dan akhirnya, mengamati perilaku yang dihasilkan. Jika perilaku yang diamati konsisten dengan harapan, tes unit lulus, jika tidak, gagal, menunjukkan bahwa ada masalah di suatu tempat di sistem yang diuji. Ketiga fase uji unit ini juga dikenal sebagai Atur, Bertindak, dan Tegaskan, atau hanya AAA.

Tes unit dapat memverifikasi aspek perilaku yang berbeda dari sistem yang diuji, tetapi kemungkinan besar itu akan jatuh ke dalam salah satu dari dua kategori berikut: berbasis negara atau berbasis interaksi . Memverifikasi bahwa sistem yang diuji menghasilkan hasil yang benar, atau bahwa status yang dihasilkannya benar, disebut pengujian unit berbasis status , sementara memverifikasi bahwa sistem tersebut memanggil metode tertentu dengan benar disebut pengujian unit berbasis interaksi .

Sebagai metafora untuk pengujian unit perangkat lunak yang tepat, bayangkan seorang ilmuwan gila yang ingin membangun semacam chimera supernatural, dengan kaki katak, tentakel gurita, sayap burung, dan kepala anjing. (Metafora ini cukup dekat dengan apa yang sebenarnya dilakukan programmer di tempat kerja). Bagaimana ilmuwan itu memastikan bahwa setiap bagian (atau unit) yang dia pilih benar-benar berfungsi? Yah, dia bisa mengambil, katakanlah, satu kaki katak, berikan stimulus listrik padanya, dan periksa kontraksi otot yang tepat. Apa yang dia lakukan pada dasarnya adalah langkah-langkah Atur-Bertindak-Tegaskan yang sama dari unit test; satu-satunya perbedaan adalah, dalam hal ini, unit mengacu pada objek fisik, bukan objek abstrak tempat kita membangun program.

apa itu pengujian unit: ilustrasi

Saya akan menggunakan C# untuk semua contoh dalam artikel ini, tetapi konsep yang dijelaskan berlaku untuk semua bahasa pemrograman berorientasi objek.

Contoh pengujian unit sederhana dapat terlihat seperti ini:

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

Tes Unit vs. Tes Integrasi

Hal penting lainnya yang perlu dipertimbangkan adalah perbedaan antara pengujian unit dan pengujian integrasi.

Tujuan pengujian unit dalam rekayasa perangkat lunak adalah untuk memverifikasi perilaku perangkat lunak yang relatif kecil, secara independen dari bagian lain. Tes unit memiliki cakupan yang sempit, dan memungkinkan kami untuk mencakup semua kasus, memastikan bahwa setiap bagian bekerja dengan benar.

Di sisi lain, tes integrasi menunjukkan bahwa bagian yang berbeda dari suatu sistem bekerja sama dalam lingkungan kehidupan nyata . Mereka memvalidasi skenario kompleks (kita dapat menganggap tes integrasi sebagai pengguna yang melakukan beberapa operasi tingkat tinggi dalam sistem kami), dan biasanya memerlukan sumber daya eksternal, seperti database atau server web, untuk hadir.

Mari kita kembali ke metafora ilmuwan gila kita, dan anggaplah dia telah berhasil menggabungkan semua bagian dari chimera. Dia ingin melakukan tes integrasi dari makhluk yang dihasilkan, memastikan bahwa ia dapat, katakanlah, berjalan di berbagai jenis medan. Pertama-tama, ilmuwan harus meniru lingkungan tempat makhluk itu berjalan. Kemudian, dia melemparkan makhluk itu ke lingkungan itu dan menusuknya dengan tongkat, mengamati apakah makhluk itu berjalan dan bergerak sesuai rencana. Setelah menyelesaikan tes, ilmuwan gila membersihkan semua kotoran, pasir, dan batu yang sekarang berserakan di laboratoriumnya yang indah.

ilustrasi contoh pengujian unit

Perhatikan perbedaan yang signifikan antara pengujian unit dan integrasi: Pengujian unit memverifikasi perilaku sebagian kecil aplikasi, terisolasi dari lingkungan dan bagian lain, dan cukup mudah diterapkan, sedangkan pengujian integrasi mencakup interaksi antara komponen yang berbeda, di lingkungan yang dekat dengan kehidupan nyata, dan membutuhkan lebih banyak usaha, termasuk penyiapan tambahan dan fase pembongkaran.

Kombinasi yang wajar dari pengujian unit dan integrasi memastikan bahwa setiap unit bekerja dengan benar, terlepas dari yang lain, dan bahwa semua unit ini berfungsi dengan baik saat terintegrasi, memberi kami tingkat kepercayaan yang tinggi bahwa seluruh sistem bekerja seperti yang diharapkan.

Namun, kita harus ingat untuk selalu mengidentifikasi jenis tes yang kita terapkan: tes unit atau integrasi. Perbedaan terkadang bisa menipu. Jika kami pikir kami sedang menulis tes unit untuk memverifikasi beberapa kasus tepi halus di kelas logika bisnis, dan menyadari bahwa itu memerlukan sumber daya eksternal seperti layanan web atau database untuk hadir, ada sesuatu yang tidak beres — pada dasarnya, kami menggunakan palu godam untuk memecahkan kacang. Dan itu berarti desain yang buruk.

Apa yang Membuat Tes Unit yang Baik?

Sebelum masuk ke bagian utama dari tutorial ini dan menulis unit test, mari kita bahas dengan cepat sifat-sifat unit test yang baik. Prinsip pengujian unit menuntut bahwa pengujian yang baik adalah:

  • Mudah untuk menulis. Pengembang biasanya menulis banyak pengujian unit untuk mencakup berbagai kasus dan aspek perilaku aplikasi, jadi seharusnya mudah untuk membuat kode semua rutinitas pengujian tersebut tanpa banyak usaha.

  • Dapat dibaca. Maksud dari tes unit harus jelas. Pengujian unit yang baik menceritakan sebuah cerita tentang beberapa aspek perilaku aplikasi kita, jadi seharusnya mudah untuk memahami skenario mana yang sedang diuji dan — jika pengujian gagal — mudah untuk mendeteksi cara mengatasi masalah. Dengan pengujian unit yang baik, kita dapat memperbaiki bug tanpa benar-benar men-debug kode!

  • Dapat diandalkan. Tes unit harus gagal hanya jika ada bug dalam sistem yang sedang diuji. Tampaknya cukup jelas, tetapi programmer sering mengalami masalah ketika pengujian mereka gagal bahkan ketika tidak ada bug yang diperkenalkan. Misalnya, pengujian mungkin lulus saat menjalankan satu per satu, tetapi gagal saat menjalankan seluruh rangkaian pengujian, atau meneruskan mesin pengembangan kami dan gagal di server integrasi berkelanjutan. Situasi ini merupakan indikasi dari cacat desain. Tes unit yang baik harus dapat direproduksi dan independen dari faktor eksternal seperti lingkungan atau urutan yang berjalan.

  • Cepat. Pengembang menulis pengujian unit sehingga mereka dapat menjalankannya berulang kali dan memeriksa bahwa tidak ada bug yang diperkenalkan. Jika pengujian unit lambat, pengembang cenderung melewatkan menjalankannya di mesin mereka sendiri. Satu tes lambat tidak akan membuat perbedaan yang signifikan; tambahkan seribu lagi dan kita pasti terjebak menunggu beberapa saat. Pengujian unit yang lambat juga dapat menunjukkan bahwa sistem yang diuji, atau pengujian itu sendiri, berinteraksi dengan sistem eksternal, membuatnya bergantung pada lingkungan.

  • Benar-benar kesatuan, bukan integrasi. Seperti yang telah kita bahas, pengujian unit dan integrasi memiliki tujuan yang berbeda. Baik pengujian unit maupun sistem yang diuji tidak boleh mengakses sumber daya jaringan, database, sistem file, dll., untuk menghilangkan pengaruh faktor eksternal.

Itu saja — tidak ada rahasia untuk menulis unit test . Namun, ada beberapa teknik yang memungkinkan kita untuk menulis kode yang dapat diuji .

Kode yang Dapat Diuji dan Tidak Dapat Diuji

Beberapa kode ditulis sedemikian rupa sehingga sulit, atau bahkan tidak mungkin, untuk menulis unit test yang baik untuknya. Jadi, apa yang membuat kode sulit untuk diuji? Mari kita tinjau beberapa anti-pola, bau kode, dan praktik buruk yang harus kita hindari saat menulis kode yang dapat diuji.

Meracuni Basis Kode dengan Faktor Non-Deterministik

Mari kita mulai dengan contoh sederhana. Bayangkan kita sedang menulis program untuk mikrokontroler rumah pintar, dan salah satu persyaratannya adalah menyalakan lampu di halaman belakang secara otomatis jika ada gerakan yang terdeteksi di sana pada sore atau malam hari. Kami telah memulai dari bawah ke atas dengan menerapkan metode yang mengembalikan representasi string dari perkiraan waktu dalam sehari (“Malam”, “Pagi”, “Siang” atau “Malam”):

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

Pada dasarnya, metode ini membaca waktu sistem saat ini dan mengembalikan hasil berdasarkan nilai tersebut. Jadi, apa yang salah dengan kode ini?

Jika kita memikirkannya dari perspektif pengujian unit, kita akan melihat bahwa tidak mungkin untuk menulis pengujian unit berbasis status yang tepat untuk metode ini. DateTime.Now , pada dasarnya, adalah input tersembunyi, yang mungkin akan berubah selama eksekusi program atau di antara pengujian. Dengan demikian, panggilan selanjutnya akan menghasilkan hasil yang berbeda.

Perilaku non-deterministik seperti itu membuat pengujian logika internal metode GetTimeOfDay() tidak mungkin dilakukan tanpa benar-benar mengubah tanggal dan waktu sistem. Mari kita lihat bagaimana tes tersebut perlu diterapkan:

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

Tes seperti ini akan melanggar banyak aturan yang dibahas sebelumnya. Akan mahal untuk menulis (karena pengaturan non-sepele dan logika teardown), tidak dapat diandalkan (mungkin gagal bahkan jika tidak ada bug dalam sistem yang diuji, karena masalah izin sistem, misalnya), dan tidak dijamin untuk lari cepat. Dan, akhirnya, pengujian ini sebenarnya bukan pengujian unit — ini akan menjadi sesuatu antara pengujian unit dan integrasi, karena pengujian ini berpura-pura menguji kasus tepi sederhana tetapi memerlukan lingkungan yang harus disiapkan dengan cara tertentu. Hasilnya tidak sepadan dengan usaha, ya?

Ternyata semua masalah testabilitas ini disebabkan oleh API GetTimeOfDay() berkualitas rendah. Dalam bentuknya saat ini, metode ini mengalami beberapa masalah:

  • Hal ini erat digabungkan ke sumber data konkret. Metode ini tidak dapat digunakan kembali untuk memproses tanggal dan waktu yang diambil dari sumber lain, atau diteruskan sebagai argumen; metode ini hanya berfungsi dengan tanggal dan waktu mesin tertentu yang mengeksekusi kode. Kopling ketat adalah akar utama dari sebagian besar masalah testabilitas.

  • Itu melanggar Prinsip Tanggung Jawab Tunggal (SRP). Metode ini memiliki banyak tanggung jawab; ia mengkonsumsi informasi dan juga memprosesnya. Indikator lain dari pelanggaran SRP adalah ketika satu kelas atau metode memiliki lebih dari satu alasan untuk berubah . Dari perspektif ini, metode GetTimeOfDay() dapat diubah baik karena penyesuaian logika internal, atau karena sumber tanggal dan waktu harus diubah.

  • Itu terletak tentang informasi yang dibutuhkan untuk menyelesaikan pekerjaannya. Pengembang harus membaca setiap baris kode sumber yang sebenarnya untuk memahami input tersembunyi apa yang digunakan dan dari mana asalnya. Tanda tangan metode saja tidak cukup untuk memahami perilaku metode.

  • Sulit untuk diprediksi dan dipertahankan. Perilaku metode yang bergantung pada keadaan global yang dapat berubah tidak dapat diprediksi hanya dengan membaca kode sumber; perlu memperhitungkan nilainya saat ini, bersama dengan seluruh rangkaian peristiwa yang dapat mengubahnya lebih awal. Dalam aplikasi dunia nyata, mencoba mengungkap semua hal itu menjadi sakit kepala yang nyata.

Setelah meninjau API, akhirnya mari kita perbaiki! Untungnya, ini jauh lebih mudah daripada membahas semua kekurangannya — kita hanya perlu memecahkan masalah yang terkait erat.

Memperbaiki API: Memperkenalkan Argumen Metode

Cara yang paling jelas dan mudah untuk memperbaiki API adalah dengan memperkenalkan argumen metode:

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

Sekarang metode ini mengharuskan pemanggil untuk memberikan argumen DateTime , alih-alih mencari informasi ini secara diam-diam. Dari perspektif pengujian unit, ini bagus; metode ini sekarang deterministik (yaitu, nilai pengembaliannya sepenuhnya bergantung pada input), jadi pengujian berbasis status semudah melewatkan beberapa nilai DateTime dan memeriksa hasilnya:

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

Perhatikan bahwa refactor sederhana ini juga menyelesaikan semua masalah API yang dibahas sebelumnya (penggabungan ketat, pelanggaran SRP, API yang tidak jelas dan sulit dipahami) dengan memperkenalkan lapisan yang jelas antara data apa yang harus diproses dan bagaimana hal itu harus dilakukan.

Luar biasa — metode ini dapat diuji, tetapi bagaimana dengan kliennya ? Sekarang adalah tanggung jawab penelepon untuk memberikan tanggal dan waktu ke metode GetTimeOfDay(DateTime dateTime) , yang berarti bahwa mereka dapat menjadi tidak dapat diuji jika kita tidak memberikan perhatian yang cukup. Mari kita lihat bagaimana kita bisa menghadapinya.

Memperbaiki API Klien: Injeksi Ketergantungan

Katakanlah kita terus bekerja pada sistem rumah pintar, dan menerapkan klien berikut dari metode GetTimeOfDay(DateTime dateTime) — kode mikrokontroler rumah pintar yang disebutkan di atas yang bertanggung jawab untuk menyalakan atau mematikan lampu, berdasarkan waktu dan deteksi gerakan :

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

Aduh! Kami memiliki jenis masalah input DateTime.Now tersembunyi yang sama — satu-satunya perbedaan adalah terletak pada level abstraksi yang sedikit lebih tinggi. Untuk mengatasi masalah ini, kami dapat memperkenalkan argumen lain, sekali lagi mendelegasikan tanggung jawab memberikan nilai DateTime ke pemanggil metode baru dengan tanda tangan ActuateLights(bool motionDetected, DateTime dateTime) . Namun, alih-alih memindahkan masalah ke tingkat yang lebih tinggi dalam tumpukan panggilan sekali lagi, mari gunakan teknik lain yang memungkinkan kita untuk menjaga ActuateLights(bool motionDetected) dan kliennya dapat diuji: Inversion of Control, atau IoC.

Inversi Kontrol adalah teknik sederhana, tetapi sangat berguna, untuk memisahkan kode, dan khususnya untuk pengujian unit. (Lagi pula, menjaga hal-hal digabungkan secara longgar sangat penting untuk dapat menganalisisnya secara independen satu sama lain.) Poin kunci dari IoC adalah untuk memisahkan kode pengambilan keputusan ( kapan harus melakukan sesuatu) dari kode tindakan ( apa yang harus dilakukan ketika sesuatu terjadi ). Teknik ini meningkatkan fleksibilitas, membuat kode kita lebih modular, dan mengurangi sambungan antar komponen.

Pembalikan Kontrol dapat diimplementasikan dalam beberapa cara; mari kita lihat satu contoh tertentu — Injeksi Ketergantungan menggunakan konstruktor — dan bagaimana hal itu dapat membantu dalam membangun API SmartHomeController yang dapat diuji.

Pertama, mari buat antarmuka IDateTimeProvider , yang berisi tanda tangan metode untuk mendapatkan beberapa tanggal dan waktu:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

Kemudian, buat referensi IDateTimeProvider SmartHomeController dan delegasikan tanggung jawab untuk mendapatkan tanggal dan waktu:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

Sekarang kita dapat melihat mengapa Inversion of Control disebut demikian: kontrol dari mekanisme apa yang digunakan untuk membaca tanggal dan waktu telah dibalik , dan sekarang menjadi milik klien SmartHomeController , bukan SmartHomeController itu sendiri. Dengan demikian, eksekusi metode ActuateLights(bool motionDetected) sepenuhnya bergantung pada dua hal yang dapat dengan mudah dikelola dari luar: argumen motionDetected , dan implementasi konkret IDateTimeProvider , diteruskan ke konstruktor SmartHomeController .

Mengapa ini penting untuk pengujian unit? Ini berarti bahwa implementasi IDateTimeProvider yang berbeda dapat digunakan dalam kode produksi dan kode pengujian unit. Di lingkungan produksi, beberapa implementasi kehidupan nyata akan disuntikkan (misalnya, yang membaca waktu sistem aktual). Namun, dalam pengujian unit, kita dapat menyuntikkan implementasi "palsu" yang mengembalikan nilai DateTime konstan atau yang telah ditentukan sebelumnya yang cocok untuk menguji skenario tertentu.

Implementasi palsu dari IDateTimeProvider dapat terlihat seperti ini:

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

Dengan bantuan kelas ini, dimungkinkan untuk mengisolasi SmartHomeController dari faktor non-deterministik dan melakukan pengujian unit berbasis status. Mari kita verifikasi bahwa, jika gerakan terdeteksi, waktu gerakan itu dicatat di properti LastMotionTime :

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

Besar! Tes seperti ini tidak mungkin dilakukan sebelum refactoring. Sekarang setelah kami menghilangkan faktor non-deterministik dan memverifikasi skenario berbasis negara bagian, apakah menurut Anda SmartHomeController sepenuhnya dapat diuji?

Meracuni Basis Kode dengan Efek Samping

Terlepas dari kenyataan bahwa kami memecahkan masalah yang disebabkan oleh input tersembunyi non-deterministik, dan kami dapat menguji fungsionalitas tertentu, kode (atau, setidaknya, sebagian) masih belum dapat diuji!

Mari kita tinjau bagian berikut dari metode ActuateLights(bool motionDetected) yang bertanggung jawab untuk menyalakan atau mematikan lampu:

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

Seperti yang bisa kita lihat, SmartHomeController mendelegasikan tanggung jawab menyalakan atau mematikan lampu ke objek BackyardLightSwitcher , yang mengimplementasikan pola Singleton. Apa yang salah dengan desain ini?

Untuk menguji unit sepenuhnya metode ActuateLights(bool motionDetected) , kita harus melakukan pengujian berbasis interaksi selain pengujian berbasis status; yaitu, kita harus memastikan bahwa metode untuk menyalakan atau mematikan lampu dipanggil jika, dan hanya jika, kondisi yang sesuai terpenuhi. Sayangnya, desain saat ini tidak memungkinkan kita untuk melakukan itu: metode TurnOn() dan TurnOff TurnOff() dari BackyardLightSwitcher memicu beberapa perubahan status dalam sistem, atau, dengan kata lain, menghasilkan efek samping . Satu-satunya cara untuk memverifikasi bahwa metode ini dipanggil adalah untuk memeriksa apakah efek samping yang sesuai benar-benar terjadi atau tidak, yang bisa menyakitkan.

Memang, misalkan sensor gerak, lentera halaman belakang, dan mikrokontroler rumah pintar terhubung ke jaringan Internet of Things dan berkomunikasi menggunakan beberapa protokol nirkabel. Dalam hal ini, pengujian unit dapat mencoba menerima dan menganalisis lalu lintas jaringan tersebut. Atau, jika komponen perangkat keras dihubungkan dengan kabel, pengujian unit dapat memeriksa apakah tegangan diterapkan ke rangkaian listrik yang sesuai. Atau, bagaimanapun, dapat memeriksa apakah lampu benar-benar menyala atau mati menggunakan sensor cahaya tambahan.

Seperti yang dapat kita lihat, metode efek samping pengujian unit bisa sesulit pengujian unit yang non-deterministik, dan bahkan mungkin tidak mungkin. Upaya apa pun akan menyebabkan masalah yang serupa dengan yang telah kita lihat. Tes yang dihasilkan akan sulit diterapkan, tidak dapat diandalkan, berpotensi lambat, dan tidak benar-benar unit. Dan, setelah semua itu, kilatan cahaya setiap kali kita menjalankan test suite pada akhirnya akan membuat kita gila!

Sekali lagi, semua masalah pengujian ini disebabkan oleh API yang buruk, bukan kemampuan pengembang untuk menulis pengujian unit. Tidak peduli seberapa tepatnya kontrol cahaya diterapkan, API SmartHomeController mengalami masalah yang sudah biasa ini:

  • Hal ini erat digabungkan dengan implementasi konkret. API bergantung pada hard-coded, instance konkret dari BackyardLightSwitcher . Metode ActuateLights(bool motionDetected) tidak dapat digunakan kembali untuk mengganti lampu selain yang ada di halaman belakang.

  • Itu melanggar Prinsip Tanggung Jawab Tunggal. API memiliki dua alasan untuk berubah: Pertama, perubahan logika internal (seperti memilih untuk menyalakan lampu hanya di malam hari, tetapi tidak di malam hari) dan kedua, jika mekanisme sakelar lampu diganti dengan yang lain.

  • Itu terletak tentang ketergantungannya. Tidak ada cara bagi pengembang untuk mengetahui bahwa SmartHomeController bergantung pada komponen BackyardLightSwitcher yang dikodekan dengan keras, selain menggali ke dalam kode sumber.

  • Sulit untuk dipahami dan dipertahankan. Bagaimana jika lampu menolak untuk menyala ketika kondisinya tepat? Kami dapat menghabiskan banyak waktu untuk mencoba memperbaiki SmartHomeController tidak berhasil, hanya untuk menyadari bahwa masalahnya disebabkan oleh bug di BackyardLightSwitcher (atau, bahkan lebih lucu, bola lampu yang terbakar!).

Solusi dari masalah testabilitas dan API berkualitas rendah adalah, tidak mengherankan, untuk memisahkan komponen yang digabungkan erat satu sama lain. Seperti contoh sebelumnya, menggunakan Injeksi Ketergantungan akan menyelesaikan masalah ini; cukup tambahkan ketergantungan ILightSwitcher ke SmartHomeController , delegasikan tanggung jawab membalik sakelar lampu, dan berikan implementasi ILightSwitcher palsu, hanya uji yang akan merekam apakah metode yang sesuai dipanggil dalam kondisi yang tepat. Namun, daripada menggunakan Injeksi Ketergantungan lagi, mari kita tinjau pendekatan alternatif yang menarik untuk memisahkan tanggung jawab.

Memperbaiki API: Fungsi Tingkat Tinggi

Pendekatan ini adalah opsi dalam bahasa berorientasi objek apa pun yang mendukung fungsi kelas satu . Mari kita manfaatkan fitur fungsional C# dan membuat metode ActuateLights(bool motionDetected) menerima dua argumen lagi: sepasang delegasi Action , menunjuk ke metode yang harus dipanggil untuk menyalakan dan mematikan lampu. Solusi ini akan mengubah metode menjadi fungsi tingkat tinggi :

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

Ini adalah solusi rasa yang lebih fungsional daripada pendekatan Injeksi Ketergantungan berorientasi objek klasik yang telah kita lihat sebelumnya; namun, ini memungkinkan kita mencapai hasil yang sama dengan lebih sedikit kode, dan lebih ekspresif, daripada Injeksi Ketergantungan. Tidak perlu lagi mengimplementasikan kelas yang sesuai dengan antarmuka untuk memasok SmartHomeController dengan fungsionalitas yang diperlukan; sebagai gantinya, kita bisa melewati definisi fungsi. Fungsi tingkat tinggi dapat dianggap sebagai cara lain untuk mengimplementasikan Inversion of Control.

Sekarang, untuk melakukan pengujian unit berbasis interaksi dari metode yang dihasilkan, kita dapat memasukkan tindakan palsu yang dapat diverifikasi dengan mudah ke dalamnya:

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

Terakhir, kami telah membuat SmartHomeController API sepenuhnya dapat diuji, dan kami dapat melakukan pengujian unit berbasis status dan interaksi untuknya. Sekali lagi, perhatikan bahwa selain meningkatkan testabilitas, memperkenalkan jahitan antara pengambilan keputusan dan kode tindakan membantu memecahkan masalah kopling ketat, dan menghasilkan API yang lebih bersih dan dapat digunakan kembali.

Sekarang, untuk mencapai cakupan pengujian unit penuh, kita cukup menerapkan sekelompok pengujian yang tampak serupa untuk memvalidasi semua kemungkinan kasus — bukan masalah besar karena pengujian unit sekarang cukup mudah untuk diterapkan.

Ketidakmurnian dan Testabilitas

Non-determinisme dan efek samping yang tidak terkendali serupa dalam efek destruktifnya pada basis kode. Ketika digunakan dengan sembarangan, mereka mengarah ke kode yang menipu, sulit untuk dipahami dan dipelihara, digabungkan dengan erat, tidak dapat digunakan kembali, dan tidak dapat diuji.

Di sisi lain, metode yang bersifat deterministik dan bebas efek samping jauh lebih mudah untuk diuji, dipikirkan, dan digunakan kembali untuk membangun program yang lebih besar. Dalam hal pemrograman fungsional, metode seperti itu disebut fungsi murni . Kami jarang memiliki unit masalah yang menguji fungsi murni; yang harus kita lakukan adalah memberikan beberapa argumen dan memeriksa kebenarannya. Apa yang benar-benar membuat kode tidak dapat diuji adalah kode keras, faktor tidak murni yang tidak dapat diganti, diganti, atau diabstraksikan dengan cara lain.

Pengotor beracun: jika metode Foo() bergantung pada metode non-deterministik atau efek samping Bar() , maka Foo() menjadi non-deterministik atau efek samping juga. Akhirnya, kita mungkin akan meracuni seluruh basis kode. Lipat gandakan semua masalah ini dengan ukuran aplikasi kehidupan nyata yang kompleks, dan kita akan menemukan diri kita dibebani dengan basis kode yang sulit dipelihara yang penuh dengan bau, anti-pola, ketergantungan rahasia, dan segala macam hal buruk dan tidak menyenangkan.

contoh pengujian unit: ilustrasi

Namun, kenajisan tidak bisa dihindari; setiap aplikasi kehidupan nyata harus, pada titik tertentu, membaca dan memanipulasi keadaan dengan berinteraksi dengan lingkungan, database, file konfigurasi, layanan web, atau sistem eksternal lainnya. Jadi, alih-alih bertujuan untuk menghilangkan pengotor sama sekali, adalah ide yang baik untuk membatasi faktor-faktor ini, menghindari membiarkan mereka meracuni basis kode Anda, dan menghancurkan dependensi hard-code sebanyak mungkin, agar dapat menganalisis dan menguji unit secara mandiri.

Tanda Peringatan Umum dari Kode yang Sulit Diuji

Kesulitan menulis tes? Masalahnya bukan di test suite Anda. Itu ada dalam kode Anda.
Menciak

Terakhir, mari kita tinjau beberapa tanda peringatan umum yang menunjukkan bahwa kode kita mungkin sulit untuk diuji.

Properti dan Bidang Statis

Properti dan bidang statis atau, sederhananya, keadaan global, dapat memperumit pemahaman kode dan kemampuan pengujian, dengan menyembunyikan informasi yang diperlukan suatu metode untuk menyelesaikan tugasnya, dengan memperkenalkan non-determinisme, atau dengan mempromosikan penggunaan efek samping yang ekstensif. Fungsi yang membaca atau memodifikasi keadaan global yang dapat berubah secara inheren tidak murni.

Misalnya, sulit untuk mempertimbangkan kode berikut, yang bergantung pada properti yang dapat diakses secara global:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

Bagaimana jika metode HeatWater() tidak dipanggil padahal kita yakin seharusnya demikian? Karena setiap bagian dari aplikasi mungkin telah mengubah nilai CostSavingEnabled , kita harus menemukan dan menganalisis semua tempat yang mengubah nilai itu untuk mencari tahu apa yang salah. Juga, seperti yang telah kita lihat, tidak mungkin untuk menetapkan beberapa properti statis untuk tujuan pengujian (misalnya, DateTime.Now , atau Environment.MachineName ; mereka hanya-baca, tetapi masih non-deterministik).

Di sisi lain, keadaan global yang tidak berubah dan deterministik sepenuhnya baik-baik saja. Sebenarnya, ada nama yang lebih familiar untuk ini — konstanta. Nilai konstan seperti Math.PI tidak memperkenalkan non-determinisme apa pun, dan, karena nilainya tidak dapat diubah, tidak mengizinkan efek samping apa pun:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

lajang

Pada dasarnya, pola Singleton hanyalah bentuk lain dari negara global. Lajang mempromosikan API yang tidak jelas yang berbohong tentang dependensi nyata dan memperkenalkan kopling ketat yang tidak perlu antar komponen. Mereka juga melanggar Prinsip Tanggung Jawab Tunggal karena, selain tugas utama mereka, mereka mengontrol inisialisasi dan siklus hidup mereka sendiri.

Para lajang dapat dengan mudah membuat pengujian unit bergantung pada pesanan karena mereka membawa status selama masa pakai seluruh aplikasi atau rangkaian pengujian unit. Lihat contoh berikut:

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

Static Methods

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. Sebagai contoh:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.