Bangun dengan Percaya Diri: Panduan untuk Tes JUnit
Diterbitkan: 2022-03-11Dengan kemajuan teknologi dan industri yang beralih dari model air terjun ke Agile dan sekarang ke DevOps, perubahan dan penyempurnaan dalam aplikasi diterapkan ke produksi begitu dibuat. Dengan kode yang di-deploy ke produksi secepat ini, kita harus yakin bahwa perubahan kita berhasil, dan juga tidak merusak fungsionalitas yang sudah ada sebelumnya.
Untuk membangun kepercayaan ini, kita harus memiliki kerangka kerja untuk pengujian regresi otomatis. Untuk melakukan pengujian regresi, ada banyak pengujian yang harus dilakukan dari sudut pandang API-level, tetapi di sini kita akan membahas dua jenis pengujian utama:
- Pengujian unit , di mana setiap pengujian yang diberikan mencakup unit terkecil dari suatu program (fungsi atau prosedur). Ini mungkin atau mungkin tidak mengambil beberapa parameter input dan mungkin atau mungkin tidak mengembalikan beberapa nilai.
- Pengujian integrasi , di mana unit individu diuji bersama untuk memeriksa apakah semua unit berinteraksi satu sama lain seperti yang diharapkan.
Ada banyak kerangka kerja yang tersedia untuk setiap bahasa pemrograman. Kami akan fokus pada unit penulisan dan pengujian integrasi untuk aplikasi web yang ditulis dalam kerangka Java Spring.
Sebagian besar waktu, kami menulis metode di kelas, dan ini, pada gilirannya, berinteraksi dengan metode dari beberapa kelas lain. Di dunia sekarang ini—khususnya dalam aplikasi perusahaan—kompleksitas aplikasi sedemikian rupa sehingga satu metode dapat memanggil lebih dari satu metode dari beberapa kelas. Jadi saat menulis unit test untuk metode seperti itu, kita membutuhkan cara untuk mengembalikan data tiruan dari panggilan tersebut. Ini karena maksud dari pengujian unit ini adalah untuk menguji hanya satu metode dan tidak semua panggilan yang dibuat oleh metode khusus ini.
Mari beralih ke pengujian unit Java di Spring menggunakan kerangka kerja JUnit. Kami akan mulai dengan sesuatu yang mungkin pernah Anda dengar: mengejek.
Apa Itu Mengejek dan Kapan Itu Muncul?
Misalkan Anda memiliki kelas, CalculateArea
, yang memiliki fungsi calculateArea(Type type, Double... args)
yang menghitung luas bentuk dari tipe yang diberikan (lingkaran, persegi, atau persegi panjang.)
Kode berjalan seperti ini dalam aplikasi normal yang tidak menggunakan injeksi ketergantungan:
public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
public class SquareService { public Double area(double r) { return r * r; } }
public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
public enum Type { RECTANGLE,SQUARE,CIRCLE; }
Sekarang, jika kita ingin menguji unit fungsi calculateArea()
dari kelas CalculateArea
, maka motif kita harus memeriksa apakah kasus switch
dan kondisi pengecualian berfungsi. Kita tidak boleh menguji apakah layanan bentuk mengembalikan nilai yang benar, karena seperti yang disebutkan sebelumnya, motif pengujian unit suatu fungsi adalah untuk menguji logika fungsi, bukan logika pemanggilan fungsi yang dibuat.
Jadi kami akan mengejek nilai yang dikembalikan oleh fungsi layanan individual (mis. rectangleService.area()
dan menguji fungsi pemanggilan (mis. CalculateArea.calculateArea()
) berdasarkan nilai tiruan tersebut.
Kasus pengujian sederhana untuk layanan persegi panjang—menguji bahwa calculateArea()
memang memanggil rectangleService.area()
dengan parameter yang benar—akan terlihat seperti ini:
import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Dua jalur utama yang perlu diperhatikan di sini adalah:
-
rectangleService = Mockito.mock(RectangleService.class);
—Ini menciptakan tiruan, yang bukan objek sebenarnya, tetapi objek tiruan. -
Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
—Ini mengatakan bahwa, ketika diejek, dan metodearea
objekrectangleService
dipanggil dengan parameter yang ditentukan, lalu kembalikan20d
.
Sekarang, apa yang terjadi ketika kode di atas adalah bagian dari aplikasi Spring?
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Di sini kami memiliki dua anotasi untuk kerangka kerja Spring yang mendasari untuk dideteksi pada saat inisialisasi konteks:
-
@Component
: Membuat kacang tipeCalculateArea
-
@Autowired
: Mencari kacangrectangleService
,squareService
, dancircleService
dan menyuntikkannya ke dalam beancalculatedArea
Demikian pula, kami juga membuat kacang untuk kelas lain:
import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }
Sekarang jika kita menjalankan tes, hasilnya sama. Kami menggunakan injeksi konstruktor di sini, dan untungnya, jangan mengubah kasus uji kami.
Tetapi ada cara lain untuk menyuntikkan biji layanan persegi, lingkaran, dan persegi panjang: injeksi lapangan. Jika kita menggunakannya, maka test case kita akan membutuhkan beberapa perubahan kecil.
Kami tidak akan membahas mekanisme injeksi mana yang lebih baik, karena itu bukan cakupan artikel. Tetapi kami dapat mengatakan ini: Apa pun jenis mekanisme yang Anda gunakan untuk menyuntikkan kacang, selalu ada cara untuk menulis tes JUnit untuk itu.
Dalam kasus injeksi lapangan, kodenya seperti ini:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Catatan: Karena kita menggunakan injeksi bidang, tidak diperlukan konstruktor berparameter, jadi objek dibuat menggunakan yang default dan nilainya ditetapkan menggunakan mekanisme injeksi bidang.
Kode untuk kelas layanan kami tetap sama seperti di atas, tetapi kode untuk kelas uji adalah sebagai berikut:
public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Beberapa hal berbeda di sini: bukan dasar-dasarnya, tetapi cara kita mencapainya.
Pertama, cara kita mengolok-olok objek kita: Kita menggunakan anotasi @Mock
bersama dengan initMocks()
untuk membuat tiruan. Kedua, kami menyuntikkan tiruan ke objek aktual menggunakan @InjectMocks
bersama dengan initMocks()
.
Ini hanya dilakukan untuk mengurangi jumlah baris kode.
Apa Itu Pelari Uji, dan Jenis Pelari Apa Saja?
Dalam contoh di atas, runner dasar yang digunakan untuk menjalankan semua pengujian adalah BlockJUnit4ClassRunner
yang mendeteksi semua anotasi dan menjalankan semua pengujian yang sesuai.
Jika kami menginginkan lebih banyak fungsionalitas maka kami dapat menulis pelari khusus. Misalnya, di kelas pengujian di atas, jika kita ingin melewati baris MockitoAnnotations.initMocks(this);
maka kita bisa menggunakan runner lain yang dibangun di atas BlockJUnit4ClassRunner
, misalnya MockitoJUnitRunner
.
Menggunakan MockitoJUnitRunner
, kita bahkan tidak perlu menginisialisasi tiruan dan menyuntikkannya. Itu akan dilakukan oleh MockitoJUnitRunner
sendiri hanya dengan membaca anotasi.
(Ada juga SpringJUnit4ClassRunner
, yang menginisialisasi ApplicationContext
yang diperlukan untuk pengujian integrasi Spring—sama seperti ApplicationContext
dibuat saat aplikasi Spring dimulai. Ini akan kita bahas nanti.)
Ejekan Sebagian
Ketika kita ingin sebuah objek di kelas pengujian untuk mengejek beberapa metode, tetapi juga memanggil beberapa metode yang sebenarnya, maka kita perlu mengejek sebagian. Ini dicapai melalui @Spy
di JUnit.
Tidak seperti menggunakan @Mock
, dengan @Spy
, objek nyata dibuat, tetapi metode objek tersebut dapat diejek atau sebenarnya disebut—apa pun yang kita butuhkan.
Misalnya, jika metode area
di kelas RectangleService
memanggil metode tambahan log()
dan kita benar-benar ingin mencetak log itu, maka kodenya akan berubah menjadi seperti di bawah ini:

@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }
Jika kita mengubah anotasi @Mock
dari rectangleService
menjadi @Spy
, dan juga membuat beberapa perubahan kode seperti yang ditunjukkan di bawah ini, maka dalam hasil kita akan benar-benar melihat log dicetak, tetapi metode area()
akan diejek. Artinya, fungsi aslinya dijalankan semata-mata untuk efek sampingnya; nilai pengembaliannya diganti dengan yang diejek.
@RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Bagaimana Kami Menguji Controller
atau RequestHandler
?
Dari apa yang kami pelajari di atas, kode uji pengontrol untuk contoh kami akan menjadi seperti di bawah ini:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }
Melihat kode pengujian pengontrol di atas, ini berfungsi dengan baik, tetapi memiliki satu masalah dasar: Ini hanya menguji panggilan metode, bukan panggilan API yang sebenarnya. Semua kasus pengujian di mana parameter API dan status panggilan API perlu diuji untuk input yang berbeda tidak ada.
Kode ini lebih baik:
import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
Di sini kita dapat melihat bagaimana MockMvc
melakukan tugas melakukan panggilan API yang sebenarnya. Ini juga memiliki beberapa pencocokan khusus seperti status()
dan content()
yang memudahkan untuk memvalidasi konten.
Pengujian Integrasi Java Menggunakan JUnit dan Mock
Sekarang setelah kita mengetahui masing-masing unit kode berfungsi, mari pastikan mereka juga berinteraksi satu sama lain seperti yang kita harapkan.
Pertama, kita perlu membuat instance semua kacang, hal yang sama yang terjadi pada saat inisialisasi konteks Spring selama startup aplikasi.
Untuk ini, kami mendefinisikan semua kacang di kelas, katakanlah TestConfig.java
:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }
Sekarang mari kita lihat bagaimana kita menggunakan kelas ini dan menulis tes integrasi:
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
Beberapa hal berubah di sini:
-
@ContextConfiguration(classes = {TestConfig.class})
—ini memberitahu test case tempat semua definisi bean berada. - Sekarang alih-alih
@InjectMocks
kami menggunakan:
@Autowired AreaController areaController;
Segala sesuatu yang lain tetap sama. Jika kita men-debug pengujian, kita akan melihat bahwa kode benar-benar berjalan hingga baris terakhir dari metode area()
di RectangleService
di mana return r*h
dihitung. Dengan kata lain, logika bisnis yang sebenarnya berjalan.
Ini tidak berarti bahwa tidak ada ejekan terhadap pemanggilan metode atau pemanggilan database yang tersedia dalam pengujian integrasi. Dalam contoh di atas, tidak ada layanan atau basis data pihak ketiga yang digunakan, oleh karena itu kami tidak perlu menggunakan tiruan. Dalam kehidupan nyata, aplikasi seperti itu jarang terjadi, dan kita akan sering menemukan database atau API pihak ketiga, atau keduanya. Dalam hal ini, ketika kita membuat kacang di kelas TestConfig
, kita tidak membuat objek sebenarnya, tetapi objek tiruan, dan menggunakannya di mana pun diperlukan.
Bonus: Cara Membuat Data Uji Objek Besar
Seringkali yang menghentikan pengembang back-end dalam menulis unit atau pengujian integrasi adalah data pengujian yang harus kita persiapkan untuk setiap pengujian.
Biasanya jika datanya cukup kecil, memiliki satu atau dua variabel, maka mudah untuk membuat objek dari kelas data uji dan menetapkan beberapa nilai.
Misalnya, jika kita mengharapkan objek tiruan untuk mengembalikan objek lain, ketika suatu fungsi dipanggil pada objek tiruan, kita akan melakukan sesuatu seperti ini:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
Dan kemudian untuk menggunakan objek ini, kita akan melakukan sesuatu seperti ini:
Mockito.when(service.method(arguments...)).thenReturn(object);
Ini baik-baik saja dalam contoh JUnit di atas, tetapi ketika variabel anggota di kelas Class1
di atas terus meningkat, maka pengaturan bidang individu menjadi cukup merepotkan. Kadang-kadang bahkan mungkin terjadi bahwa suatu kelas memiliki anggota kelas non-primitif lain yang ditentukan. Kemudian, membuat objek dari kelas itu dan menyetel bidang yang diperlukan individu semakin meningkatkan upaya pengembangan hanya untuk menyelesaikan beberapa boilerplate.
Solusinya adalah membuat skema JSON dari kelas di atas dan menambahkan data yang sesuai dalam file JSON satu kali. Sekarang di kelas uji tempat kita membuat objek Class1
, kita tidak perlu membuat objek secara manual. Sebagai gantinya, kami membaca file JSON dan, menggunakan ObjectMapper
, memetakannya ke dalam kelas Class1
yang diperlukan:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );
Ini adalah upaya satu kali untuk membuat file JSON dan menambahkan nilai ke dalamnya. Setiap pengujian baru setelah itu dapat menggunakan salinan file JSON tersebut dengan bidang yang diubah sesuai dengan kebutuhan pengujian baru.
Dasar-dasar JUnit: Berbagai Pendekatan dan Keterampilan yang Dapat Dipindahtangankan
Jelas bahwa ada banyak cara untuk menulis pengujian unit Java tergantung pada bagaimana kita memilih untuk menyuntikkan kacang. Sayangnya, sebagian besar artikel tentang topik tersebut cenderung menganggap hanya ada satu cara, sehingga mudah menjadi bingung, terutama ketika bekerja dengan kode yang ditulis dengan asumsi yang berbeda. Mudah-mudahan, pendekatan kami di sini menghemat waktu pengembang dalam mencari tahu cara yang benar untuk mengejek dan test runner mana yang akan digunakan.
Terlepas dari bahasa atau kerangka kerja yang kami gunakan—bahkan mungkin versi baru dari Spring atau JUnit—dasar konseptual tetap sama seperti yang dijelaskan dalam tutorial JUnit di atas. Selamat menguji!