Praktik Terbaik Pengujian Java





Untuk memiliki cakupan kode yang memadai, dan untuk membuat fungsionalitas baru dan memfaktor ulang yang lama tanpa takut merusak sesuatu, pengujian harus dapat dipelihara dan mudah dibaca. Pada artikel ini, saya akan berbicara tentang banyak teknik untuk menulis tes unit dan integrasi di Java, yang telah saya kumpulkan selama bertahun-tahun. Saya akan mengandalkan teknologi modern: JUnit5, AssertJ, Testcontainers, dan juga saya tidak akan mengabaikan Kotlin. Beberapa tip akan tampak jelas bagi Anda, sementara yang lain mungkin bertentangan dengan apa yang telah Anda baca di buku tentang pengembangan dan pengujian perangkat lunak.



Pendeknya



  • Tulis pengujian secara ringkas dan spesifik, menggunakan fungsi helper, parameterisasi, berbagai primitif library AssertJ, jangan menyalahgunakan variabel, hanya memeriksa apa yang terkait dengan fungsionalitas yang diuji dan jangan memasukkan semua kasus non-standar ke dalam satu pengujian
  • , ,
  • , -,
  • KISS DRY
  • , , , in-memory-
  • JUnit5 AssertJ —
  • : , , Clock - .




Given, When, Then (, , )



Tes harus terdiri dari tiga blok, dipisahkan oleh baris kosong. Setiap blok harus dibuat sesingkat mungkin. Gunakan metode lokal untuk menjaganya tetap kompak.



Diberikan / Diberikan (masukan): persiapan ujian, misalnya, pembuatan data dan konfigurasi tiruan.

When (action): panggil metode yang diuji

Then / To (output): periksa kebenaran dari nilai yang diterima



// 
@Test
public void findProduct() {
    insertIntoDatabase(new Product(100, "Smartphone"));

    Product product = dao.findProduct(100);

    assertThat(product.getName()).isEqualTo("Smartphone");
}


Gunakan awalan "aktual *" dan "diharapkan *"



// 
ProductDTO product1 = requestProduct(1);

ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);



Jika Anda akan menggunakan variabel dalam uji kecocokan, tambahkan awalan "aktual" dan "diharapkan" ke variabel ini. Ini akan meningkatkan keterbacaan kode Anda dan memperjelas tujuan variabel. Itu juga membuat mereka lebih sulit untuk dibingungkan saat membandingkan.



// 
ProductDTO actualProduct = requestProduct(1);

ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); //   


Gunakan nilai preset, bukan nilai acak



Hindari memasukkan nilai acak ke input tes. Hal ini dapat menyebabkan tes berkedip, yang sangat sulit untuk di-debug. Selain itu, jika Anda melihat nilai acak dalam pesan kesalahan, Anda tidak akan dapat melacaknya kembali ke tempat terjadinya kesalahan.



// 
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad



Gunakan nilai standar yang berbeda untuk semuanya. Dengan cara ini Anda akan mendapatkan hasil tes yang dapat direproduksi dengan sempurna, serta dengan cepat menemukan tempat yang tepat dalam kode melalui pesan kesalahan.



// 
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");



Anda dapat menulis ini lebih pendek lagi menggunakan fungsi pembantu (lihat di bawah).



Tulis tes yang ringkas dan spesifik



Gunakan fungsi pembantu jika memungkinkan



Pisahkan kode berulang ke dalam fungsi lokal dan beri nama yang bermakna. Ini akan membuat tes Anda tetap ringkas dan mudah dibaca dalam sekejap.



// 
@Test
public void categoryQueryParameter() throws Exception {
    List<ProductEntity> products = List.of(
            new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
            new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
            new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
    );
    for (ProductEntity product : products) {
        template.execute(createSqlInsertStatement(product));
    }

    String responseJson = client.perform(get("/products?category=Office"))
            .andExpect(status().is(200))
            .andReturn().getResponse().getContentAsString();

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


// 
@Test
public void categoryQueryParameter2() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("1", "Office"),
            createProductWithCategory("2", "Office"),
            createProductWithCategory("3", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("1", "2");
}


  • menggunakan fungsi helper untuk membuat data (objek) ( createProductWithCategory()) dan pemeriksaan kompleks. Teruskan hanya parameter tersebut ke fungsi helper yang relevan dalam pengujian ini; selebihnya, gunakan default yang memadai. Di Kotlin, ada nilai parameter default untuk ini, dan di Java, Anda dapat menggunakan rantai panggilan metode dan overloading untuk menyimulasikan parameter default.
  • daftar parameter panjang variabel akan membuat kode Anda lebih elegan ( ìnsertIntoDatabase())
  • fungsi helper juga dapat digunakan untuk membuat nilai sederhana. Kotlin melakukannya dengan lebih baik melalui fungsi ekstensi.


//  (Java)
Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)
UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")


//  (Kotlin)
val ts = 1.toInstant()
val id = 1.toUUID()


Fungsi helper di Kotlin dapat diimplementasikan seperti ini:



fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())

fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")


Jangan terlalu sering menggunakan variabel



Refleks terkondisi programmer adalah untuk memindahkan nilai yang sering digunakan ke dalam variabel.



// 
@Test
public void variables() throws Exception {
    String relevantCategory = "Office";
    String id1 = "4243";
    String id2 = "1123";
    String id3 = "9213";
    String irrelevantCategory = "Hardware";
    insertIntoDatabase(
            createProductWithCategory(id1, relevantCategory),
            createProductWithCategory(id2, relevantCategory),
            createProductWithCategory(id3, irrelevantCategory)
    );

    String responseJson = requestProductsByCategory(relevantCategory);

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly(id1, id2);
}


Sayangnya, ini sangat kelebihan kode. Lebih buruk lagi, melihat nilai dalam pesan kesalahan tidak mungkin dilacak kembali ke tempat kesalahan terjadi.

"KISS lebih penting dari KERING"


// 
@Test
public void variables() throws Exception {
    insertIntoDatabase(
            createProductWithCategory("4243", "Office"),
            createProductWithCategory("1123", "Office"),
            createProductWithCategory("9213", "Hardware")
    );

    String responseJson = requestProductsByCategory("Office");

    assertThat(toDTOs(responseJson))
            .extracting(ProductDTO::getId)
            .containsOnly("4243", "1123");
}


Jika Anda mencoba menulis tes sekompak mungkin (yang saya anjurkan dengan hangat), maka nilai yang digunakan kembali terlihat jelas. Kode itu sendiri menjadi lebih kompak dan lebih mudah dibaca. Terakhir, pesan kesalahan akan mengarahkan Anda ke baris tempat kesalahan terjadi.



Jangan memperluas pengujian yang ada untuk "menambahkan satu hal kecil lagi"



// 
public class ProductControllerTest {
    @Test
    public void happyPath() {
        //   ...
    }
}


Selalu tergoda untuk menambahkan kasus khusus ke pengujian yang ada yang memvalidasi fungsionalitas dasar. Namun akibatnya, ujian menjadi lebih besar dan lebih sulit untuk dipahami. Kasus-kasus tertentu yang tersebar di selembar kode besar mudah untuk diabaikan. Jika pengujian gagal, Anda mungkin tidak segera memahami apa sebenarnya penyebabnya.



// 
public class ProductControllerTest {
    @Test
    public void multipleProductsAreReturned() {}
    @Test
    public void allProductValuesAreReturned() {}
    @Test
    public void filterByCategory() {}
    @Test
    public void filterByDateCreated() {}
}


Sebaliknya, tulis pengujian baru dengan nama deskriptif yang segera menjelaskan perilaku apa yang diharapkan dari kode yang diuji. Ya, Anda harus mengetik lebih banyak huruf pada keyboard (melawan ini, izinkan saya mengingatkan Anda, fungsi pembantu membantu dengan baik), tetapi Anda akan mendapatkan tes yang sederhana dan dapat dimengerti dengan hasil yang dapat diprediksi. Omong-omong, ini adalah cara yang bagus untuk mendokumentasikan fungsionalitas baru.



Centang hanya yang ingin Anda uji



Pikirkan tentang fungsionalitas yang Anda uji. Hindari melakukan pemeriksaan yang tidak perlu hanya karena Anda bisa. Selain itu, ingatlah apa yang telah diuji dalam tes tertulis sebelumnya dan jangan mengujinya kembali. Tes harus kompak dan perilaku yang diharapkan harus jelas dan tanpa detail yang tidak perlu.



Katakanlah kita ingin menguji pegangan HTTP yang mengembalikan daftar produk. Rangkaian pengujian kami harus berisi pengujian berikut:



1. Satu pengujian pemetaan besar yang memverifikasi bahwa semua nilai dari database dikembalikan dengan benar dalam respons JSON dan ditetapkan dengan benar dalam format yang benar. Kita dapat dengan mudah menulis ini menggunakan fungsi isEqualTo()(untuk satu item) atau containsOnly()(untuk beberapa item) dari paket AssertJ, jika Anda mengimplementasikan metode dengan benarequals()...



String responseJson = requestProducts();

ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
        .containsOnly(expectedDTO1, expectedDTO2);


2. Beberapa pengujian yang memeriksa perilaku yang benar dari parameter? Kategori. Di sini kami hanya ingin memeriksa apakah filter berfungsi dengan baik, bukan nilai properti, karena kami melakukannya sebelumnya. Oleh karena itu, cukup bagi kami untuk memeriksa kecocokan id produk yang diterima:



String responseJson = requestProductsByCategory("Office");

assertThat(toDTOs(responseJson))
        .extracting(ProductDTO::getId)
        .containsOnly("1", "2");


3. Beberapa tes lagi yang memeriksa kasus khusus atau logika bisnis khusus, misalnya, bahwa nilai-nilai tertentu dalam respons dihitung dengan benar. Dalam kasus ini, kami hanya tertarik pada beberapa bidang dari seluruh respons JSON. Jadi, kami mendokumentasikan logika khusus ini dengan pengujian kami. Jelas bahwa kita tidak membutuhkan apapun selain bidang ini di sini.



assertThat(actualProduct.getPrice()).isEqualTo(100);


Tes mandiri



Jangan sembunyikan parameter yang relevan (dalam fungsi helper)



// 
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Lebih mudah menggunakan fungsi helper untuk menghasilkan data dan memeriksa kondisi, tetapi mereka harus dipanggil dengan parameter. Terima parameter untuk semua yang berarti dalam pengujian dan perlu dikontrol dari kode pengujian. Jangan memaksa pembaca untuk beralih ke fungsi helper untuk memahami arti dari tes tersebut. Aturan sederhana: arti dari tes harus jelas ketika melihat tes itu sendiri.



// 
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Simpan data pengujian di dalam pengujian itu sendiri



Semuanya harus ada di dalam. Sangat menggoda untuk mentransfer beberapa data ke dalam metode @Beforedan menggunakannya kembali dari sana. Tetapi ini akan memaksa pembaca untuk beralih ke file tersebut untuk memahami apa yang sebenarnya terjadi di sini. Sekali lagi, fungsi helper akan membantu Anda menghindari pengulangan dan membuat pengujian Anda lebih mudah dipahami.



Gunakan komposisi alih-alih warisan



Jangan membangun hierarki kelas pengujian yang kompleks.



// 
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}


Hierarki seperti itu mempersulit pemahaman dan Anda, kemungkinan besar, akan segera menemukan diri Anda menulis penerus berikutnya dari tes dasar, di mana banyak sampah dijahit sehingga tes saat ini tidak perlu sama sekali. Ini mengalihkan perhatian pembaca dan menyebabkan kesalahan yang tidak kentara. Pewarisan tidak fleksibel: menurut Anda apakah Anda dapat menggunakan semua metode kelas AllInclusiveBaseTest, tetapi tidak satu pun dari induknya ? AdvancedBaseTest?Selain itu, pembaca harus terus-menerus beralih di antara kelas dasar yang berbeda untuk memahami gambaran besarnya.

“Lebih baik menggandakan kode daripada memilih abstraksi yang salah” (Sandi Metz)



Saya merekomendasikan menggunakan komposisi sebagai gantinya. Tulis cuplikan kecil dan kelas untuk setiap tugas yang terkait dengan perlengkapan (mulai database pengujian, buat skema, masukkan data, mulai server tiruan). Gunakan kembali bagian-bagian ini dalam metode @BeforeAllatau dengan menetapkan objek yang dibuat ke bidang kelas pengujian. Dengan cara ini, Anda akan dapat membangun setiap kelas tes baru dari bagian kosong ini, mulai dari bagian Lego. Akibatnya, setiap tes akan memiliki perlengkapannya sendiri yang dapat dimengerti dan memastikan tidak ada hal asing yang terjadi di dalamnya. Tes menjadi mandiri, karena berisi semua yang Anda butuhkan.



// 
public class MyTest {
    //   
    private JdbcTemplate template;
    private MockWebServer taxService;

    @BeforeAll
    public void setupDatabaseSchemaAndMockWebServer() throws IOException {
        this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
        this.taxService = new MockWebServer();
        taxService.start();
    }
}


//   
public class DatabaseFixture {
    public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {
        PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");
        db.start();
        DataSource dataSource = DataSourceBuilder.create()
                .driverClassName("org.postgresql.Driver")
                .username(db.getUsername())
                .password(db.getPassword())
                .url(db.getJdbcUrl())
                .build();
        JdbcTemplate template = new JdbcTemplate(dataSource);
        SchemaCreator.createSchema(template);
        return template;
    }
}


Sekali lagi:

"KISS lebih penting dari KERING"


Tes langsung itu bagus. Bandingkan hasilnya dengan konstanta



Jangan gunakan kembali kode produksi



Tes harus memvalidasi kode produksi, bukan menggunakannya kembali. Jika Anda menggunakan kembali kode pertempuran dalam ujian, Anda mungkin melewatkan bug dalam kode itu karena Anda tidak lagi mengujinya.



// 
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));

ProductDTO actualDTO = requestProduct(1);


//   
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);


Sebaliknya, pikirkan tentang input dan output saat menulis tes. Tes memasukkan data ke input dan membandingkan output dengan konstanta yang telah ditentukan sebelumnya. Sering kali, penggunaan ulang kode tidak diperlukan.



// Do
assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));


Jangan menyalin logika bisnis ke dalam pengujian



Pemetaan objek adalah contoh utama kasus ketika tes menarik logika dari kode pertempuran ke dalam dirinya sendiri. Misalkan pengujian kami berisi sebuah metode mapEntityToDto(), yang hasilnya digunakan untuk memeriksa bahwa DTO yang dihasilkan berisi nilai yang sama dengan elemen yang ditambahkan ke basis di awal pengujian. Dalam hal ini, Anda kemungkinan besar akan menyalin kode pertempuran ke dalam tes, yang mungkin mengandung kesalahan.



// 
ProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);

ProductDTO actualDTO = requestProduct(1);

 // mapEntityToDto()    ,   -
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);



Solusi yang benar adalah actualDTOmembandingkannya dengan objek referensi yang dibuat secara manual dengan nilai yang ditentukan. Ini sangat sederhana, lugas, dan melindungi dari potensi kesalahan.



// 
ProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);


Jika Anda tidak ingin membuat dan memeriksa kecocokan untuk seluruh objek referensi, Anda dapat memeriksa objek turunan atau umumnya hanya properti objek yang relevan dengan pengujian.



Jangan menulis terlalu banyak logika



Izinkan saya mengingatkan Anda bahwa pengujian terutama tentang input dan output. Kirimkan data dan periksa apa yang dikembalikan kepada Anda. Tidak perlu menulis logika yang rumit di dalam pengujian. Jika Anda memasukkan loop dan kondisi ke dalam pengujian, Anda membuatnya kurang dapat dimengerti dan lebih rentan kesalahan. Jika logika validasi Anda kompleks, gunakan banyak fungsi AssertJ untuk melakukan pekerjaan itu untuk Anda.



Jalankan pengujian di lingkungan seperti pertempuran



Uji paket komponen sepenuhnya



Umumnya disarankan untuk menguji setiap kelas secara terpisah menggunakan tiruan. Namun, pendekatan ini memiliki kelemahan: dengan cara ini, interaksi kelas satu sama lain tidak diuji, dan pemfaktoran ulang entitas umum akan menghentikan semua pengujian sekaligus, karena setiap kelas dalam memiliki pengujiannya sendiri. Selain itu, jika Anda menulis tes untuk setiap kelas, maka jumlahnya akan terlalu banyak.





Pengujian unit terisolasi dari setiap kelas



Sebagai gantinya, saya merekomendasikan untuk fokus pada pengujian integrasi. Yang saya maksud dengan "pengujian integrasi" adalah mengumpulkan semua kelas bersama-sama (seperti dalam produksi) dan menguji seluruh bundel, termasuk komponen infrastruktur (server HTTP, database, logika bisnis). Dalam kasus ini, Anda menguji perilaku alih-alih implementasinya. Pengujian semacam itu lebih akurat, lebih dekat ke dunia nyata, dan tahan terhadap pemfaktoran ulang komponen internal. Idealnya, satu kelas tes sudah cukup.





Pengujian integrasi (= menggabungkan semua kelas dan menguji paket)



Jangan gunakan database dalam memori untuk pengujian





Dengan basis dalam memori, Anda menguji dalam lingkungan berbeda tempat kode Anda akan bekerja.



Menggunakan basis dalam memori ( H2 , HSQLDB , Fongo ) untuk pengujian, Anda mengorbankan validitas dan cakupannya. Database semacam itu sering kali berperilaku berbeda dan menghasilkan hasil yang berbeda. Tes semacam itu mungkin berhasil lulus, tetapi tidak menjamin pengoperasian aplikasi yang benar dalam produksi. Selain itu, Anda dapat dengan mudah menemukan diri Anda dalam situasi di mana Anda tidak dapat menggunakan atau menguji beberapa perilaku atau karakteristik fitur basis Anda, karena mereka tidak diimplementasikan dalam database dalam memori atau berperilaku berbeda.



Solusi: gunakan database yang sama seperti dalam operasi nyata. Perpustakaan Testcontainers yang luar biasa menyediakan API kaya untuk aplikasi Java yang memungkinkan Anda mengelola container langsung dari kode pengujian Anda.



Java / JVM



Menggunakan -noverify -XX:TieredStopAtLevel=1



Selalu tambahkan opsi JVM -noverify -XX:TieredStopAtLevel=1ke konfigurasi Anda untuk menjalankan pengujian. Ini akan menghemat waktu 1-2 detik untuk memulai mesin virtual sebelum menjalankan tes. Ini sangat berguna pada hari-hari awal pengujian Anda, ketika Anda sering menjalankannya dari IDE.



Harap diperhatikan bahwa sejak Java 13 -noverifysudah tidak digunakan lagi.



Tip: Tambahkan argumen ini ke template konfigurasi “JUnit” di IntelliJ IDEA sehingga Anda tidak perlu melakukan ini setiap kali membuat proyek baru.







Gunakan AssertJ



AssertJ adalah pustaka yang sangat kuat dan matang dengan API yang kaya dan aman, serta sekumpulan fungsi validasi nilai yang kaya dan pesan kesalahan pengujian yang informatif. Banyak fungsi validasi yang memudahkan pemrogram membebaskan pemrogram dari kebutuhan untuk mendeskripsikan logika kompleks dalam badan pengujian, yang memungkinkan mereka membuat pengujian yang ringkas. Misalnya:



assertThat(actualProduct)
        .isEqualToIgnoringGivenFields(expectedProduct, "id");

assertThat(actualProductList).containsExactly(
        createProductDTO("1", "Smartphone", 250.00),
        createProductDTO("1", "Smartphone", 250.00)
);

assertThat(actualProductList)
        .usingElementComparatorIgnoringFields("id")
        .containsExactly(expectedProduct1, expectedProduct2);

assertThat(actualProductList)
        .extracting(Product::getId)
        .containsExactly("1", "2");

assertThat(actualProductList)
        .anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));

assertThat(actualProductList)
        .filteredOn(product -> product.getCategory().equals("Smartphone"))
        .allSatisfy(product -> assertThat(product.isLiked()).isTrue());


Hindari menggunakan assertTrue()danassertFalse()



Menggunakan sederhana assertTrue()atau assertFalse()mengarah ke pesan kesalahan pengujian samar:



// 
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);

expected: <true> but was: <false>


Gunakan panggilan AssertJ sebagai gantinya, yang mengembalikan pesan yang jelas dan informatif di luar kotak.



// 
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);

Expecting:
 <[Product[id=1, name='Samsung Galaxy']]>
to contain:
 <[Product[id=2, name='iPhone']]>
but could not find:
 <[Product[id=2, name='iPhone']]>


Jika Anda perlu memeriksa nilai boolean, buat pesan lebih as()deskriptif dengan metode AssertJ.



Gunakan JUnit5



JUnit5 adalah pustaka yang sangat baik untuk pengujian (unit-). Itu terus dikembangkan dan menyediakan programmer dengan banyak fitur yang berguna, seperti tes berparameter, pengelompokan, tes bersyarat, kontrol siklus hidup.



Gunakan pengujian berparameter



Pengujian parameter memungkinkan Anda menjalankan pengujian yang sama dengan sekumpulan nilai input yang berbeda. Ini memungkinkan Anda untuk memeriksa beberapa kasus tanpa menulis kode tambahan. Dalam JUnit5 untuk ini adalah alat yang sangat baik @ValueSource, @EnumSource, @CsvSourcedan @MethodSource.



// 
@ParameterizedTest
@ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"])
public void rejectedInvalidTokens(String invalidToken) {
    client.perform(get("/products").param("token", invalidToken))
            .andExpect(status().is(400))
}

@ParameterizedTest
@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])
public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {
    // ...
}


Saya sangat menyarankan untuk memanfaatkan trik ini semaksimal mungkin, karena ini memungkinkan Anda untuk menguji lebih banyak kasus dengan sedikit usaha.



Terakhir, saya ingin menarik perhatian Anda ke @CsvSourcedan @MethodSource, yang dapat digunakan untuk parameterisasi yang lebih kompleks, di mana Anda juga perlu mengontrol hasilnya: Anda dapat meneruskannya di salah satu parameter.



@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "5, 3, 8",
    "10, -20, -10"
})
public void add(int summand1, int summand2, int expectedSum) {
    assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);
}


@MethodSourcesangat efektif dalam hubungannya dengan objek uji terpisah yang berisi semua parameter yang diinginkan dan hasil yang diharapkan. Sayangnya, di Java, deskripsi struktur data seperti itu (yang disebut POJO) sangat rumit. Oleh karena itu, saya akan memberikan contoh penggunaan kelas data Kotlin.



data class TestData(
    val input: String?,
    val expected: Token?
)

@ParameterizedTest
@MethodSource("validTokenProvider")
fun `parse valid tokens`(data: TestData) {
    assertThat(parse(data.input)).isEqualTo(data.expected)
}

private fun validTokenProvider() = Stream.of(
    TestData(input = "1511443755_2", expected = Token(1511443755, "2")),
    TestData(input = "151175_13521", expected = Token(151175, "13521")),
    TestData(input = "151144375_id", expected = Token(151144375, "id")),
    TestData(input = "15114437599_1", expected = Token(15114437599, "1")),
    TestData(input = null, expected = null)
)


Tes kelompok



Anotasi @Nesteddari JUnit5 berguna untuk metode pengujian pengelompokan. Secara logis, masuk akal untuk mengelompokkan jenis pengujian tertentu (seperti InputIsXY, ErrorCases) atau mengumpulkan setiap metode pengujian ( GetDesigndan UpdateDesign) dalam kelompok Anda .



public class DesignControllerTest {
    @Nested
    class GetDesigns {
        @Test
        void allFieldsAreIncluded() {}
        @Test
        void limitParameter() {}
        @Test
        void filterParameter() {}
    }
    @Nested
    class DeleteDesign {
        @Test
        void designIsRemovedFromDb() {}
        @Test
        void return404OnInvalidIdParameter() {}
        @Test
        void return401IfNotAuthorized() {}
    }
}






Nama pengujian yang dapat dibaca dengan @DisplayNameatau kutipan mundur di Kotlin



Di Java, Anda dapat menggunakan anotasi @DisplayNameuntuk memberikan pengujian Anda nama yang lebih mudah dibaca.



public class DisplayNameTest {
    @Test
    @DisplayName("Design is removed from database")
    void designIsRemoved() {}
    @Test
    @DisplayName("Return 404 in case of an invalid parameter")
    void return404() {}
    @Test
    @DisplayName("Return 401 if the request is not authorized")
    void return401() {}
}






Di Kotlin, Anda dapat menggunakan nama fungsi dengan spasi di dalamnya dengan mengapitnya dalam tanda kutip tunggal backtick. Dengan cara ini Anda mendapatkan hasil yang mudah dibaca tanpa redundansi kode.



@Test
fun `design is removed from db`() {}


Simulasikan layanan eksternal



Untuk menguji klien HTTP, kita perlu mensimulasikan layanan yang mereka akses. Saya sering menggunakan MockWebServer dari OkHttp untuk tujuan ini . Alternatifnya adalah WireMock atau Mockserver dari Testcontainers .



MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
        .addHeader("Content-Type", "application/json")
        .setBody("{\"name\": \"Smartphone\"}"));

ProductDTO productDTO = client.retrieveProduct("1");

assertThat(productDTO.getName()).isEqualTo("Smartphone");


Gunakan Awaitility untuk Menguji Kode Asinkron



Awaitility adalah pustaka untuk menguji kode asinkron. Anda dapat menentukan berapa kali mencoba ulang untuk memeriksa hasil sebelum menyatakan pengujian tidak berhasil.



private static final ConditionFactory WAIT = await()
        .atMost(Duration.ofSeconds(6))
        .pollInterval(Duration.ofSeconds(1))
        .pollDelay(Duration.ofSeconds(1));

@Test
public void waitAndPoll(){
    triggerAsyncEvent();
    WAIT.untilAsserted(() -> {
        assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
    });
}


Tidak perlu menyelesaikan dependensi DI (Spring)



Kerangka DI membutuhkan beberapa detik untuk menginisialisasi sebelum tes dapat dimulai. Ini memperlambat putaran umpan balik, terutama pada tahap awal pengembangan.



Oleh karena itu, saya mencoba untuk tidak menggunakan DI dalam tes integrasi, tetapi membuat objek yang diperlukan secara manual dan "mengikat" keduanya. Jika Anda menggunakan injeksi konstruktor, ini adalah yang termudah. Biasanya, dalam pengujian Anda, Anda memvalidasi logika bisnis, dan Anda tidak memerlukan DI untuk itu.



Selain itu, sejak versi 2.2, Spring Boot mendukung inisialisasi kacang lambat, yang secara signifikan mempercepat pengujian menggunakan DI.



Kode Anda harus dapat diuji



Jangan gunakan akses statis. Tidak pernah



Akses statis adalah anti-pola. Pertama, ini mengaburkan dependensi dan efek samping, membuat seluruh kode sulit dibaca dan rentan terhadap kesalahan kecil. Kedua, akses statis menghalangi pengujian. Anda tidak bisa lagi mengganti objek, tetapi dalam pengujian Anda perlu menggunakan tiruan atau objek nyata dengan konfigurasi yang berbeda (misalnya, objek DAO yang menunjuk ke database pengujian).



Alih-alih mengakses kode secara statis, letakkan di metode non-statis, buat instance kelas, dan teruskan objek yang dihasilkan ke konstruktor.



// 
public class ProductController {
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = ProductDAO.getProducts();
        return mapToDTOs(products);
    }
}



// 
public class ProductController {
    private ProductDAO dao;
    public ProductController(ProductDAO dao) {
        this.dao = dao;
    }
    public List<ProductDTO> getProducts() {
        List<ProductEntity> products = dao.getProducts();
        return mapToDTOs(products);
    }
}


Untungnya, kerangka kerja DI seperti Spring menyediakan alat yang membuat akses statis tidak diperlukan dengan membuat dan menautkan objek secara otomatis tanpa keterlibatan kita.



Parameterisasi



Semua bagian kelas yang relevan harus dapat dikonfigurasi dari sisi pengujian. Pengaturan tersebut dapat diteruskan ke konstruktor kelas.



Bayangkan, misalnya, DAO Anda memiliki batas tetap 1000 objek per permintaan. Untuk memeriksa batas ini, Anda perlu menambahkan 1001 objek ke database pengujian sebelum pengujian. Dengan menggunakan argumen konstruktor, Anda dapat membuat nilai ini dapat disesuaikan: dalam produksi, biarkan 1000, dalam pengujian, kurangi menjadi 2. Jadi, untuk memeriksa pekerjaan batas, Anda hanya perlu menambahkan hanya 3 catatan ke database pengujian.



Gunakan injeksi konstruktor



Injeksi lapangan jahat dan mengarah pada kemampuan pengujian kode yang buruk. Anda perlu menginisialisasi DI sebelum tes atau melakukan sihir refleksi aneh. Oleh karena itu, lebih disukai menggunakan injeksi konstruktor untuk dengan mudah mengontrol objek dependen selama pengujian.



Di Java, Anda harus menulis sedikit kode tambahan:



// 
public class ProductController {

    private ProductDAO dao;
    private TaxClient client;

    public ProductController(ProductDAO dao, TaxClient client) {
        this.dao = dao;
        this.client = client;
    }
}


Di Kotlin, hal yang sama ditulis lebih ringkas:



// 
class ProductController(
    private val dao: ProductDAO,
    private val client: TaxClient
){
}


Jangan gunakan Instant.now() ataunew Date()



Anda tidak perlu mendapatkan waktu saat ini dengan panggilan Instant.now()atau new Date()dalam kode produksi jika Anda ingin menguji perilaku ini.



// 
public class ProductDAO {
    public void updateDateModified(String productId) {
        Instant now = Instant.now(); // !
        Update update = Update()
            .set("dateModified", now);
        Query query = Query()
            .addCriteria(where("_id").eq(productId));
        return mongoTemplate.updateOne(query, update, ProductEntity.class);
    }
}


Masalahnya adalah waktu yang dibutuhkan tidak dapat dikontrol oleh ujian. Anda tidak akan dapat membandingkan hasil yang diperoleh dengan nilai tertentu, karena selalu berbeda. Gunakan kelas Clockdari Java sebagai gantinya .



// 
public class ProductDAO {
    private Clock clock; 

    public ProductDAO(Clock clock) {
        this.clock = clock;
    }

    public void updateProductState(String productId, State state) {
        Instant now = clock.instant();
        // ...
    }
}


Dalam pengujian ini, Anda dapat membuat objek tiruan untuk Clock, meneruskannya ke, ProductDAOdan mengonfigurasi objek tiruan untuk mengembalikan waktu yang sama. Setelah panggilan selesai, updateProductState()kami akan dapat memeriksa bahwa nilai yang kami tentukan telah masuk ke dalam database.



Pisahkan eksekusi asynchronous dari logika sebenarnya



Menguji kode asinkron itu rumit. Library seperti Awaitility sangat membantu, tetapi prosesnya masih berbelit-belit dan kami mungkin akan berakhir dengan tes yang berkedip. Masuk akal untuk memisahkan logika bisnis (biasanya sinkron) dan kode infrastruktur asinkron, jika memungkinkan.



Misalnya, dengan membawa logika bisnis ke dalam ProductController, kita dapat dengan mudah mengujinya secara sinkron. Semua logika asinkron dan paralel akan tetap ada di ProductScheduler, yang dapat diuji secara terpisah.



// 
public class ProductScheduler {

    private ProductController controller;

    @Scheduled
    public void start() {
        CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
        CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
        String usResult = usFuture.get();
        String germanyResult = germanyFuture.get();
    }
}


Kotlin



Artikel saya Praktik terbaik untuk pengujian unit di Kotlin berisi banyak teknik pengujian unit khusus Kotlin. (Catatan terjemahan: tulis di komentar jika Anda tertarik dengan terjemahan bahasa Rusia dari artikel ini).



All Articles