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: