Pola arsitektur MVI di Kotlin Multiplatform. Bagian 3: Pengujian





Artikel ini adalah bagian terakhir dari seri penerapan pola arsitektur MVI di Multiplatform Kotlin. Di dua bagian sebelumnya ( bagian 1 dan bagian 2 ), kami ingat apa itu MVI, membuat modul Kittens generik untuk memuat gambar kucing dan mengintegrasikannya ke dalam aplikasi iOS dan Android.



Pada bagian ini, kita akan membahas modul Kittens dengan tes unit dan integrasi. Kita akan belajar tentang batasan pengujian saat ini di Kotlin Multiplatform, mencari tahu cara mengatasinya, dan bahkan membuatnya bekerja untuk keuntungan kita.



Proyek sampel yang diperbarui tersedia di GitHub kami .



Prolog



Tidak ada keraguan bahwa pengujian merupakan langkah penting dalam pengembangan perangkat lunak. Tentu saja, ini memperlambat proses, tetapi pada saat yang sama:



  • memungkinkan Anda untuk memeriksa kasus-kasus tepi yang sulit ditangkap secara manual;

  • Mengurangi kemungkinan regresi saat menambahkan fitur baru, memperbaiki bug, dan memfaktorkan ulang;

  • memaksa Anda untuk menguraikan dan menyusun kode Anda.



Sekilas, poin terakhir mungkin tampak seperti kerugian, karena butuh waktu. Namun, itu membuat kode lebih mudah dibaca dan bermanfaat dalam jangka panjang.



β€œMemang, rasio waktu yang dihabiskan untuk membaca versus menulis lebih dari 10 banding 1. Kami terus membaca kode lama sebagai bagian dari upaya untuk menulis kode baru. ... [Oleh karena itu,] membuatnya mudah dibaca membuat lebih mudah menulis. " - Robert C. Martin, "Clean Code: A Handbook of Agile Software Craftsmanship"


Kotlin Multiplatform memperluas kemampuan pengujian. Teknologi ini menambahkan satu fitur penting: setiap pengujian secara otomatis dilakukan pada semua platform yang didukung. Jika, misalnya, hanya Android dan iOS yang didukung, maka jumlah pengujian dapat dikalikan dua. Dan jika pada titik tertentu dukungan untuk platform lain ditambahkan, maka secara otomatis akan tercakup dalam pengujian. 



Pengujian pada semua platform yang didukung itu penting karena mungkin terdapat perbedaan dalam perilaku kode. Misalnya, Kotlin / Native memiliki model memori khusus , Kotlin / JS juga terkadang memberikan hasil yang tidak terduga.



Sebelum melangkah lebih jauh, perlu disebutkan beberapa batasan pengujian di Multiplatform Kotlin. Yang terbesar adalah kurangnya library tiruan untuk Kotlin / Native dan Kotlin / JS. Ini mungkin tampak seperti kerugian besar, tetapi saya pribadi menganggapnya sebagai keuntungan. Menguji di Multiplatform Kotlin cukup sulit bagi saya: Saya harus membuat antarmuka untuk setiap dependensi dan menulis implementasi pengujiannya (palsu). Butuh waktu lama, tetapi pada titik tertentu saya menyadari bahwa menghabiskan waktu untuk abstraksi adalah investasi yang mengarah pada kode yang lebih bersih. 



Saya juga memperhatikan bahwa modifikasi selanjutnya pada kode ini membutuhkan waktu lebih sedikit. Mengapa demikian? Karena interaksi kelas dengan dependensinya tidak dipaku (diolok-olok). Dalam kebanyakan kasus, cukup dengan memperbarui implementasi pengujiannya saja. Tidak perlu mendalami setiap metode pengujian untuk memperbarui tiruan. Akibatnya, saya berhenti menggunakan pustaka tiruan bahkan dalam pengembangan Android standar. Saya sarankan membaca artikel berikut: " Mengolok-olok tidak praktis - Gunakan yang palsu " oleh Pravin Sonawane .



Rencana



Mari kita ingat apa yang kita miliki di modul Kittens dan apa yang harus kita uji.



  • KittenStore adalah komponen utama modul. Implementasi KittenStoreImpl- nya berisi sebagian besar logika bisnis. Ini adalah hal pertama yang akan kami uji.

  • KittenComponent adalah fasad modul dan titik integrasi untuk semua komponen internal. Kami akan membahas komponen ini dengan tes integrasi.

  • KittenView adalah antarmuka publik yang mewakili ketergantungan UI KittenComponent.

  • KittenDataSource adalah antarmuka akses Web internal yang memiliki implementasi khusus platform untuk iOS dan Android.



Untuk pemahaman yang lebih baik tentang struktur modul, saya akan memberikan diagram UMLnya







:



  • Menguji KittenStore
    • Membuat implementasi uji KittenStore.Parser

    • Membuat implementasi pengujian KittenStore.Network

    • Menulis Tes Unit untuk KittenStoreImpl



  • Menguji KittenComponent
    • Membuat implementasi pengujian KittenDataSource

    • Buat Implementasi Uji KittenView

    • Menulis Tes Integrasi untuk KittenComponent



  • Menjalankan tes

  • kesimpulan





Pengujian Unit KittenStore



Antarmuka KittenStore memiliki kelas implementasinya sendiri - KittenStoreImpl. Inilah yang akan kami uji. Ini memiliki dua dependensi (antarmuka internal), yang didefinisikan langsung di kelas itu sendiri. Mari kita mulai dengan menulis implementasi pengujian untuk mereka.



Uji implementasi KittenStore.Parser



Komponen ini bertanggung jawab atas permintaan jaringan. Seperti inilah tampilan antarmukanya:



antarmuka Jaringan {
fun load () : Mungkin < String >
}
lihat mentah KittenStoreImpl.kt dihosting dengan ❀ oleh GitHub


Sebelum menulis implementasi pengujian antarmuka jaringan, kita perlu menjawab satu pertanyaan penting: data apa yang dikembalikan server? Jawabannya adalah bahwa server mengembalikan sekumpulan tautan gambar secara acak, setiap kali kumpulan yang berbeda. Dalam kehidupan nyata, format JSON digunakan, tetapi karena kami memiliki abstraksi Parser, kami tidak peduli tentang format dalam pengujian unit.



Implementasi nyata dapat mengalihkan aliran, sehingga pelanggan dapat dibekukan di Kotlin / Native. Akan sangat bagus untuk memodelkan perilaku ini untuk memastikan kode menangani semuanya dengan benar.



Jadi, implementasi pengujian Jaringan kami harus memiliki fitur-fitur berikut:



  • harus mengembalikan kumpulan baris berbeda yang tidak kosong untuk setiap permintaan;

  • format tanggapan harus sama untuk Jaringan dan Parser;

  • harus dapat mensimulasikan kesalahan jaringan (Mungkin harus selesai tanpa respons);

  • harus memungkinkan untuk mensimulasikan format respon yang tidak valid (untuk memeriksa kesalahan dalam Parser);

  • seharusnya mungkin untuk mensimulasikan penundaan respons (untuk memeriksa fase boot);

  • harus dapat dibekukan di Kotlin / Native (untuk berjaga-jaga).



Implementasi pengujian itu sendiri mungkin terlihat seperti ini:



kelas TestKittenStoreNetwork (
penjadwal val pribadi : TestScheduler
) : KittenStoreImpl . Jaringan {
var images: List<String>? by AtomicReference<List<String>?>(null)
private var seed: Int by AtomicInt()
override fun load(): Maybe<String> =
singleFromFunction { images }
.notNull()
.map { it.joinToString(separator = SEPARATOR) }
.observeOn(scheduler)
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
private const val SEPARATOR = ";"
}
}
view raw TestKittenStoreNetwork.kt dihosting dengan ❀ oleh GitHub


TestKittenStoreNetwork memiliki penyimpanan string (seperti server nyata) dan dapat menghasilkannya. Untuk setiap permintaan, daftar baris saat ini dikodekan menjadi satu baris. Jika properti "images" adalah nol maka Maybe akan berhenti, yang seharusnya dianggap sebagai kesalahan.



Kami juga menggunakan TestScheduler . Penjadwal ini memiliki satu fungsi penting: membekukan semua tugas yang masuk. Jadi, operator observOn, yang digunakan bersama dengan TestScheduler, akan membekukan downstream, serta semua data yang melewatinya, seperti dalam kehidupan nyata. Tetapi pada saat yang sama, multithreading tidak akan terlibat, yang menyederhanakan pengujian dan membuatnya lebih andal.



Selain itu, TestScheduler memiliki mode "pemrosesan manual" khusus yang memungkinkan kita menyimulasikan latensi jaringan.



Uji implementasi KittenStore.Parser



Komponen ini bertanggung jawab untuk mem-parsing tanggapan dari server. Inilah antarmukanya:



antarmuka Parser {
fun parse ( json : String ) : Maybe < List < String >>
}
lihat mentah KittenStoreImpl.kt dihosting dengan ❀ oleh GitHub


Jadi, apa pun yang diunduh dari web harus diubah menjadi daftar tautan. Jaringan kami hanya menggabungkan string menggunakan pemisah titik koma (;), jadi gunakan format yang sama di sini.



Berikut implementasi pengujiannya:



class TestKittenStoreParser : KittenStoreImpl.Parser {
override fun parse(json: String): Maybe<List<String>> =
json
.toSingle()
.filter { it != "" }
.map { it.split(SEPARATOR) }
.observeOn(TestScheduler())
private companion object {
private const val SEPARATOR = ";"
}
}
view raw TestKittenStoreParser.kt dihosting dengan ❀ oleh GitHub


Seperti pada Jaringan, TestScheduler digunakan untuk membekukan pelanggan dan memeriksa kompatibilitasnya dengan model memori Kotlin / Native. Kesalahan pemrosesan respons disimulasikan jika string input kosong.



Tes unit untuk KittenStoreImpl



Kami sekarang memiliki implementasi uji semua dependensi. Sudah waktunya untuk tes unit. Semua unit test bisa ditemukan di repository , disini saya hanya akan memberikan inisialisasi dan beberapa test sendiri.



Langkah pertama adalah membuat contoh implementasi pengujian kami:



class KittenStoreTest {
private val parser = TestKittenStoreParser ()
swasta val networkScheduler = TestScheduler ()
private val network = TestKittenStoreNetwork ( networkScheduler )
toko kesenangan pribadi () : KittenStore = KittenStoreImpl (jaringan, parser)
// ...
}
lihat mentah KittenStoreTest.kt dihosting dengan ❀ oleh GitHub


KittenStoreImpl menggunakan mainScheduler, jadi langkah selanjutnya adalah menimpanya:



class KittenStoreTest {
jaringan val pribadi = TestKittenStoreNetwork ()
private val parser = TestKittenStoreParser()
private fun store(): KittenStore = KittenStoreImpl(network, parser)
@BeforeTest
fun before() {
overrideSchedulers(main = { TestScheduler() })
}
@AfterTest
fun after() {
overrideSchedulers()
}
// ...
}
view raw KittenStoreTest.kt hosted with ❀ by GitHub


Sekarang kita bisa menjalankan beberapa tes. KittenStoreImpl harus memuat gambar segera setelah pembuatan. Artinya, permintaan jaringan harus dipenuhi, tanggapannya harus diproses, dan status harus diperbarui dengan hasil baru.



@Uji
menyenangkan load_images_WHEN_created () {
val images = network.generateImages ()
val store = store ()
assertEquals ( State.Data.Images (url = gambar), store.state. Data )
}
lihat mentah KittenStoreTest.kt dihosting dengan ❀ oleh GitHub


Apa yang kita lakukan:



  • menghasilkan gambar di Jaringan;

  • membuat instance baru KittenStoreImpl;

  • memastikan negara bagian berisi daftar string yang benar.



Skenario lain yang perlu kita pertimbangkan adalah mendapatkan KittenStore.Intent.Reload. Dalam kasus ini, daftar harus dimuat ulang dari jaringan.



@Uji
fun reloads_images_WHEN_Intent_Reload () {
network.generateImages ()
val store = store ()
val newImages = network.generateImages ()
store.onNext ( Intent.Reload )
(assertEquals State.Data.Images (url = newImages), store.state. Data )
}
lihat mentah KittenStoreTest.kt dihosting dengan ❀ oleh GitHub


Langkah-langkah pengujian:



  • menghasilkan gambar sumber;

  • buat instance KittenStoreImpl;

  • menghasilkan gambar baru;

  • send Intent.Reload;

  • pastikan kondisinya berisi gambar baru.



Terakhir, mari kita uji skenario berikut: saat flag isLoading disetel saat gambar dimuat.



@Uji
fun isLoading_true_WHEN_loading () {
networkScheduler.isManualProcessing = true
network.generateImages ()
val store = store ()
assertTrue (store.state.isLoading)
}
lihat mentah KittenStoreTest.kt dihosting dengan ❀ oleh GitHub


Kami telah mengaktifkan pemrosesan manual untuk TestScheduler - sekarang tugas tidak akan diproses secara otomatis. Ini memungkinkan kami untuk memeriksa status sambil menunggu jawaban.



Pengujian Integrasi KittenComponent



Seperti yang saya sebutkan di atas, KittenComponent adalah titik integrasi dari keseluruhan modul. Kami dapat menutupinya dengan tes integrasi. Mari kita lihat API-nya:



intern kelas KittenComponent internal yang konstruktor ( DataSource : KittenDataSource ) {
konstruktor () : ini ( KittenDataSource ())
menyenangkan onViewCreated ( tampilan : KittenView ) { / * ... * / }
menyenangkan onStart () { / * ... * / }
menyenangkan onStop () { / * ... * / }
menyenangkan onViewDestroyed () { / * ... * / }
menyenangkan onDestroy () { / * ... * / }
}
lihat mentah KittenComponent.kt dihosting dengan ❀ oleh GitHub


Ada dua dependensi, KittenDataSource dan KittenView. Kami memerlukan implementasi pengujian untuk ini sebelum kami dapat memulai pengujian.



Sebagai kelengkapan, diagram berikut menunjukkan aliran data di dalam modul:







Menguji implementasi KittenDataSource



Komponen ini bertanggung jawab atas permintaan jaringan. Ini memiliki implementasi terpisah untuk setiap platform, dan kami membutuhkan implementasi lain untuk pengujian. Seperti inilah tampilan antarmuka KittenDataSource:



antarmuka internal KittenDataSource {
fun load ( batas : Int , offset : Int ) : Maybe < String >
}


TheCatAPI mendukung pagination, jadi saya langsung menambahkan argumen yang sesuai. Jika tidak, ini sangat mirip dengan KittenStore.Network, yang kami implementasikan sebelumnya. Satu-satunya perbedaan adalah kami harus menggunakan format JSON karena kami menguji kode nyata dalam integrasi. Jadi kami hanya meminjam ide implementasi:



kelas internal TestKittenDataSource (
penjadwal val pribadi : TestScheduler
) : KittenDataSource {
private var images by AtomicReference<List<String>?>(null)
private var seed by AtomicInt()
override fun load(limit: Int, page: Int): Maybe<String> =
singleFromFunction { images }
.notNull()
.map {
val offset = page * limit
it.subList(fromIndex = offset, toIndex = offset + limit)
}
.mapIterable { it.toJsonObject() }
.map { JsonArray(it).toString() }
.onErrorComplete()
.observeOn(scheduler)
private fun String.toJsonObject(): JsonObject =
JsonObject(mapOf("url" to JsonPrimitive(this)))
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
}
}
view raw TestKittenDataSource.kt hosted with ❀ by GitHub


Seperti sebelumnya, kami membuat daftar string berbeda yang dikodekan ke dalam larik JSON pada setiap permintaan. Jika tidak ada gambar yang dibuat, atau argumen permintaan salah, Mungkin hanya akan berhenti tanpa tanggapan.



Pustaka kotlinx.serialization digunakan untuk membentuk array JSON . Ngomong-ngomong, KittenStoreParser yang diuji menggunakannya untuk mendekode.



Menguji implementasi KittenView



Ini adalah komponen terakhir yang kita perlukan untuk implementasi pengujian sebelum kita dapat memulai pengujian. Inilah antarmukanya:



antarmuka KittenView : MviView < Model , Event > {
Model kelas data (
val isLoading : Boolean ,
val isError : Boolean ,
val imageUrls : List < String >
)
Acara kelas tertutup {
objek RefreshTriggered : Event ()
}
}
lihat mentah MviKmpKittenViewInterface.kt dihosting dengan ❀ oleh GitHub


Ini adalah tampilan yang hanya mengambil model dan mengaktifkan peristiwa, jadi implementasi pengujiannya sangat sederhana:



kelas TestKittenView : AbstractMviView < Model , Event > (), KittenView {
model lateinit var : Model
override fun render ( model : Model ) {
ini .model = model
}
}
lihat mentah TestKittenView.kt dihosting dengan ❀ oleh GitHub


Kita hanya perlu mengingat model terakhir yang diterima - ini akan memungkinkan kita untuk memeriksa kebenaran model yang ditampilkan. Kita juga bisa mengirimkan event atas nama KittenView menggunakan metode dispatch (Event), yang dideklarasikan di kelas AbstractMviView yang diwariskan.



Tes integrasi untuk KittenComponent



Kumpulan tes lengkap dapat ditemukan di repositori , di sini saya hanya akan memberikan beberapa yang paling menarik.



Seperti sebelumnya, mari kita mulai dengan membuat instance dependensi dan menginisialisasi:



class KittenComponentTest {
private val dataSourceScheduler = TestScheduler ()
private val dataSource = TestKittenDataSource (dataSourceScheduler)
tampilan val pribadi = TestKittenView ()
kesenangan pribadi startComponent () : KittenComponent =
KittenComponent (dataSource). terapkan {
onViewCreated (tampilan)
onStart ()
}
// ...
}
lihat mentah KittenComponentTest.kt dihosting dengan ❀ oleh GitHub


Saat ini ada dua penjadwal yang digunakan untuk modul: mainScheduler dan computationScheduler. Kami perlu menggantinya:



class KittenComponentTest {
private val dataSourceScheduler = TestScheduler ()
private val dataSource = TestKittenDataSource (dataSourceScheduler)
tampilan val pribadi = TestKittenView ()
kesenangan pribadi startComponent () : KittenComponent =
KittenComponent (dataSource). terapkan {
onViewCreated (tampilan)
onStart ()
}
// ...
@Tokopedia
kesenangan sebelumnya () {
overrideSchedulers (main = { TestScheduler ()}, computation = { TestScheduler ()})
}
@After
kesenangan setelah () {
overrideSchedulers ()
}
}
lihat mentah KittenComponentTest.kt dihosting dengan ❀ oleh GitHub


Kami sekarang dapat menulis beberapa tes. Mari kita periksa skrip utama terlebih dahulu untuk memastikan gambar dimuat dan ditampilkan saat startup:



@Uji
kesenangan load_and_shows_images_WHEN_created () {
val images = dataSource.generateImages ()
startComponent ()
assertEquals (gambar, view.model.imageUrls)
}
lihat mentah KittenComponentTest.kt dihosting dengan ❀ oleh GitHub


Tes ini sangat mirip dengan yang kami tulis ketika kami melihat tes unit untuk KittenStore. Hanya sekarang seluruh modul terlibat.



Langkah-langkah pengujian:



  • menghasilkan link ke gambar di TestKittenDataSource;

  • membuat dan menjalankan KittenComponent;

  • pastikan tautan mencapai TestKittenView.



Skenario menarik lainnya: gambar perlu dimuat ulang saat KittenView mengaktifkan peristiwa RefreshTriggered.



@Uji
fun reloads_images_WHEN_Event_RefreshTriggered () {
dataSource.generateImages ()
startComponent ()
val newImages = dataSource.generateImages ()
view.dispatch ( Event.RefreshTriggered )
assertEquals (newImages, view.model.imageUrls)
}
lihat mentah KittenComponentTest4.kt dihosting dengan ❀ oleh GitHub


Tahapan:



  • menghasilkan tautan sumber ke gambar;

  • membuat dan menjalankan KittenComponent;

  • menghasilkan tautan baru;

  • kirim Event.RefreshTriggered atas nama KittenView;

  • pastikan tautan baru mencapai TestKittenView.





Menjalankan tes



Untuk menjalankan semua pengujian, kita perlu melakukan tugas Gradle berikut:



./gradlew :shared:kittens:build


Ini akan mengkompilasi modul dan menjalankan semua pengujian pada semua platform yang didukung: Android dan iosx64.



Dan inilah laporan liputan JaCoCo:







Kesimpulan



Pada artikel ini, kami membahas modul Kittens dengan pengujian unit dan integrasi. Desain modul yang diusulkan memungkinkan kami untuk mencakup bagian-bagian berikut:



  • KittenStoreImpl - berisi sebagian besar logika bisnis;

  • KittenStoreNetwork - bertanggung jawab atas permintaan jaringan tingkat tinggi;

  • KittenStoreParser - bertanggung jawab untuk mengurai respons jaringan;

  • semua transformasi dan koneksi.



Poin terakhir sangat penting. Anda dapat menutupinya berkat fitur MVI. Tanggung jawab tampilan ini adalah untuk menampilkan data dan acara pengiriman. Semua langganan, konversi, dan tautan dilakukan di dalam modul. Jadi, kami dapat mencakup semuanya dengan tes umum, kecuali tampilan itu sendiri.



Tes semacam itu memiliki keuntungan sebagai berikut:



  • jangan gunakan API platform;

  • dilakukan dengan sangat cepat;

  • dapat diandalkan (jangan berkedip);

  • berjalan di semua platform yang didukung.



Kami juga dapat menguji kode untuk kompatibilitas dengan model memori Kotlin / Native yang kompleks. Ini juga sangat penting karena kurangnya keamanan pada waktu pembuatan: kode hanya mogok saat waktu proses dengan pengecualian yang sulit untuk di-debug.



Semoga ini bisa membantu Anda dalam proyek Anda. Terima kasih telah membaca artikel saya! Dan jangan lupa untuk mengikuti saya di Twitter .



...





Latihan bonus



Jika Anda ingin bekerja dengan implementasi pengujian atau bermain dengan MVI, berikut adalah beberapa latihan langsung.



Memfaktorkan ulang KittenDataSource



Ada dua implementasi antarmuka KittenDataSource dalam modul: satu untuk Android dan satu lagi untuk iOS. Saya telah menyebutkan bahwa mereka bertanggung jawab atas akses jaringan. Tapi sebenarnya mereka memiliki fungsi lain: mereka menghasilkan URL untuk permintaan tersebut berdasarkan argumen input "batas" dan "halaman". Pada saat yang sama, kami memiliki kelas KittenStoreNetwork yang tidak melakukan apa pun kecuali mendelegasikan panggilan ke KittenDataSource.



Tugas: Pindahkan logika pembuatan permintaan URL dari KittenDataSourceImpl (di Android dan iOS) ke KittenStoreNetwork. Anda perlu mengubah antarmuka KittenDataSource sebagai berikut:







Setelah Anda selesai melakukannya, Anda perlu memperbarui pengujian Anda. Satu-satunya kelas yang perlu Anda sentuh adalah TestKittenDataSource.



Menambahkan pemuatan halaman



TheCatAPI mendukung pagination, jadi kami dapat menambahkan fungsionalitas ini untuk pengalaman pengguna yang lebih baik. Anda bisa mulai dengan menambahkan acara Event.EndReached baru untuk KittenView, setelah itu kode akan berhenti mengkompilasi. Kemudian Anda perlu menambahkan Intent.LoadMore yang sesuai, konversikan Event baru menjadi Intent, dan proses yang terakhir di KittenStoreImpl. Anda juga perlu memodifikasi antarmuka KittenStoreImpl.Network sebagai berikut:







Terakhir, Anda perlu memperbarui beberapa implementasi pengujian, memperbaiki satu atau dua pengujian yang ada, dan kemudian menulis beberapa pengujian baru untuk menutupi pagination.






All Articles