Mengoptimalkan Tes Integrasi Pegas

1. Perkenalan

Dalam artikel ini, kita akan membahas secara holistik tentang pengujian integrasi menggunakan Spring dan cara mengoptimalkannya.

Pertama, kami akan membahas secara singkat pentingnya uji integrasi dan tempatnya di Perangkat Lunak modern yang berfokus pada ekosistem Spring.

Nanti, kita akan membahas beberapa skenario, dengan fokus pada aplikasi web.

Selanjutnya, kita akan membahas beberapa strategi untuk meningkatkan kecepatan pengujian , dengan mempelajari tentang pendekatan berbeda yang dapat memengaruhi cara kami membentuk pengujian dan cara kami membentuk aplikasi itu sendiri.

Sebelum memulai, penting untuk diingat bahwa ini adalah artikel opini berdasarkan pengalaman. Beberapa dari hal ini mungkin cocok untuk Anda, beberapa mungkin tidak.

Terakhir, artikel ini menggunakan Kotlin untuk sampel kode agar tetap sesingkat mungkin, tetapi konsepnya tidak spesifik untuk bahasa ini dan cuplikan kode akan terasa bermakna bagi developer Java dan Kotlin.

2. Tes Integrasi

Pengujian integrasi adalah bagian fundamental dari rangkaian pengujian otomatis. Meskipun mereka seharusnya tidak sebanyak tes unit jika kita mengikuti piramida tes yang sehat. Mengandalkan kerangka kerja seperti Spring membuat kita membutuhkan cukup banyak pengujian integrasi untuk mengurangi risiko perilaku tertentu dari sistem kita.

Semakin kita menyederhanakan kode kita dengan menggunakan modul Spring (data, keamanan, sosial…), semakin besar kebutuhan untuk pengujian integrasi. Ini menjadi benar terutama ketika kita memindahkan bit dan bobs infrastruktur kita ke kelas @Configuration .

Kita seharusnya tidak “menguji kerangka kerja”, tetapi kita tentunya harus memverifikasi kerangka yang dikonfigurasi untuk memenuhi kebutuhan kita.

Tes integrasi membantu kami membangun kepercayaan, tetapi ada harga yang harus dibayar:

  • Itu adalah kecepatan eksekusi yang lebih lambat, yang berarti build lebih lambat
  • Selain itu, pengujian integrasi menyiratkan cakupan pengujian yang lebih luas yang tidak ideal dalam banyak kasus

Dengan pemikiran ini, kami akan mencoba menemukan beberapa solusi untuk mengurangi masalah yang disebutkan di atas.

3. Menguji Aplikasi Web

Spring menghadirkan beberapa opsi untuk menguji aplikasi web, dan sebagian besar pengembang Spring sudah mengenalnya, berikut ini:

  • MockMvc : Mengolok-olok servlet API, berguna untuk aplikasi web non-reaktif
  • TestRestTemplate : Dapat digunakan dengan menunjuk ke aplikasi kita, berguna untuk aplikasi web non-reaktif di mana servlet tiruan tidak diinginkan
  • WebTestClient: Adalah alat pengujian untuk aplikasi web reaktif, baik dengan permintaan / tanggapan palsu atau mencapai server nyata

Karena kami sudah memiliki artikel yang membahas topik ini, kami tidak akan menghabiskan waktu membicarakannya.

Silakan lihat jika Anda ingin menggali lebih dalam.

4. Mengoptimalkan Waktu Eksekusi

Tes integrasi sangat bagus. Mereka memberi kami tingkat kepercayaan yang baik. Juga jika diimplementasikan dengan tepat, mereka dapat mendeskripsikan maksud dari aplikasi kita dengan cara yang sangat jelas, dengan sedikit mocking dan kebisingan pengaturan.

Namun, saat aplikasi kita matang dan pengembangan terus menumpuk, waktu pembuatan pasti akan naik. Seiring bertambahnya waktu pembuatan, mungkin menjadi tidak praktis untuk terus menjalankan semua pengujian setiap saat.

Setelah itu, memengaruhi putaran umpan balik kami dan mulai menerapkan praktik pengembangan terbaik.

Selain itu, tes integrasi pada dasarnya mahal. Memulai persistensi, mengirim permintaan melalui (bahkan jika mereka tidak pernah meninggalkan localhost ), atau melakukan IO hanya membutuhkan waktu.

Sangat penting untuk mengawasi waktu pembuatan kami, termasuk eksekusi uji. Dan ada beberapa trik yang bisa kita terapkan di Spring agar tetap rendah.

Pada bagian selanjutnya, kami akan membahas beberapa poin untuk membantu kami mengoptimalkan waktu pembuatan kami serta beberapa kendala yang mungkin memengaruhi kecepatannya:

  • Menggunakan profil dengan bijak - bagaimana profil memengaruhi kinerja
  • Mempertimbangkan kembali @MockBean - bagaimana mengejek kinerja hit
  • Refactoring @MockBean - alternatif untuk meningkatkan kinerja
  • Berpikir dengan hati-hati tentang @ DirtiesContext - anotasi yang berguna namun berbahaya dan cara untuk tidak menggunakannya
  • Menggunakan irisan uji - alat keren yang dapat membantu atau melanjutkan
  • Menggunakan pewarisan kelas - cara untuk mengatur pengujian dengan cara yang aman
  • Manajemen negara - praktik yang baik untuk menghindari tes yang tidak stabil
  • Refactoring ke dalam unit test - cara terbaik untuk mendapatkan tubuh yang kokoh dan tajam

Ayo mulai!

4.1. Menggunakan Profil dengan Bijak

Profil adalah alat yang cukup rapi. Yakni, tag sederhana yang dapat mengaktifkan atau menonaktifkan area tertentu dari Aplikasi kita. Kami bahkan dapat menerapkan tanda fitur dengan mereka!

Saat profil kami semakin kaya, sangat menggoda untuk menukar sesekali dalam pengujian integrasi kami. Ada alat praktis untuk melakukannya, seperti @ActiveProfiles . Namun, setiap kali kami melakukan pengujian dengan profil baru, ApplicationContext baru akan dibuat.

Membuat konteks aplikasi mungkin cepat dengan aplikasi boot musim semi vanilla tanpa apa pun di dalamnya. Tambahkan ORM dan beberapa modul dan itu akan dengan cepat meroket hingga 7+ detik.

Tambahkan banyak profil, dan sebarkan melalui beberapa tes dan kami akan segera mendapatkan build 60+ detik (dengan asumsi kami menjalankan tes sebagai bagian dari build kami - dan kami harus melakukannya).

Begitu kami menghadapi aplikasi yang cukup kompleks, memperbaiki ini menakutkan. Namun, jika kita merencanakan dengan hati-hati sebelumnya, akan menjadi hal yang sepele untuk menjaga waktu pembuatan yang masuk akal.

Ada beberapa trik yang dapat kami ingat terkait profil dalam uji integrasi:

  • Buat profil gabungan, yaitu uji , sertakan semua profil yang diperlukan di dalam - tetap berpegang pada profil uji kami di mana saja
  • Rancang profil kami dengan mempertimbangkan kemampuan untuk diuji. Jika kami akhirnya harus mengganti profil, mungkin ada cara yang lebih baik
  • Nyatakan profil pengujian kami di tempat terpusat - kami akan membicarakannya nanti
  • Hindari menguji semua kombinasi profil. Atau, kita dapat memiliki paket pengujian e2e per lingkungan yang menguji aplikasi dengan kumpulan profil tertentu

4.2. Masalah dengan @MockBean

@MockBean adalah alat yang cukup ampuh.

Saat kita membutuhkan keajaiban Musim Semi tetapi ingin mengejek komponen tertentu, @MockBean sangat berguna. Tapi itu terjadi dengan harga tertentu.

Setiap kali @MockBean muncul di sebuah kelas, cache ApplicationContext ditandai sebagai kotor, oleh karena itu runner akan membersihkan cache setelah kelas uji selesai. Yang lagi-lagi menambahkan beberapa detik ekstra ke build kami.

Ini adalah salah satu yang kontroversial, tetapi mencoba untuk menggunakan aplikasi sebenarnya daripada mengejek skenario khusus ini dapat membantu. Tentu saja, tidak ada peluru perak di sini. Batasan menjadi kabur saat kita tidak membiarkan diri kita meniru ketergantungan.

Kita mungkin berpikir: Mengapa kita bertahan ketika yang ingin kita uji hanyalah layer REST kita? Ini adalah poin yang adil, dan selalu ada kompromi.

Namun, dengan beberapa prinsip dalam pikiran, ini sebenarnya dapat diubah menjadi keuntungan yang mengarah pada desain pengujian dan aplikasi kita yang lebih baik dan mengurangi waktu pengujian.

4.3. Refactoring @MockBean

Di bagian ini, kami akan mencoba memfaktorkan kembali pengujian 'lambat' menggunakan @MockBean untuk membuatnya menggunakan kembali ApplicationContext yang di- cache .

Mari kita asumsikan kita ingin menguji POST yang membuat pengguna. Jika kami mengejek - menggunakan @MockBean , kami cukup memverifikasi bahwa layanan kami telah dipanggil dengan pengguna serial yang bagus.

Jika kami menguji layanan kami dengan benar, pendekatan ini seharusnya cukup:

class UsersControllerIntegrationTest : AbstractSpringIntegrationTest() { @Autowired lateinit var mvc: MockMvc @MockBean lateinit var userService: UserService @Test fun links() { mvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content("""{ "name":"jose" }""")) .andExpect(status().isCreated) verify(userService).save("jose") } } interface UserService { fun save(name: String) }

Kami ingin menghindari @MockBean . Jadi kita akan berakhir dengan mempertahankan entitas (dengan asumsi itulah yang dilakukan layanan).

Pendekatan yang paling naif di sini adalah menguji efek sampingnya: Setelah POSTing, pengguna saya ada di DB saya, dalam contoh kami, ini akan menggunakan JDBC.

Namun, ini melanggar batas pengujian:

@Test fun links() { mvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content("""{ "name":"jose" }""")) .andExpect(status().isCreated) assertThat( JdbcTestUtils.countRowsInTable(jdbcTemplate, "users")) .isOne() }

Dalam contoh khusus ini kami melanggar batas pengujian karena kami memperlakukan aplikasi kami sebagai kotak hitam HTTP untuk mengirim pengguna, tetapi kemudian kami menegaskan menggunakan detail implementasi, yaitu, pengguna kami telah dipertahankan di beberapa DB.

Jika kita menggunakan aplikasi kita melalui HTTP, dapatkah kita menegaskan hasilnya melalui HTTP juga?

@Test fun links() { mvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content("""{ "name":"jose" }""")) .andExpect(status().isCreated) mvc.perform(get("/users/jose")) .andExpect(status().isOk) }

Ada beberapa keuntungan jika kita mengikuti pendekatan terakhir:

  • Pengujian kami akan dimulai lebih cepat (bisa dibilang, mungkin perlu sedikit lebih lama untuk dieksekusi, tetapi harus membayar kembali)
  • Selain itu, pengujian kami tidak mengetahui efek samping yang tidak terkait dengan batas HTTP, yaitu DB
  • Terakhir, pengujian kami mengungkapkan dengan jelas maksud dari sistem: Jika Anda POST, Anda akan dapat MENDAPATKAN Pengguna

Tentu saja, ini tidak selalu memungkinkan karena berbagai alasan:

  • Kami mungkin tidak memiliki titik akhir 'efek samping': Opsi di sini adalah mempertimbangkan untuk membuat 'titik akhir pengujian'
  • Complexity is too high to hit the entire app: An option here is to consider slices (we'll talk about them later)

4.4. Thinking Carefully About @DirtiesContext

Sometimes, we might need to modify the ApplicationContext in our tests. For this scenario, @DirtiesContext delivers exactly that functionality.

For the same reasons exposed above, @DirtiesContext is an extremely expensive resource when it comes to execution time, and as such, we should be careful.

Some misuses of @DirtiesContext include application cache reset or in memory DB resets. There are better ways to handle these scenarios in integration tests, and we'll cover some in further sections.

4.5. Using Test Slices

Test Slices are a Spring Boot feature introduced in the 1.4. The idea is fairly simple, Spring will create a reduced application context for a specific slice of your app.

Also, the framework will take care of configuring the very minimum.

There are a sensible number of slices available out of the box in Spring Boot and we can create our own too:

  • @JsonTest: Registers JSON relevant components
  • @DataJpaTest: Registers JPA beans, including the ORM available
  • @JdbcTest: Useful for raw JDBC tests, takes care of the data source and in memory DBs without ORM frills
  • @DataMongoTest: Tries to provide an in-memory mongo testing setup
  • @WebMvcTest: A mock MVC testing slice without the rest of the app
  • … (we can check the source to find them all)

This particular feature if used wisely can help us build narrow tests without such a big penalty in terms of performance particularly for small/medium sized apps.

However, if our application keeps growing it also piles up as it creates one (small) application context per slice.

4.6. Using Class Inheritance

Using a single AbstractSpringIntegrationTest class as the parent of all our integration tests is a simple, powerful and pragmatic way of keeping the build fast.

If we provide a solid setup, our team will simply extend it, knowing that everything ‘just works'. This way we can worry less about managing state or configuring the framework and focus on the problem at hand.

We could set all the test requirements there:

  • The Spring runner – or preferably rules, in case we need other runners later
  • profiles – ideally our aggregate test profile
  • initial config – setting the state of our application

Let's have a look at a simple base class that takes care of the previous points:

@SpringBootTest @ActiveProfiles("test") abstract class AbstractSpringIntegrationTest { @Rule @JvmField val springMethodRule = SpringMethodRule() companion object { @ClassRule @JvmField val SPRING_CLASS_RULE = SpringClassRule() } }

4.7. State Management

It's important to remember where ‘unit' in Unit Test comes from. Simply put, it means we can run a single test (or a subset) at any point getting consistent results.

Hence, the state should be clean and known before every test starts.

In other words, the result of a test should be consistent regardless of whether it is executed in isolation or together with other tests.

This idea applies just the same to integration tests. We need to ensure our app has a known (and repeatable) state before starting a new test. The more components we reuse to speed things up (app context, DBs, queues, files…), the more chances to get state pollution.

Assuming we went all in with class inheritance, now, we have a central place to manage state.

Let's enhance our abstract class to make sure our app is in a known state before running tests.

In our example, we'll assume there are several repositories (from various data sources), and a Wiremock server:

@SpringBootTest @ActiveProfiles("test") @AutoConfigureWireMock(port = 8666) @AutoConfigureMockMvc abstract class AbstractSpringIntegrationTest { //... spring rules are configured here, skipped for clarity @Autowired protected lateinit var wireMockServer: WireMockServer @Autowired lateinit var jdbcTemplate: JdbcTemplate @Autowired lateinit var repos: Set
    
      @Autowired lateinit var cacheManager: CacheManager @Before fun resetState() { cleanAllDatabases() cleanAllCaches() resetWiremockStatus() } fun cleanAllDatabases() { JdbcTestUtils.deleteFromTables(jdbcTemplate, "table1", "table2") jdbcTemplate.update("ALTER TABLE table1 ALTER COLUMN id RESTART WITH 1") repos.forEach { it.deleteAll() } } fun cleanAllCaches() { cacheManager.cacheNames .map { cacheManager.getCache(it) } .filterNotNull() .forEach { it.clear() } } fun resetWiremockStatus() { wireMockServer.resetAll() // set default requests if any } }
    

4.8. Refactoring into Unit Tests

This is probably one of the most important points. We'll find ourselves over and over with some integration tests that are actually exercising some high-level policy of our app.

Whenever we find some integration tests testing a bunch of cases of core business logic, it's time to rethink our approach and break them down into unit tests.

A possible pattern here to accomplish this successfully could be:

  • Identify integration tests that are testing multiple scenarios of core business logic
  • Duplicate the suite, and refactor the copy into unit Tests – at this stage, we might need to break down the production code too to make it testable
  • Get all tests green
  • Leave a happy path sample that is remarkable enough in the integration suite – we might need to refactor or join and reshape a few
  • Remove the remaining integration Tests

Michael Feathers covers many techniques to achieve this and more in Working Effectively with Legacy Code.

5. Summary

In this article, we had an introduction to Integration tests with a focus on Spring.

Pertama, kami berbicara tentang pentingnya pengujian integrasi dan mengapa pengujian tersebut sangat relevan dalam aplikasi Spring.

Setelah itu, kami merangkum beberapa alat yang mungkin berguna untuk jenis pengujian Integrasi tertentu di Aplikasi Web.

Terakhir, kami memeriksa daftar masalah potensial yang memperlambat waktu eksekusi pengujian kami, serta trik untuk memperbaikinya.