
Pada artikel ini, saya ingin menawarkan alternatif untuk gaya desain tes tradisional menggunakan konsep pemrograman fungsional Scala. Pendekatan ini diilhami oleh rasa sakit selama berbulan-bulan dari dukungan puluhan dan ratusan ujian yang jatuh dan keinginan yang membara untuk menjadikannya lebih mudah dan lebih mudah dimengerti.
Terlepas dari kenyataan bahwa kode tersebut ditulis dalam Scala, ide-ide yang diusulkan akan relevan untuk pengembang dan penguji dalam semua bahasa yang mendukung paradigma pemrograman fungsional. Anda dapat menemukan tautan ke Github dengan solusi dan contoh lengkap di akhir artikel.
Masalahnya
Jika Anda pernah berurusan dengan tes (itu tidak masalah - tes unit, integrasi atau fungsional), kemungkinan besar itu ditulis sebagai serangkaian instruksi berurutan. Sebagai contoh:
Ini adalah yang paling disukai untuk sebagian besar, tidak memerlukan pengembangan, cara untuk menggambarkan tes. Proyek kami memiliki sekitar 1000 tes tingkat yang berbeda (tes unit, tes integrasi, ujung ke ujung), dan semuanya, hingga baru-baru ini, ditulis dengan gaya yang sama. Ketika proyek tumbuh, kami mulai merasakan masalah yang signifikan dan perlambatan dengan dukungan tes-tes semacam itu: mengerjakan tes tidak memakan waktu lebih lama daripada menulis kode yang relevan dengan bisnis.
Saat menulis tes baru, Anda harus selalu berpikir dari awal bagaimana menyiapkan data. Seringkali salin-tempel langkah-langkah dari tes tetangga. Akibatnya, ketika model data dalam aplikasi berubah, rumah kartu hancur dan harus dikumpulkan dengan cara baru di setiap tes: terbaik, hanya perubahan fungsi pembantu, paling buruk - perendaman mendalam dalam tes dan menulis ulang.
Ketika tes jatuh dengan jujur - yaitu, karena bug dalam logika bisnis, dan bukan karena masalah dalam tes itu sendiri - untuk memahami di mana ada yang salah, tanpa debugging, tidak mungkin. Karena kenyataan bahwa butuh waktu lama untuk memahami tes, tidak ada yang sepenuhnya memiliki pengetahuan tentang persyaratan - bagaimana sistem harus berperilaku dalam kondisi tertentu.
Semua rasa sakit ini adalah gejala dari dua masalah yang lebih dalam dari desain ini:
- Isi tes diizinkan dalam bentuk yang terlalu longgar. Setiap tes unik, seperti kepingan salju. Kebutuhan untuk membaca detail tes membutuhkan banyak waktu dan kehilangan motivasi. Detail tidak penting mengalihkan perhatian dari hal utama - persyaratan diverifikasi oleh tes. Copy paste menjadi cara utama untuk menulis test case baru.
- Tes tidak membantu pengembang melokalisasi bug, tetapi hanya menandakan masalah. Untuk memahami keadaan di mana tes dilakukan, Anda harus mengembalikannya di kepala Anda atau terhubung dengan debugger.
Pemodelan
Bisakah kita berbuat lebih baik? (Spoiler: kita bisa.) Mari kita lihat apa yang terdiri dari tes ini.
val db: Database = Database.forURL(TestConfig.generateNewUrl()) migrateDb(db) insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40)
Kode yang diuji, sebagai suatu peraturan, akan menunggu beberapa parameter eksplisit untuk masuk - pengidentifikasi, ukuran, volume, filter, dll. Selain itu, sering kali perlu data dari dunia nyata - kita melihat bahwa aplikasi merujuk ke menu dan templat menu basis data. Untuk pelaksanaan pengujian yang andal, kita perlu fixture - keadaan di mana sistem dan / atau penyedia data harus berada sebelum pengujian dimulai dan parameter input, sering terkait dengan keadaan.
Kami akan menyiapkan dependensi dengan fixture ini - isi database (antrian, layanan eksternal, dll.). Dengan ketergantungan yang disiapkan, kami menginisialisasi kelas yang diuji (layanan, modul, repositori, dll.).
val svc = new SomeProductionLogic(db) val result = svc.calculatePrice(packageId = 1)
Dengan mengeksekusi kode uji pada beberapa parameter input, kami mendapatkan hasil ( output ) bisnis-signifikan - baik eksplisit (dikembalikan oleh metode) dan implisit - perubahan dalam kondisi terkenal: database, layanan eksternal, dll.
result shouldBe 90
Akhirnya, kami memverifikasi bahwa hasilnya persis seperti yang mereka harapkan, menyimpulkan tes dengan satu atau lebih pernyataan .

Dapat disimpulkan bahwa, secara umum, tes terdiri dari tahapan yang sama: menyiapkan parameter input, mengeksekusi kode tes pada mereka, dan membandingkan hasilnya dengan yang diharapkan. Kita dapat menggunakan fakta ini untuk menyingkirkan masalah pertama dalam tes - bentuk terlalu longgar, membagi tes menjadi beberapa tahap. Gagasan ini bukanlah hal baru dan telah lama digunakan dalam pengujian dengan gaya BDD ( pengembangan yang didorong oleh perilaku ).
Bagaimana dengan ekstensibilitas? Salah satu langkah dalam proses pengujian dapat berisi langkah-langkah antara sebanyak yang Anda suka. Ke depan, kita bisa membentuk fixture, pertama menciptakan semacam struktur yang dapat dibaca manusia, dan kemudian mengubahnya menjadi objek yang mengisi database. Proses pengujian dapat diperluas tanpa batas, tetapi, pada akhirnya, selalu turun ke tahap utama.

Menjalankan tes
Mari kita coba mewujudkan ide untuk membagi tes menjadi beberapa tahapan, tetapi pertama-tama kita tentukan bagaimana kita ingin melihat hasil akhirnya.
Secara umum, kami ingin membuat tes menulis dan mendukung proses yang kurang padat karya dan lebih menyenangkan. Instruksi non-unik (diulangi di tempat lain) yang kurang eksplisit di dalam tubuh tes, semakin sedikit perubahan yang perlu dilakukan untuk tes setelah mengubah kontrak atau refactoring dan semakin sedikit waktu yang dibutuhkan untuk membaca tes. Desain tes harus mendorong penggunaan kembali potongan kode yang sering digunakan dan mencegah penyalinan tanpa pertimbangan. Akan lebih baik jika tesnya terlihat seragam. Prediktabilitas meningkatkan keterbacaan dan menghemat waktu - bayangkan berapa banyak waktu yang diperlukan siswa fisika untuk menguasai setiap rumus baru jika mereka dijelaskan dalam kata-kata bentuk bebas daripada bahasa matematika.
Dengan demikian, tujuan kami adalah untuk menyembunyikan segala sesuatu yang mengganggu dan berlebihan, hanya menyisakan informasi penting untuk memahami aplikasi: apa yang diuji, apa yang diharapkan pada input, dan apa yang diharapkan pada output.

Mari kita kembali ke model perangkat uji. Secara teknis, setiap titik pada grafik ini dapat diwakili oleh tipe data, dan transisi dari satu ke yang lain - fungsi. Anda bisa datang dari tipe data awal ke yang terakhir dengan menerapkan fungsi berikut ke hasil yang sebelumnya satu per satu. Dengan kata lain, menggunakan komposisi fungsi : menyiapkan data (sebut saja prepare
), mengeksekusi kode uji ( execute
) dan memeriksa hasil yang diharapkan ( check
). Kami akan melewati titik pertama dari bagan, fixture, ke input komposisi ini. Fungsi orde tinggi yang dihasilkan disebut fungsi siklus hidup tes.
Fungsi siklus hidup def runTestCycle[FX, DEP, OUT, F[_]]( fixture: FX, prepare: FX => DEP, execute: DEP => OUT, check: OUT => F[Assertion] ): F[Assertion] =
Pertanyaannya adalah, dari mana fungsi internal itu berasal? Kami akan menyiapkan data dalam sejumlah cara terbatas - untuk mengisi database, menjadi basah, dll. - oleh karena itu, opsi untuk fungsi persiapan akan umum untuk semua tes. Sebagai hasilnya, akan lebih mudah untuk membuat fungsi siklus hidup khusus yang menyembunyikan implementasi spesifik dari persiapan data. Karena metode memohon kode yang diperiksa dan diperiksa relatif unik untuk setiap tes, execute
dan check
akan diberikan secara eksplisit.
Fungsi siklus hidup disesuaikan untuk tes integrasi pada database Dengan mendelegasikan semua nuansa administratif ke fungsi siklus hidup, kami mendapat kesempatan untuk memperluas proses pengujian tanpa harus melalui tes tertulis apa pun. Karena komposisinya, kami dapat menyusup ke mana saja dalam proses, mengekstrak, atau menambahkan data di sana.
Untuk mengilustrasikan kemungkinan pendekatan ini dengan lebih baik, kami akan menyelesaikan masalah kedua dari pengujian awal kami - kurangnya informasi pendukung untuk melokalisasi masalah. Tambahkan pencatatan ketika menerima respons dari metode yang diuji. Pencatatan kami tidak akan mengubah tipe data, tetapi hanya akan menghasilkan efek samping - menampilkan pesan di konsol. Oleh karena itu, setelah efek samping, kami akan mengembalikannya apa adanya.
Fungsi Siklus Hidup Pencatatan def logged[T](implicit loggedT: Logged[T]): T => T = (that: T) => {
Dengan gerakan sederhana seperti itu, kami menambahkan pencatatan hasil yang dikembalikan dan status basis data dalam setiap pengujian . Keuntungan dari fungsi-fungsi kecil ini adalah mereka mudah dipahami, mudah dikomposisi untuk digunakan kembali, dan mudah dihilangkan jika tidak diperlukan lagi.

Hasilnya, pengujian kami akan terlihat seperti ini:
val fixture: SomeMagicalFixture = ???
Badan pengujian telah menjadi singkat, perlengkapan dan pemeriksaan dapat digunakan kembali dalam pengujian lain, dan kami tidak secara manual menyiapkan database di tempat lain. Hanya satu masalah yang tersisa ...
Persiapan perlengkapan
Dalam kode di atas, kami menggunakan asumsi bahwa fixture akan datang dari suatu tempat yang sudah jadi dan hanya perlu ditransfer ke fungsi siklus hidup. Karena data merupakan unsur utama dalam pengujian sederhana dan didukung, kami tidak bisa tidak menyentuh bagaimana membentuknya.
Misalkan toko pengujian kami memiliki basis data berukuran sedang yang khas (untuk kesederhanaan, contoh dengan 4 tabel, tetapi dalam kenyataannya mungkin ada ratusan). Bagian berisi informasi latar belakang, bagian - bisnis langsung, dan secara keseluruhan dapat dihubungkan ke beberapa entitas logis penuh. Tabel saling berhubungan dengan kunci (kunci asing ) - untuk membuat entitas Bonus
, Anda memerlukan entitas Package
, dan pada gilirannya, User
. Dan sebagainya.

Keadaan keterbatasan sirkuit dan segala jenis peretasan menyebabkan inkonsistensi dan, sebagai hasilnya, menguji ketidakstabilan dan berjam-jam debug yang menarik. Untuk alasan ini, kami akan mengisi database dengan jujur.
Kita dapat menggunakan metode militer untuk mengisi, tetapi bahkan dengan pemeriksaan dangkal terhadap gagasan ini, banyak pertanyaan sulit muncul. Apa yang akan mempersiapkan data dalam tes untuk metode ini sendiri? Apakah saya perlu menulis ulang tes jika kontrak berubah? Bagaimana jika data dikirimkan oleh aplikasi yang tidak diuji (misalnya, diimpor oleh orang lain)? Berapa banyak pertanyaan yang berbeda yang harus dilakukan untuk membuat entitas yang bergantung pada banyak orang lain?
Mengisi basis di tes awal insertUser(db, id = 1, name = "test", role = "customer") insertPackage(db, id = 1, name = "test", userId = 1, status = "new") insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30) insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20) insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40)
Metode pembantu yang tersebar, seperti pada contoh aslinya, adalah masalah yang sama, tetapi dengan saus yang berbeda. Mereka menetapkan tanggung jawab untuk mengelola objek dependen dan hubungannya dengan diri kita sendiri, dan kami ingin menghindari ini.
Idealnya, saya ingin memiliki tipe data ini, dengan satu pandangan sekilas yang cukup untuk memahami secara umum kondisi sistem yang akan digunakan selama pengujian. Salah satu kandidat yang baik untuk visualisasi keadaan adalah tabel (a la dataset dalam PHP dan Python), di mana tidak ada yang berlebihan kecuali untuk bidang yang penting untuk logika bisnis. Jika logika bisnis berubah dalam fitur, semua dukungan pengujian akan dikurangi hingga memperbarui sel dalam dataset. Sebagai contoh:
val dataTable: Seq[DataRow] = Table( ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price") , (1, "customer", Vector(40, 20, 30) , Vector.empty , 90.0) , (2, "customer", Vector(250) , Vector.empty , 225.0) , (3, "customer", Vector(100, 120, 30) , Vector(40) , 210.0) , (4, "customer", Vector(100, 120, 30, 100) , Vector(20, 20) , 279.0) , (5, "vip" , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0) )

Dari tabel kami, kami akan menghasilkan kunci - hubungan entitas dengan ID. Dalam hal ini, jika entitas bergantung pada entitas lain, kunci akan dibentuk untuk dependensi tersebut. Mungkin terjadi bahwa dua entitas yang berbeda menghasilkan ketergantungan dengan pengidentifikasi yang sama, yang dapat menyebabkan pelanggaran pembatasan pada kunci primer dari basis data ( kunci utama ). Tetapi pada titik ini, data sangat murah untuk dideduplikasi - karena kunci hanya berisi pengidentifikasi, kita dapat menempatkan mereka ke dalam koleksi yang menyediakan deduplikasi, misalnya, di Set
. Jika ini ternyata tidak mencukupi, kita selalu dapat membuat deduplikasi yang lebih cerdas dalam bentuk fungsi tambahan yang dikompilasi menjadi fungsi siklus hidup.
Contoh utama sealed trait Key case class PackageKey(id: Int, userId: Int) extends Key case class PackageItemKey(id: Int, packageId: Int) extends Key case class UserKey(id: Int) extends Key case class BonusKey(id: Int, packageId: Int) extends Key
Kami mendelegasikan pembuatan konten palsu ke bidang (misalnya, nama) ke kelas terpisah. Kemudian, dengan bantuan kelas ini dan aturan untuk mengonversi kunci, kita mendapatkan objek string yang ditujukan langsung untuk dimasukkan ke dalam database.
Contoh garis object SampleData { def name: String = "test name" def role: String = "customer" def price: Int = 1000 def bonusAmount: Int = 0 def status: String = "new" } sealed trait Row case class PackageRow(id: Int, name: String, userId: Int, status: String) extends Row case class PackageItemRow(id: Int, packageId: Int, name: String, price: Int) extends Row case class UserRow(id: Int, name: String, role: String) extends Row case class BonusRow(id: Int, packageId: Int, bonusAmount: Int) extends Row
Data palsu default, sebagai suatu peraturan, tidak akan cukup bagi kami, jadi kami harus dapat mendefinisikan ulang bidang tertentu. Kita dapat menggunakan lensa - jalankan melalui semua garis yang dibuat dan ubah hanya bidang yang dibutuhkan. Karena lensa pada akhirnya adalah fungsi biasa, mereka dapat dikomposisi, dan ini adalah kegunaannya.
Contoh lensa def changeUserRole(userId: Int, newRole: String): Set[Row] => Set[Row] = (rows: Set[Row]) => rows.modifyAll(_.each.when[UserRow]) .using(r => if (r.id == userId) r.modify(_.role).setTo(newRole) else r)
Berkat komposisi ini, dalam keseluruhan proses kami dapat menerapkan berbagai optimasi dan peningkatan - misalnya, kelompokkan baris dalam tabel sehingga dapat disisipkan dengan satu insert
, mengurangi waktu pengujian, atau mengamankan keadaan akhir dari basis data untuk menyederhanakan masalah penangkapan.
Fungsi membentuk fixture def makeFixture[STATE, FX, ROW, F[_]]( state: STATE, applyOverrides: F[ROW] => F[ROW] = x => x ): FX = (extractKeys andThen deduplicateKeys andThen enrichWithSampleData andThen applyOverrides andThen logged andThen buildFixture) (state)
Semua bersama-sama akan memberi kita fixture yang mengisi dependensi untuk tes - database. Dalam tes itu sendiri, tidak ada yang berlebihan akan terlihat, kecuali untuk dataset asli - semua detail akan disembunyikan di dalam komposisi fungsi.

Rangkaian uji kami sekarang akan terlihat seperti ini:
val dataTable: Seq[DataRow] = Table( ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price") , (1, "customer", Vector(40, 20, 30) , Vector.empty , 90.0) , (2, "customer", Vector(250) , Vector.empty , 225.0) , (3, "customer", Vector(100, 120, 30) , Vector(40) , 210.0) , (4, "customer", Vector(100, 120, 30, 100) , Vector(20, 20) , 279.0) , (5, "vip" , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0) ) " -" - { "'customer'" - { " " - { "< 250 - " - { "(: )" in calculatePriceFor(dataTable, 1) "(: )" in calculatePriceFor(dataTable, 3) } ">= 250 " - { " - 10% " in calculatePriceFor(dataTable, 2) " - 10% " in calculatePriceFor(dataTable, 4) } } } "'vip' - 20% , " in calculatePriceFor(dataTable, 5) }
Kode pembantu:
Menambahkan kasus uji baru ke tabel menjadi tugas yang sepele, yang memungkinkan Anda untuk berkonsentrasi pada mencakup jumlah maksimum kondisi batas , daripada pada pelat tungku.
Menggunakan kembali kode persiapan fixture pada proyek lain
Yah, kami menulis banyak kode untuk menyiapkan perlengkapan dalam satu proyek tertentu, menghabiskan banyak waktu untuk ini. Bagaimana jika kita memiliki beberapa proyek? Apakah kita ditakdirkan untuk menemukan kembali roda dan copy-paste setiap kali?
Kami dapat mengabstraksi persiapan perlengkapan dari model domain tertentu. Di dunia FP, ada konsep typeclass . Singkatnya, typeclasses bukan kelas dari OOP, tetapi sesuatu seperti antarmuka, mereka mendefinisikan beberapa jenis perilaku kelompok. Perbedaan mendasar adalah bahwa kelompok jenis ini ditentukan bukan oleh pewarisan kelas, tetapi oleh instantiasi, seperti variabel biasa. Seperti halnya warisan, penyelesaian instance dari tipe kelas (via implisit ) terjadi secara statis , pada tahap kompilasi. Untuk kesederhanaan, untuk keperluan kami, typeclasses dapat dianggap sebagai ekstensi dari Kotlin dan C # .
Untuk menjaminkan suatu objek, kita tidak perlu tahu apa yang ada di dalam objek ini, bidang apa dan metode yang dimilikinya. Penting bagi kami untuk menentukan perilaku log
dengan tanda tangan tertentu. Akan Logged
untuk mengimplementasikan antarmuka Logged
tertentu di setiap kelas, dan itu tidak selalu mungkin - misalnya, di perpustakaan atau kelas standar. Dalam kasus typeclasses, semuanya jauh lebih sederhana. Kami dapat membuat turunan dari Logged
Logged, misalnya, untuk perlengkapan, dan menampilkannya dalam bentuk yang dapat dibaca. Dan untuk semua tipe lainnya, buat instance untuk tipe Any
dan gunakan metode toString
standar untuk mencatat objek apa pun di representasi internal mereka secara gratis.
Contoh dari kelas Tagged dan instance untuk itu trait Logged[A] { def log(a: A)(implicit logger: Logger): A }
Selain penebangan, kami dapat memperluas pendekatan ini ke seluruh proses mempersiapkan perlengkapan. Solusi tes akan menawarkan kacamata waktu sendiri dan implementasi abstrak dari fungsi berdasarkan mereka. Tanggung jawab proyek yang menggunakannya adalah menulis contoh typeclasses untuk tipenya sendiri.
Ketika merancang generator fixture, saya fokus pada penerapan prinsip-prinsip pemrograman dan desain SOLID sebagai indikator stabilitas dan kemampuan beradaptasi untuk sistem yang berbeda:
- Prinsip Tanggung Jawab Tunggal : Setiap typeclass menggambarkan tepat satu aspek dari perilaku tipe.
- Prinsip Terbuka Tertutup : kami tidak memodifikasi tipe tempur yang ada untuk pengujian, kami memperluasnya dengan contoh dari tyclasses.
- Prinsip Pergantian Liskov tidak menjadi masalah dalam kasus ini, karena kami tidak menggunakan warisan.
- Prinsip Segregasi Antarmuka : Kami menggunakan banyak kacamata waktu khusus alih-alih yang global.
- Prinsip Pembalikan Ketergantungan : Implementasi generator fixture tidak tergantung pada tipe tempur tertentu, tetapi pada timeclasses abstrak.
Setelah memastikan bahwa semua prinsip terpenuhi, dapat dikatakan bahwa solusi kami terlihat cukup didukung dan dapat dikembangkan untuk menggunakannya dalam proyek yang berbeda.
Setelah menulis fungsi siklus hidup, generasi fixture, dan konversi dataset menjadi fixture, serta abstrak dari model domain spesifik aplikasi, kami akhirnya siap untuk mengukur solusi kami untuk semua tes.
Ringkasan
Kami beralih dari gaya desain uji tradisional (selangkah demi selangkah) ke gaya fungsional. Gaya selangkah demi selangkah baik pada tahap awal dan proyek kecil karena tidak memerlukan tenaga kerja tambahan dan tidak membatasi pengembang, tetapi mulai kehilangan ketika ada banyak tes pada proyek. Gaya fungsional tidak dirancang untuk menyelesaikan semua masalah dalam pengujian, tetapi dapat sangat membantu penskalaan dan dukungan pengujian dalam proyek-proyek di mana jumlahnya ada dalam ratusan atau ribuan. Tes gaya fungsional lebih kompak dan fokus pada apa yang benar-benar penting (data, kode uji dan hasil yang diharapkan), dan bukan pada langkah-langkah menengah.
Selain itu, kami melihat contoh nyata dari seberapa kuat konsep komposisi dan jenis kacamata dalam pemrograman fungsional. Dengan bantuan mereka, mudah untuk merancang solusi, bagian integral yang dapat diperpanjang dan dapat digunakan kembali.
, , , . , , , -. , . !
: Github