Menguji dengan contoh
Diterbitkan: 2022-02-15Kami melanjutkan rangkaian blog kami tentang segala sesuatu yang berhubungan dengan pengujian. Di blog ini, kami berfokus pada contoh nyata.
Sementara contoh dalam posting ini ditulis menggunakan JUnit 5 dan AssertJ, pelajarannya berlaku untuk kerangka pengujian unit lainnya.
JUnit adalah kerangka pengujian paling populer untuk Java. AssertJ adalah pustaka Java yang membantu pengembang menulis tes yang lebih ekspresif.
Struktur tes dasar
Contoh tes pertama yang akan kita lihat adalah kalkulator sederhana untuk menjumlahkan 2 angka.
kelas KalkulatorSeharusnya { @Uji // 1 jumlah batal() { Kalkulator kalkulator = Kalkulator baru(); // 2 int hasil = kalkulator.jumlah(1, 2); // 3 menegaskanItu(hasil).isEqualTo(3); // 4 } }
Saya lebih suka menggunakan konvensi penamaan ClassShould
saat menulis tes untuk menghindari pengulangan should
atau test
di setiap nama metode. Anda dapat membaca lebih lanjut tentang itu di sini.
Apa gunanya tes di atas?
Mari kita pecahkan tes baris demi baris:
- Anotasi
@Test
memungkinkan kerangka kerja JUnit mengetahui metode mana yang dimaksudkan untuk dijalankan sebagai pengujian. Sangat normal untuk memiliki metodeprivate
di kelas tes yang bukan tes. - Ini adalah fase penyusunan pengujian kami, di mana kami mempersiapkan lingkungan pengujian. Yang kita butuhkan untuk pengujian ini adalah memiliki instance
Calculator
. - Ini adalah fase tindakan di mana kita memicu perilaku yang ingin kita uji.
- Ini adalah fase penegasan di mana kami memeriksa apa yang terjadi dan jika semuanya diselesaikan seperti yang diharapkan.
assertThat(result)
adalah bagian dari library AssertJ dan memiliki banyak kelebihan.
Setiap kelebihan mengembalikan objek Assert
khusus. Objek yang dikembalikan memiliki metode yang masuk akal untuk objek yang kami berikan ke metode assertThat
. Dalam kasus kami, objek itu adalah AbstractIntegerAssert
dengan metode untuk menguji Integer. isEqualTo(3)
akan memeriksa apakah result == 3
. Jika ya, tes akan lulus dan gagal sebaliknya.
Kami tidak akan fokus pada implementasi apa pun dalam posting blog ini.
Cara berpikir lain tentang Atur , Tindakan , Tegaskan Diberikan , Kapan , Lalu .
Setelah kami menulis implementasi sum
kami, kami dapat bertanya pada diri sendiri beberapa pertanyaan:
- Bagaimana saya bisa meningkatkan tes ini?
- Apakah ada lebih banyak kasus uji yang harus saya bahas?
- Apa yang terjadi jika saya menambahkan angka positif dan negatif? Dua bilangan negatif? Satu positif dan satu negatif?
- Bagaimana jika saya melebihi nilai integer?
Mari tambahkan kasus ini dan tingkatkan sedikit nama pengujian yang ada.
Kami tidak akan membiarkan overflow dalam implementasi kami. Jika sum
meluap, kami akan melempar ArithmeticException
sebagai gantinya.
kelas KalkulatorSeharusnya { kalkulator Kalkulator pribadi = Kalkulator baru(); @Uji void sumPositiveNumbers() { int jumlah = kalkulator.jumlah(1, 2); menegaskanItu(jumlah).isEqualTo(3); } @Uji void sumNegativeNumbers() { int jumlah = kalkulator.jumlah(-1, -1); menegaskanItu(jumlah).isEqualTo(-2); } @Uji void sumPositiveAndNegativeNumbers() { int jumlah = kalkulator.jumlah(1, -2); menegaskanItu(jumlah).isEqualTo(-1); } @Uji void failWithArithmeticExceptionWhenOverflown() { assertThatThrownBy(() -> kalkulator.sum(Integer.MAX_VALUE, 1)) .isInstanceOf(ArithmeticException.class); } }
JUnit akan membuat instance baru CalculatorShould
sebelum menjalankan setiap metode @Test
. Artinya, setiap CalculatorShould
akan memiliki calculator
yang berbeda sehingga kita tidak perlu men-instance-nya di setiap pengujian.
tes shouldFailWithArithmeticExceptionWhenOverflown
menggunakan jenis pernyataan yang assert
. Ia memeriksa bahwa sepotong kode gagal. metode assertThatThrownBy
akan menjalankan lambda yang kami sediakan dan memastikannya gagal. Seperti yang telah kita ketahui, semua metode assertThat
mengembalikan Assert
khusus yang memungkinkan kita untuk memeriksa jenis pengecualian mana yang terjadi.
Ini adalah contoh bagaimana kita dapat menguji bahwa kode kita gagal ketika kita mengharapkannya. Jika suatu saat kami memfaktorkan ulang Calculator
dan tidak mengeluarkan ArithmeticException
pada overflow, pengujian kami akan gagal.
Pola desain objek Ibu
Contoh berikutnya adalah kelas validator untuk memastikan instance Person valid.
class PersonValidatorShould { validator PersonValidator pribadi = PersonValidator baru(); @Uji void failWhenNameIsNull() { Orang orang = Orang baru(null, 20, Alamat baru(...), ...); assertThatThrownBy(() -> validator.validate(person)) .isInstanceOf(InvalidPersonException.class); } @Uji void failWhenAgeIsNegative() { Orang orang = Orang baru("John", -5, Alamat baru(...), ...); assertThatThrownBy(() -> validator.validate(person)) .isInstanceOf(InvalidPersonException.class); } }
Pola desain ObjectMother sering digunakan dalam pengujian yang membuat objek kompleks untuk menyembunyikan detail instantiasi dari pengujian. Beberapa tes bahkan mungkin membuat objek yang sama tetapi menguji hal-hal yang berbeda di atasnya.
Tes #1 sangat mirip dengan tes #2. Kita dapat PersonValidatorShould
dengan mengekstrak validasi sebagai metode pribadi lalu meneruskan instance Person
ilegal ke dalamnya dengan harapan semuanya gagal dengan cara yang sama.
class PersonValidatorShould { validator PersonValidator pribadi = PersonValidator baru(); @Uji void failWhenNameIsNull() { shouldFailValidation(PersonObjectMother.createPersonWithoutName()); } @Uji void failWhenAgeIsNegative() { shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge()); } private void shouldFailValidation(Person invalidPerson) { assertThatThrownBy(() -> validator.validate(invalidPerson)) .isInstanceOf(InvalidPersonException.class); } }
Menguji keacakan
Bagaimana seharusnya kita menguji keacakan dalam kode kita?
Misalkan kita memiliki PersonGenerator
yang memiliki generateRandom
untuk menghasilkan instance Person
secara acak.
Kita mulai dengan menulis sebagai berikut:
class PersonGeneratorShould { generator PersonGenerator pribadi = PersonGenerator baru(); @Uji void generateValidPerson() { Orang orang = generator.generateRandom(); menegaskanItu(orang). } }
Dan kemudian kita harus bertanya pada diri sendiri:
- Apa yang saya coba buktikan di sini? Apa yang perlu dilakukan fungsi ini?
- Haruskah saya memverifikasi bahwa orang yang dihasilkan adalah instance non-null?
- Apakah saya perlu membuktikan itu acak?
- Apakah instance yang dihasilkan harus mengikuti beberapa aturan bisnis?
Kami dapat menyederhanakan pengujian kami menggunakan Dependency Injection.
antarmuka publik RandomGenerator { String generateRandomString(); int generateRandomInteger(); }
PersonGenerator
sekarang memiliki konstruktor lain yang juga menerima turunan dari antarmuka itu. Secara default, ini menggunakan implementasi JavaRandomGenerator
yang menghasilkan nilai acak menggunakan java.Random
.
Namun, dalam pengujian, kami dapat menulis implementasi lain yang lebih dapat diprediksi.
@Uji void generateValidPerson() { RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20); Generator PersonGenerator = baru PersonGenerator(randomGenerator); Orang orang = generator.generateRandom(); menegaskanItu(orang).isEqualTo(Orang baru("John Doe", 20)); }
Tes ini membuktikan bahwa PersonGenerator
menghasilkan instance acak seperti yang ditentukan oleh RandomGenerator
tanpa masuk ke detail RandomGenerator
.
Menguji JavaRandomGenerator
tidak benar-benar menambah nilai apa pun karena ini adalah pembungkus sederhana di sekitar java.Random
. Dengan mengujinya, Anda pada dasarnya akan menguji java.Random
dari perpustakaan standar Java. Menulis tes yang jelas hanya akan menghasilkan perawatan tambahan dengan sedikit manfaat jika ada.
Untuk menghindari penulisan implementasi untuk tujuan pengujian, seperti PredictableGenerator
, Anda harus menggunakan perpustakaan tiruan seperti Mockito.
Ketika kami menulis PredictableGenerator
, kami sebenarnya mematikan kelas RandomGenerator
secara manual. Anda juga bisa mematikannya menggunakan Mockito:
@Uji void generateValidPerson() { RandomGenerator randomGenerator = tiruan(RandomGenerator.class); ketika(randomGenerator.generateRandomString()).thenReturn("John Doe"); ketika(randomGenerator.generateRandomInteger()).thenReturn(20); Generator PersonGenerator = baru PersonGenerator(randomGenerator); Orang orang = generator.generateRandom(); menegaskanItu(orang).isEqualTo(Orang baru("John Doe", 20)); }
Cara menulis tes ini lebih ekspresif dan menghasilkan lebih sedikit implementasi untuk tes tertentu.
Mockito adalah library Java untuk menulis mock dan stub. Ini sangat berguna saat menguji kode yang bergantung pada pustaka eksternal yang tidak dapat Anda buat dengan mudah. Ini memungkinkan Anda untuk menulis perilaku untuk kelas-kelas ini tanpa mengimplementasikannya secara langsung.
Mockito juga memungkinkan sintaks lain untuk membuat dan menyuntikkan tiruan untuk mengurangi boilerplate ketika kami memiliki lebih dari satu tes yang serupa dengan yang biasa kami lakukan:
@ExtendWith(MockitoExtension.class) // 1 class PersonGeneratorShould { @Mock // 2 RandomGenerator RandomGenerator; @InjectMocks // 3 generator PersonGenerator pribadi; @Uji void generateValidPerson() { ketika(randomGenerator.generateRandomString()).thenReturn("John Doe"); ketika(randomGenerator.generateRandomInteger()).thenReturn(20); Orang orang = generator.generateRandom(); menegaskanItu(orang).isEqualTo(Orang baru("John Doe", 20)); } }
1. JUnit 5 dapat menggunakan "ekstensi" untuk memperluas kemampuannya. Anotasi ini memungkinkannya mengenali tiruan melalui anotasi dan menyuntikkannya dengan benar.
2. Anotasi @Mock
membuat instance bidang yang diolok-olok. Ini sama dengan menulis mock(RandomGenerator.class)
di badan metode pengujian kami.
3. Anotasi @InjectMocks
akan membuat instance baru PersonGenerator
dan menyuntikkan tiruan di instance generator
.
Untuk detail lebih lanjut tentang ekstensi JUnit 5 lihat di sini.
Untuk detail lebih lanjut tentang injeksi Mockito lihat di sini.
Ada satu perangkap untuk menggunakan @InjectMocks
. Ini mungkin menghilangkan kebutuhan untuk mendeklarasikan instance objek secara manual, tetapi kita kehilangan keamanan waktu kompilasi dari konstruktor. Jika suatu saat seseorang menambahkan ketergantungan lain ke konstruktor, kami tidak akan mendapatkan kesalahan waktu kompilasi di sini. Hal ini dapat menyebabkan kegagalan tes yang tidak mudah dideteksi. Saya lebih suka menggunakan @BeforeEach
untuk mengatur instance secara manual:
@ExtendWith(MockitoExtension.class) class PersonGeneratorShould { @Mengejek RandomGenerator RandomGenerator; generator PersonGenerator pribadi; @SebelumSetiap batalkan pengaturan() { generator = new PersonGenerator(randomGenerator); } @Uji void generateValidPerson() { ketika(randomGenerator.generateRandomString()).thenReturn("John Doe"); ketika(randomGenerator.generateRandomInteger()).thenReturn(20); Orang orang = generator.generateRandom(); menegaskanItu(orang).isEqualTo(Orang baru("John Doe", 20)); } }
Menguji proses yang sensitif terhadap waktu
Sepotong kode sering bergantung pada stempel waktu dan kami cenderung menggunakan metode seperti System.currentTimeMillis()
untuk mendapatkan stempel waktu epoch saat ini.
Meskipun ini terlihat baik-baik saja, sulit untuk menguji dan membuktikan apakah kode kita berfungsi dengan benar ketika kelas membuat keputusan untuk kita secara internal. Contoh keputusan seperti itu adalah menentukan hari apa hari ini.
pengindeks kelasHarus { pengindeks pengindeks pribadi = pengindeks baru(); @Uji void generateIndexNameForTomorrow() { String indexName = indexer.tomorrow("my-index"); // tes ini akan berhasil hari ini, tapi bagaimana dengan besok? menegaskanItu(namaindeks) .isEqualTo("indeks-saya.2022-02-02"); } }
Kita harus menggunakan Injeksi Ketergantungan lagi untuk dapat 'mengendalikan' hari apa saat membuat nama indeks.
Java memiliki kelas Clock
untuk menangani kasus penggunaan seperti ini. Kami dapat memberikan instance Clock
ke Indexer
kami untuk mengontrol waktu. Konstruktor default dapat menggunakan Clock.systemUTC()
untuk kompatibilitas mundur. Sekarang kita dapat mengganti panggilan System.currentTimeMillis( System.currentTimeMillis()
dengan clock.millis()
.
Dengan menyuntikkan Clock
, kami dapat menerapkan waktu yang dapat diprediksi di kelas kami dan menulis tes yang lebih baik.
Menguji metode pembuatan file
- Bagaimana seharusnya kita menguji kelas yang menulis outputnya ke file?
- Di mana kami harus menyimpan file-file ini agar dapat berfungsi di OS apa pun?
- Bagaimana kita bisa memastikan bahwa file tersebut belum ada?
Saat berurusan dengan file, akan sulit untuk menulis tes jika kita mencoba mengatasi masalah ini sendiri seperti yang akan kita lihat dalam contoh berikut. Tes berikutnya adalah tes lama dengan kualitas yang meragukan. Itu harus menguji apakah DogToCsvWriter
membuat serial dan menulis anjing ke file CSV:
kelas DogToCsvWriterShould { private DogToCsvWriter writer = new DogToCsvWriter("/tmp/dogs.csv"); @Uji batalkan convertToCsv() { writer.appendAsCsv(Anjing baru(Breed.CORGI, Color.BROWN, "Monty")); writer.appendAsCsv(Anjing baru(Breed.MALTESE, Color.WHITE, "Zoe")); String csv = Files.readString("/tmp/dogs.csv"); assertThat(csv).isEqualTo("Monty,corgi,coklat\nZoe,malta,putih"); } }
Proses serialisasi harus dipisahkan dari proses penulisan, tetapi mari kita fokus pada perbaikan tes.
Masalah pertama dengan pengujian di atas adalah bahwa itu tidak akan berfungsi di Windows karena pengguna Windows tidak akan dapat menyelesaikan jalur /tmp/dogs.csv
. Masalah lainnya adalah itu tidak akan berfungsi jika file sudah ada karena tidak dihapus saat pengujian di atas dijalankan. Ini mungkin berfungsi dengan baik di saluran CI/CD, tetapi tidak secara lokal jika dijalankan beberapa kali.
JUnit 5 memiliki anotasi yang dapat Anda gunakan untuk mendapatkan referensi ke direktori sementara yang dibuat dan dihapus oleh kerangka kerja untuk Anda. Sementara mekanisme membuat dan menghapus file sementara bervariasi dari satu kerangka ke kerangka kerja lainnya, idenya tetap sama.
kelas DogToCsvWriterShould { @Uji void convertToCsv(@TempDir Path tempDir) { Path dogsCsv = tempDir.resolve("dogs.csv"); Penulis DogToCsvWriter = DogToCsvWriter baru (dogsCsv); writer.appendAsCsv(Anjing baru(Breed.CORGI, Color.BROWN, "Monty")); writer.appendAsCsv(Anjing baru(Breed.MALTESE, Color.WHITE, "Zoe")); String csv = Files.readString(dogsCsv); assertThat(csv).isEqualTo("Monty,corgi,coklat\nZoe,malta,putih"); } }
Dengan perubahan kecil ini, kami sekarang yakin bahwa pengujian di atas akan bekerja pada Windows, macOS dan Linux tanpa harus khawatir tentang jalur absolut. Ini juga akan menghapus file yang dibuat setelah pengujian sehingga kami sekarang dapat menjalankannya beberapa kali dan mendapatkan hasil yang dapat diprediksi setiap kali.
Pengujian perintah vs kueri
Apa perbedaan antara perintah dan kueri?
- Perintah : kita menginstruksikan suatu objek untuk melakukan tindakan yang menghasilkan efek tanpa mengembalikan nilai (metode batal)
- Query : kami meminta suatu objek untuk melakukan suatu tindakan dan mengembalikan hasil atau pengecualian
Sejauh ini, kami telah menguji sebagian besar kueri di mana kami memanggil metode yang mengembalikan nilai atau telah melemparkan pengecualian dalam fase tindakan. Bagaimana kita bisa menguji metode void
dan melihat apakah mereka berinteraksi dengan benar dengan kelas lain? Kerangka kerja menyediakan serangkaian metode berbeda untuk menulis tes semacam ini.
Pernyataan yang kami tulis sejauh ini untuk kueri dimulai dengan assertThat
. Saat menulis tes perintah, kami menggunakan kumpulan metode yang berbeda karena kami tidak lagi memeriksa hasil langsung metode seperti yang kami lakukan dengan kueri. Kami ingin 'memverifikasi' interaksi metode kami dengan bagian lain dari sistem kami.
@ExtendWith(MockitoExtension.class) class FeedMentionServiceShould { @Mengejek repositori FeedRepository pribadi; @Mengejek emitor FeedMentionEventEmitter pribadi; layanan FeedMentionService pribadi; @SebelumSetiap batalkan pengaturan() { service = new FeedMentionService(repositori, emitor); } @Uji void insertMentionToFeed() { feedId panjang = 1L; Sebutkan sebutkan = ...; kapan(repository.upsertMention(feedId, mention)) .thenReturn(UpsertResult.success(feedId, sebutkan)); FeedInsertionEvent event = new FeedInsertionEvent(feedId, sebutkan); mentionService.insertMentionToFeed(acara); verifikasi(emitor).mentionInsertedToFeed(feedId, sebutkan); verifikasiNoMoreInteractions(emitor); } }
Dalam pengujian ini, pertama-tama kami mengejek repositori kami untuk merespons dengan UpsertResult.success
ketika diminta untuk menyebutkan penyebutan di feed kami. Kami tidak peduli dengan pengujian repositori di sini. Metode repositori harus diuji di FeedRepositoryShould
. Dengan mengejek perilaku ini, kami sebenarnya tidak memanggil metode repositori. Kami hanya memberi tahunya bagaimana meresponsnya saat berikutnya dipanggil.
Kami kemudian memberi tahu mentionService
kami untuk memasukkan penyebutan ini di umpan kami. Kami tahu bahwa itu harus mengeluarkan hasil hanya jika berhasil memasukkan penyebutan di umpan. Dengan menggunakan metode verify
, kami dapat memastikan bahwa metode mentionInsertedToFeed
dipanggil dengan penyebutan dan umpan kami dan tidak dipanggil lagi menggunakan verifyNoMoreInteractions
.
Pikiran terakhir
Menulis tes kualitas berasal dari pengalaman, dan cara terbaik untuk belajar adalah dengan melakukan. Kiat-kiat yang ditulis di blog ini berasal dari latihan. Sulit untuk melihat beberapa jebakan jika Anda tidak pernah menemukannya dan mudah-mudahan, saran ini akan membuat desain kode Anda lebih kuat. Memiliki tes yang andal akan meningkatkan kepercayaan diri Anda untuk mengubah banyak hal tanpa berkeringat setiap kali Anda harus menerapkan kode Anda.
Tertarik untuk bergabung dengan tim Mediatoolkit?
Lihat posisi terbuka kami untuk Pengembang Frontend Senior !