Hai Minggu ini tugasnya adalah menulis tes integrasi untuk aplikasi Spring Boot menggunakan interaksi asinkron dengan sistem eksternal. Menyegarkan banyak materi tentang debugging kode multithreaded. Artikel "Menguji Multi-Threaded dan Asynchronous Code" oleh Jonathan Halterman,
terjemahan saya yang diberikan di bawah ini, menarik perhatian.
Terima kasih kepada
shalomman ,
schroeder , dan
FTOH untuk komentar kode paling penting dari artikel aslinya.
Jika Anda menulis kode cukup lama atau mungkin tidak, maka Anda mungkin menemukan skrip di mana Anda perlu menguji kode multi-utas. Secara umum diyakini bahwa ulir dan pengujian tidak boleh dicampur. Ini biasanya terjadi karena apa yang akan diuji baru diluncurkan di dalam sistem multi-ulir dan dapat diuji secara individual tanpa menggunakan utas. Tetapi bagaimana jika Anda tidak dapat memisahkannya, atau lebih, jika multithreading adalah aspek dari kode yang Anda uji?
Saya di sini untuk memberi tahu Anda bahwa meskipun utas dalam tes tidak terlalu umum, mereka cukup digunakan. Polisi perangkat lunak tidak akan menangkap Anda karena memulai utas dalam unit test, meskipun bagaimana sebenarnya menguji kode multi-utas adalah masalah lain. Beberapa teknologi asinkron yang sangat baik, seperti Akka dan Vert.x, menyediakan ruang
uji untuk meringankan beban ini. Namun di luar itu, pengujian kode multi-ulir biasanya memerlukan pendekatan yang berbeda dari tes unit sinkron biasa.
Kami berjalan paralel
Langkah pertama adalah meluncurkan tindakan multithreaded yang ingin Anda periksa hasilnya. Sebagai contoh, mari kita gunakan API hipotetis untuk mendaftarkan penangan pesan di bus pesan dan menerbitkan pesan di bus, yang akan dikirimkan ke penangan kami secara tidak sinkron di utas terpisah:
messageBus.registerHandler(message - > { System.out.println("Received " + message); }); messageBus.publish("test");
Itu terlihat bagus. Ketika tes dimulai, bus harus mengirimkan pesan kami ke pawang di utas lain, tetapi ini tidak terlalu berguna, karena kami tidak memeriksa apa pun. Mari perbarui pengujian kami untuk mengonfirmasi bahwa bus pesan mengirimkan pesan kami seperti yang diharapkan:
String msg = "test"; messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); }; messageBus.publish(msg);
Terlihat lebih baik. Kami menjalankan tes kami dan itu hijau. Keren! Tetapi pesan yang Diterima tidak dicetak di mana pun, ada sesuatu yang salah di suatu tempat.
Tunggu sebentar
Dalam tes di atas, ketika sebuah pesan dipublikasikan di bus pesan, pesan tersebut dikirim oleh bus ke pawang di utas lainnya. Tetapi ketika alat pengujian unit seperti JUnit menjalankan tes, ia tidak tahu apa-apa tentang aliran bus pesan. JUnit hanya tahu tentang utas utama di mana ia menjalankan tes. Dengan demikian, ketika bus pesan sedang sibuk mencoba untuk menyampaikan pesan, tes menyelesaikan eksekusi di utas uji utama dan JUnit melaporkan keberhasilan. Bagaimana cara mengatasinya? Kami membutuhkan utas uji utama untuk menunggu bus pesan untuk mengirimkan pesan kami. Jadi mari kita tambahkan pernyataan tidur:
String msg = "test"; messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); }; messageBus.publish(msg); Thread.sleep(1000);
Tes kami berwarna hijau dan ekspresi Diterima dicetak seperti yang diharapkan. Keren! Tapi satu detik tidur berarti tes kami dilakukan setidaknya selama satu detik, dan tidak ada yang baik di dalamnya. Kita dapat mengurangi waktu tidur, tetapi kemudian kita berisiko menyelesaikan tes sebelum menerima pesan. Kami membutuhkan cara untuk mengoordinasikan antara utas uji utama dan utas penangan pesan. Melihat paket
java.util.concurrent , kami yakin dapat menemukan apa yang dapat kami gunakan. Bagaimana dengan
CountDownLatch ?
String msg = "test"; CountDownLatch latch = new CountDownLatch(1); messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); latch.countDown(); }; messageBus.publish(msg); latch.await();
Dalam pendekatan ini, kami membagikan CountDownLatch antara utas uji utama dan utas penangan pesan. Utas utama terpaksa menunggu di pemblokir. Utas uji melepaskan utas utama yang tertunda dengan memanggil countDown () pada pemblokir setelah menerima pesan. Kita tidak perlu lagi tidur selama satu detik. Tes kami memakan waktu persis seperti yang dibutuhkan.
Sangat bahagia?
Dengan pesona baru kami, CountDownLatch, kami mulai menulis tes multithreaded, seperti fashionista terbaru. Tetapi cukup cepat, kami melihat bahwa salah satu kasus pengujian kami diblokir selamanya dan tidak berakhir. Apa yang sedang terjadi Pertimbangkan skenario bus pesan: pemblokir membuat Anda menunggu, tetapi itu dirilis hanya setelah menerima pesan. Jika bus tidak berfungsi dan pesan tidak pernah terkirim, tes tidak akan pernah berakhir. Jadi mari kita tambahkan batas waktu ke pemblokir:
latch.await(1, TimeUnit.SECONDS);
Tes yang diblokir gagal setelah 1 detik dengan pengecualian TimeoutException. Pada akhirnya, kami akan menemukan masalah dan memperbaiki tes, tetapi memutuskan untuk meninggalkan batas waktu di tempat. Jika ini pernah terjadi lagi, kami lebih suka pengujian kami untuk mengunci sesaat dan crash, daripada memblokir selamanya dan tidak selesai sama sekali.
Masalah lain yang kami perhatikan ketika menulis tes adalah bahwa semuanya sepertinya lulus bahkan ketika seharusnya tidak. Bagaimana ini mungkin? Pertimbangkan tes pemrosesan pesan lagi:
messageBus.registerHandler(message -> { assertEquals(message, msg); latch.countDown(); };
Kita seharusnya menggunakan CountDownLatch untuk mengoordinasikan penyelesaian pengujian kami dengan utas uji utama, tetapi bagaimana dengan menegaskan? Jika validasi gagal, akankah JUnit mengetahuinya? Ternyata karena kami tidak melakukan validasi di utas uji utama, setiap cacat check tetap tidak diperhatikan oleh JUnit. Mari kita coba skrip kecil untuk menguji ini:
CountDownLatch latch = new CountDownLatch(1); new Thread(() -> { assertTrue(false); latch.countDown(); }).start(); latch.await();
Tesnya hijau! Jadi apa yang kita lakukan sekarang? Kami membutuhkan cara untuk mengirim kesalahan pengujian dari aliran penanganan pesan kembali ke aliran pengujian utama. Jika kegagalan terjadi di utas penangan pesan, kami membutuhkannya untuk muncul kembali di utas utama sehingga pengujian membalik, seperti yang diharapkan. Mari kita coba lakukan ini:
CountDownLatch latch = new CountDownLatch(1); AtomicReference<AssertionError> failure = new AtomicReference<>(); new Thread(() -> { try { assertTrue(false); } catch (AssertionError e) { failure.set(e); } latch.countDown(); }).start(); latch.await(); if (failure.get() != null) throw failure.get();
Mulai cepat dan ya, tes gagal, sebagaimana mestinya! Sekarang kita dapat kembali dan menambahkan CountDownLatches, coba / tangkap dan AtomicReference blok untuk semua kasus pengujian kami. Keren! Sebenarnya, tidak keren, sepertinya seperti boilerplate.
Hentikan sampah
Idealnya, kita membutuhkan API yang memungkinkan kita untuk mengoordinasikan menunggu, memeriksa, dan melanjutkan eksekusi antara utas, sehingga pengujian unit dapat lulus atau gagal seperti yang diharapkan, di mana pun validasi gagal. Untungnya, ConcurrentUnit menyediakan kerangka kerja ringan yang tidak hanya itu: Pelayan. Mari kita adaptasi tes pemrosesan pesan di atas untuk yang terakhir kalinya dan lihat apa yang dapat dilakukan Pelayan dari ConcurrentUnit bagi kita:
String msg = "test"; Waiter waiter = new Waiter(); messageBus.registerHandler(message -> { waiter.assertEquals(message, msg); waiter.resume(); }; messageBus.publish(msg); waiter.await(1, TimeUnit.SECONDS);
Dalam tes ini, kita melihat bahwa Pelayan telah menggantikan CountDownLatch dan AtomicReference kita. Dengan Waiter, kami memblokir utas tes utama, melakukan tes, lalu melanjutkan utas tes utama sehingga tes dapat diselesaikan. Jika cek gagal, maka memanggil wait.await akan secara otomatis melepaskan kunci dan melempar kegagalan, yang akan menyebabkan tes lulus atau gagal, sebagaimana mestinya, bahkan jika pemeriksaan dilakukan dari utas lainnya.
Bahkan lebih paralel
Sekarang kami telah menjadi penguji multi-utas bersertifikasi, kami mungkin ingin mengonfirmasi bahwa beberapa tindakan asinkron sedang terjadi. ConcurrentUnit's Waiter membuat ini mudah:
Waiter waiter = new Waiter(); messageBus.registerHandler(message -> { waiter.resume(); }; messageBus.publish("one"); messageBus.publish("two"); waiter.await(1, TimeUnit.SECONDS, 2);
Di sini kami menerbitkan dua pesan di bus dan memverifikasi bahwa kedua pesan dikirim, membuat Pelayan menunggu resume () dipanggil 2 kali. Jika pesan tidak terkirim dan resume tidak dipanggil dua kali dalam 1 detik, maka pengujian akan gagal dengan kesalahan TimeoutException.
Satu tip umum dengan pendekatan ini adalah untuk memastikan waktu tunggu Anda cukup lama untuk menyelesaikan setiap tindakan bersamaan. Dalam kondisi normal, ketika sistem yang diuji berfungsi seperti yang diharapkan, batas waktu tidak menjadi masalah dan hanya berlaku jika terjadi kegagalan sistem karena alasan apa pun.
Ringkasan
Dalam artikel ini, kami belajar bahwa pengujian unit multithread tidak jahat dan cukup mudah dilakukan. Kami belajar tentang pendekatan umum ketika kami memblokir utas tes utama, melakukan pemeriksaan dari beberapa utas lainnya, dan kemudian melanjutkan utas utama. Dan kami belajar tentang
ConcurrentUnit , yang dapat memfasilitasi tugas ini.
Selamat mencoba!
Diterjemahkan oleh @middle_java