/blog/*

Java Unit Test

Ketika membangun suatu aplikasi pasti kita akan melakukan proses test untuk mencegah error dan memastikan aplikasi yang kita buat berjalan susuai dengan ekspektasi kita. Ada beberapa level atau tahapan test, tapi pada tulisan ini khusus membahas unit test saja.

Unit test merupakan tahapan test untuk menguji komponen terkecil dari suatu aplikasi seperti module, function, atau suatu object. Tujuan dari unit test adalah untuk memastikan setiap unit code berjalan sesuai ekspektasi. Proses unit test dilakukan pada fase development(coding phase) dan dijalankan oleh developer itu sendiri(white-box testing). Karena unit test digunakan untuk menguji unit-unit terkecil dari suatu aplikasi, maka tidak heran jika source code dari unit test lebih banyak dari source code aplikasi itu sendiri. Oleh karena itu di Java, code unit test dipisah pada direktori khusus /test.

JUnit

Untuk implementasi unit test pada Java, kita membutuhkan library tambahan karena Java sendiri tidak menyediakan. Saat ini framework test yang paling populer di Java adalah JUnit. Saat tulisan ini dibuat versi terbaru dari JUnit adalah versi 5 atau nama artifact-id nya junit-jupiter. JUnit ini membutuhkan Java minimal versi 8.

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.7.1</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Dalam membuat suatu unit test, pada umumnya class unit test memiliki nama yang sama dengan class yang akan ditest ditambah dengan akhiran *Test. Sebagai contoh jika kita ingin membuat unit test dari class Calculator maka nama class unit test nya adalah CalculatorTest. Setiap unit yang ingin ditest ditandai dengan annotation @Test, dan jangan lupa semua method unit test harus bertipe void.

@Test
void testAddSuccess(){
    var result = calculator.add(5, 10);
}

Jika code diatas dijalankan sukses dan tidak menghasilkan error/exception maka unit test tersebut lulus karena sesuai dengan ekspektasi kita.

Assertions

Pada unit test, assertions digunakan untuk menvalidasi suatu unit code berjalan dan menghasilkan output sesuai dengan ekspektasi kita. Pada JUnit assertions di representasikan pada class Assertions yang memiliki banyak sekali static function.

@Test
void testAddSuccess(){
    var result = calculator.add(5, 10);
    assertEquals(15, result);   // expected value is 15
}

Pada kode test diatas, dengan menggunakan assertEquals() kita bisa mendeklarasikan ekspektasi value dari hasil suatu fungsi yang dijalankan. Sebagai contoh jika fungsi add(5, 10) dijalankan, maka return value yang diharapkan muncul adalah 15, jika tidak sesuai maka test gagal.

Selain mengharapkan hasil output sukses, dalam membuat suatu aplikasi kita juga harus memprediksi kemungkinan terjadinya error/exception. Artinya kita harus menguji suatu fungsi dengan ekspektasi jika dijalankan terjadi error. Untuk menguji kasus tersebut kita bisa menggunakan assertThrows().

// Calculator.java

public int divide(int val1, int val2){
    if(val2 == 0){
        throw new IllegalArgumentException("Cannot divide by zero");
    }

    return val1 / val2;
}
//CalculatorTest.java

@Test
void testDivideFailed(){
    assertThrows(IllegalArgumentException.class, () -> {
        calculator.divide(10, 0);
    });
}

Pada kode program diatas unit test testDivideFailed() untuk menguji fungsi divide(10, 0) menghasilkan suatu error/exception. Test dikatakan lulus/berhasil jika fungsi tersebut menghasilkan error, justru jika fungsi tersebut sukses tanpa ada error, test tersebut gagal.

Aborted Test

Pada kondisi tertentu kita ingin membatalkan(aborted) suatu unit test. Cara cepat adalah dengan melempar exception TestAbortedException.

@Test
void testAborted(){
    var profile = System.getenv("PROFILE");
    if(!"DEV".equals(profile)){
        throw new TestAbortedException("Aborted. Test only run on DEV environment.");
    }
}

Ada juga cara yang lebih mudah jika kita ingin membatalkan suatu unit test berdasarkan kondisi tertentu yaitu dengan menggunakan Assumptions. Penggunaan Assumptions mirip dengan Assertions, bedanya jika Assumtions nilainya tidak sama maka akan melempar exception TestAbortedException dan membatalkan unit test tersebut.

@Test
void testAssumptions(){
    assumeTrue("DEV".equals(System.getenv("PROFILE")));

    // test here
}

JUnit juga sudah menyediakan berbagai annotation siap pakai untuk menjalankan test berdasarkan suatu kondisi, seperti kondisi system operasi, versi JRE, system property, dll. Sebagai contoh jika kita ingin menjalankan test berdasarkan kondisi environment variable tertentu berikut contohnya:

@Test
@EnabledIfEnvironmentVariable(named = "PROFILE", matches = "DEV")
void testEnabledOnProfileDev(){
    // test enabled when PROFILE is DEV
}

Before/After

JUnit mendukung fitur dimana kita bisa mendefinisikan suatu fungsi yang akan dieksekusi sebelum maupun sesudah unit test dijalankan. JUnit memiliki annotation @BeforeAll dan @AfterAll yang akan dieksekusi sekali sebelum/sesudah class unit test dijalankan. Yang perlu diingat untuk function yang ditandai dengan annotation tersebut harus bertipe static. Sedangkan annotation @BeforeEach dan @AfterEach akan dieksekusi sebelum/sesudah pada setiap method unit test yang akan dijalankan.

@BeforeAll
static void setup(){
    System.out.println("@BeforeAll - executes once before all test methods in this class.");
}

@BeforeEach
void init(){
    System.out.println("@BeforeEach - executes before each test method in this class.");
}

@AfterEach
void tearDown(){
    System.out.println("@AfterEach - executed after each test method.");
}

@AfterAll
static void done(){
    System.out.println("@AfterAll - executed after all test methods.");
}

// unit test here

Filtering Test

Class atau suatu function unit test bisa kita tambahkan annotation @Tag. Tujuannya adalah untuk menandai suatu unit test tertentu agar bisa di seleksi unit test mana yang ingin dijalankan. Dengan menambahkan annotation @Tag kita bisa memilih unit test mana yang ingin di include atau di exclude. Jika kita menggunakan annotation @Tag di level class maka otomatis semua function unit test yang ada didalam class tersebut memiliki tag yang sama.

@Tag("integration-test")
public class IntegrationTest {

    // unit test here

}
@Test
@Tag("integration-test")
void integrationTest(){

}

Untuk menjalankan unit test berdasarkan tag tertentu dengan Maven, berikut perintahnya:

# include
mvn test -Dgroups=tag1,tag2

# exclude
mvn test -DexcludedGroups=tag1,tag2

Atau bisa juga dengan menggunakan Maven plugin maven-surefire-plugin untuk menentukan unit test mana yang ingin dijalankan berdasarkan tag tertentu. Berikut konfigurasinya pada pom.xml:

<plugins>
    <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.22.1</version>
        <configuration>
            <groups>integration-test</groups>
        </configuration>
    </plugin>
</plugins>

Jika tag yang ingin di include/exclude sudah di deklarasikan pada plugin maven-surefire-plugin, untuk menjalankan testnya cukup dengan perintah mvn test saja.

Lifecycle Test

Ketika suatu class test dijalankan, yang dilakukan JUnit di background adalah dengan membuat object test baru setiap kali menjalankan method unit test. Akibatnya jika kita memerlukan variabel global yang value nya ditentukan dari unit test sebelumnya, maka unit test berikutnya tidak akan mendapatkan value tersebut karena object class test-nya dibuat ulang.

Secara default object test memang dibuat independent per unit test, artinya tiap unit test tidak bergantung dengan unit test lainnya. Namun kita bisa merubah lifecycle object test ini dengan menggunakan annotation @TestInstance. Setiap class test memiliki default TestInstance.Lifecycle.PER_METHOD, kita bisa menggantinya dengan menambahkan annotation TestInstance.Lifecycle.PER_CLASS. Dengan demikian object test hanya akan dibuat sekali per class, dan semua method unit test akan menggunakan object yang sama tersebut.

@TestInstance(TestInstance.Lifecycle.PER_METHOD)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class LifecycleTest {

    private int counter;

    @BeforeAll
    void setup(){
        counter = 1;
    }

    @AfterAll
    void done(){
        System.out.println("last counter: " + counter);
    }

    @Test
    @Order(1)
    void test2(){
        counter++;
    }

    @Test
    @Order(2)
    void test1(){
        counter++;
    }

    @Test
    @Order(3)
    void test3(){
        counter++;
    }

}

Dengan menggunakan lifecycle TestInstance.Lifecycle.PER_CLASS kita bisa menggunakan annotation @BeforeAll dan @AfterAll tanpa perlu set tipe static.

Parameterized Test

Kita bisa emnambahkan suatu parameter pada function unit test. Ada beberapa cara seperti dengan menggunakan class ParameterResolver atau bisa juga menggunakan annotation @ParameterizedTest. Berikut contoh sederhananya:

@DisplayName("Test ValueSource")
@ParameterizedTest(name = "{displayName} with parameter {0}")
@ValueSource(ints = {1, 2, 3, 5, 10, 18, 23, 0})
void testWithValueSource(int value){
    var result = calculator.add(value, value);
    var expected = value + value;

    Assertions.assertEquals(expected, result);
}

Dengan menggunakan @ParameterizedTest kita bisa menambahkan parameter pada method unit test. Untuk sumber datanya bisa dideklarasikan langsung dengan annotation @ValueSource. Atau juga datanya cukup banyak dan ingin dipisah, bisa menggunakan annotation @MethodSource yang dimana kita deklarasikan datanya pada suatu method dengan tipe static. Berikut contohnya:

@DisplayName("Test MethodSource")
@ParameterizedTest(name = "{displayName} with parameter {0}")
@MethodSource({"parameterSource"})
void testWithMethodSource(int value){
    var result = calculator.add(value, value);
    var expected = value + value;

    Assertions.assertEquals(expected, result);
}

static List<Integer> parameterSource(){
    return List.of(1, 2, 4, 5, 10);
}

Concurrent Test

Jika aplikasi semakin besar maka semakin banyak pula unit test yang akan dijalankan, hal ini akan memakan waktu. JUnit mendukung fitur concurrent, dimana kita bisa menjalankan unit test secara parallel. Namun hal yang perlu di perhatikan jika menggunakan fitur ini adalah pastikan semua unit test independent atau tidak bergantung pada unit test lainnya. Menggunakan fitur ini cukup dengan menambahkan annotation @Execution(ExecutionMode.CONCURRENT) pada class test.

@Execution(ExecutionMode.CONCURRENT)
public class SlowTest {

    @Test
    @Timeout(value = 5, unit = TimeUnit.SECONDS)
    public void testSlow1() throws InterruptedException {
        Thread.sleep(4_000);
    }

    @Test
    @Timeout(value = 5, unit = TimeUnit.SECONDS)
    public void testSlow2() throws InterruptedException {
        Thread.sleep(4_000);
    }

    @Test
    @Timeout(value = 5, unit = TimeUnit.SECONDS)
    public void testSlow3() throws InterruptedException {
        Thread.sleep(4_000);
    }

}

Fitur ini harus diaktifkan terlebih dahulu, caranya dengan membuat file properties junit-platform.properties pada direktori /test/resources. Berikut isinya:

junit.jupiter.execution.parallel.enabled=true

Kita sudah membahas berbagai fitur-fitur dasar JUnit yang bisa digunakan untuk membuat unit test. Selanjutnya untuk fitur-fitur lain seperti mocking akan dibahas di tulisan lain. Semua kode program lengkap pada tulisan ini dapat diakses disini.

Ref: