Unit Test #
Bug yang ditemukan saat production bisa menelan biaya ratusan kali lebih mahal dari bug yang ditemukan saat development. Unit test adalah jaring pengaman pertama: setiap metode diuji secara terisolasi, setiap edge case dieksplisitkan dalam kode, dan setiap perubahan di masa depan langsung terdeteksi jika merusak perilaku yang ada. Di Java, JUnit 5 adalah standar de facto untuk unit testing — ekspresif, fleksibel, dan terintegrasi dengan baik ke semua build tool dan IDE. Artikel ini membahas cara menulis test yang berarti dengan JUnit 5 dari assertions dasar hingga parameterized test, cara mengorganisasi test dengan lifecycle hooks, cara menguji exception, dan praktik terbaik yang membuat test suite kamu mudah dirawat.
Gambaran Umum #
Unit test yang baik punya tiga karakteristik: cepat (berjalan dalam milidetik), terisolasi (tidak bergantung pada database, jaringan, atau state global), dan deterministik (selalu menghasilkan hasil yang sama untuk input yang sama).
flowchart LR
A["Kode Produksi\n(src/main/java)"] -->|"diuji oleh"| B["Test\n(src/test/java)"]
B -->|"mvn test /"| C["Test Runner\n(JUnit Platform)"]
C --> D{"Semua\nlulus?"}
D -- Ya --> E["✓ Build berhasil"]
D -- Tidak --> F["✗ Build gagal\n+ laporan error"]Pola paling umum dalam menulis test adalah AAA (Arrange, Act, Assert):
@Test
void contohPolaAAA() {
// Arrange — siapkan data dan objek yang dibutuhkan
Kalkulator kalkulator = new Kalkulator();
int a = 5, b = 3;
// Act — jalankan kode yang diuji
int hasil = kalkulator.tambah(a, b);
// Assert — verifikasi hasilnya sesuai ekspektasi
assertEquals(8, hasil);
}
JUnit 5 #
JUnit 5 (nama resmi: JUnit Jupiter) adalah versi terbaru yang membawa banyak peningkatan dari JUnit 4 — anotasi yang lebih ekspresif, parameterized test bawaan, ekstensi yang lebih fleksibel, dan dukungan Java 8+ features.
Dependensi #
<!-- Maven — satu dependensi sudah mencakup API + Engine -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- Plugin Surefire untuk menjalankan test via mvn test -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
// Gradle
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}
test {
useJUnitPlatform()
}
Test Pertama — Kalkulator #
Mulai dari kelas yang akan diuji:
// src/main/java/com/example/Kalkulator.java
public class Kalkulator {
public int tambah(int a, int b) { return a + b; }
public int kurang(int a, int b) { return a - b; }
public int kali(int a, int b) { return a * b; }
public double bagi(double a, double b) {
if (b == 0) throw new ArithmeticException("Pembagi tidak boleh nol");
return a / b;
}
public boolean genap(int n) { return n % 2 == 0; }
}
// src/test/java/com/example/KalkulatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class KalkulatorTest {
private Kalkulator kalkulator;
@BeforeEach
void setUp() {
kalkulator = new Kalkulator(); // buat objek segar sebelum setiap test
}
@Test
@DisplayName("tambah dua angka positif")
void tambahDuaAngkaPositif() {
assertEquals(8, kalkulator.tambah(5, 3));
}
@Test
@DisplayName("tambah angka negatif")
void tambahAngkaNegatif() {
assertEquals(-2, kalkulator.tambah(-5, 3));
}
@Test
@DisplayName("kurang menghasilkan negatif jika b > a")
void kurangMenghasilkanNegatif() {
assertEquals(-1, kalkulator.kurang(2, 3));
}
@Test
@DisplayName("bagi normal")
void bagiNormal() {
assertEquals(2.5, kalkulator.bagi(5, 2), 0.001); // toleransi floating point
}
}
Assertions Lengkap #
JUnit 5 menyediakan banyak metode assertion di kelas Assertions. Pilih yang paling deskriptif untuk kasus yang diuji.
import static org.junit.jupiter.api.Assertions.*;
// Kesetaraan
assertEquals(42, hasil);
assertEquals(3.14, nilai, 0.001); // toleransi untuk double
assertNotEquals(0, hasil);
// Null
assertNull(objek);
assertNotNull(objek);
// Boolean
assertTrue(kalkulator.genap(4));
assertFalse(kalkulator.genap(7));
// Referensi (apakah objek yang sama persis, bukan hanya equal)
assertSame(objekA, objekB);
assertNotSame(objekA, salinan);
// Array dan koleksi
assertArrayEquals(new int[]{1, 2, 3}, hasil);
assertIterableEquals(List.of("a", "b"), daftar);
// Beberapa assertions sekaligus — semua dijalankan meski ada yang gagal
assertAll("properti produk",
() -> assertEquals("Laptop", produk.getNama()),
() -> assertEquals(12_000_000.0, produk.getHarga(), 0.01),
() -> assertTrue(produk.getStok() > 0)
);
// Pesan error kustom — muncul saat assertion gagal
assertEquals(5, hasil, "Hasil tambah 2+3 seharusnya 5");
assertEquals(5, hasil, () -> "Hasil adalah " + hasil + ", seharusnya 5");
// Gunakan lambda agar pesan hanya dihitung saat gagal (lazy)
Menguji Exception #
@Test
@DisplayName("bagi dengan nol harus lempar ArithmeticException")
void bagiDenganNolLemparException() {
// assertThrows: pastikan exception yang benar dilempar
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> kalkulator.bagi(10, 0)
);
// Verifikasi pesan exception
assertEquals("Pembagi tidak boleh nol", ex.getMessage());
}
@Test
@DisplayName("bagi normal TIDAK boleh lempar exception")
void bagiNormalTidakLemparException() {
// assertDoesNotThrow: pastikan tidak ada exception yang dilempar
assertDoesNotThrow(() -> kalkulator.bagi(10, 2));
}
@Test
@DisplayName("exception dilempar dalam batas waktu")
void exceptionDalamTimeout() {
assertTimeoutPreemptively(
java.time.Duration.ofSeconds(1),
() -> kalkulator.bagi(0, 0) // pastikan cepat, tidak infinite loop
);
}
Lifecycle Hooks #
Lifecycle hooks memungkinkan kamu menyiapkan dan membersihkan state sebelum/sesudah test — tanpa duplikasi kode di setiap metode test.
import org.junit.jupiter.api.*;
class DatabaseTest {
private static KoneksiDB koneksi; // dibuat sekali untuk semua test
private Transaksi transaksi; // dibuat baru setiap test
@BeforeAll // static: dipanggil sekali sebelum semua test di kelas ini
static void bukaKoneksi() {
koneksi = new KoneksiDB("jdbc:h2:mem:testdb");
System.out.println("Koneksi DB dibuka");
}
@BeforeEach // dipanggil sebelum setiap metode @Test
void mulaiTransaksi() {
transaksi = koneksi.mulaiTransaksi();
System.out.println("Transaksi dimulai");
}
@Test
void simpanDataBaru() {
// test ini punya transaksi segar
transaksi.simpan(new Produk("Laptop", 12_000_000));
assertEquals(1, transaksi.hitungProduk());
}
@Test
void hapusData() {
transaksi.simpan(new Produk("Mouse", 150_000));
transaksi.hapus("Mouse");
assertEquals(0, transaksi.hitungProduk());
}
@AfterEach // dipanggil setelah setiap metode @Test
void rollback() {
transaksi.rollback(); // pastikan setiap test dimulai dengan state bersih
System.out.println("Rollback dilakukan");
}
@AfterAll // static: dipanggil sekali setelah semua test selesai
static void tutupKoneksi() {
koneksi.tutup();
System.out.println("Koneksi DB ditutup");
}
}
Urutan eksekusi untuk dua test:
@BeforeAll (sekali)
@BeforeEach → @Test simpanDataBaru → @AfterEach
@BeforeEach → @Test hapusData → @AfterEach
@AfterAll (sekali)
Anotasi Utilitas #
// Skip test dengan alasan
@Test
@Disabled("Fitur belum diimplementasikan — lihat tiket #42")
void fiturBelumAda() { /* ... */ }
// Kondisional — jalankan hanya di OS tertentu
@Test
@EnabledOnOs(OS.LINUX)
void khususLinux() { /* ... */ }
@Test
@DisabledOnOs(OS.WINDOWS)
void tidakDiWindows() { /* ... */ }
// Kondisional berdasarkan versi Java
@Test
@EnabledForJreRange(min = JRE.JAVA_17)
void fiturJava17() { /* ... */ }
// Tandai sebagai test yang diharapkan gagal (untuk dokumentasi regresi)
@Test
@Disabled("Bug diketahui — menunggu fix di sprint berikutnya")
void bugYangDiketahui() {
fail("Ini harus gagal sampai bug diperbaiki");
}
// Ulangi test beberapa kali (untuk test non-deterministik)
@RepeatedTest(5)
void testYangDiulang(RepetitionInfo info) {
System.out.println("Percobaan " + info.getCurrentRepetition());
assertTrue(Math.random() >= 0); // selalu true
}
Parameterized Test #
Sering kali sebuah metode perlu diuji dengan banyak input berbeda. Alih-alih menulis test terpisah untuk setiap kasus, gunakan @ParameterizedTest untuk menjalankan satu test dengan banyak set data.
@ValueSource — Input Tunggal Sederhana #
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ParameterizedTest(name = "{0} adalah genap")
@ValueSource(ints = {2, 4, 6, 100, -8})
void angkaGenap(int angka) {
assertTrue(kalkulator.genap(angka));
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void stringKosongAtauSpasi(String input) {
assertTrue(input.isBlank());
}
@CsvSource — Beberapa Parameter per Kasus #
import org.junit.jupiter.params.provider.CsvSource;
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"2, 3, 5",
"0, 0, 0",
"-5, 5, 0",
"100, -50, 50",
"Integer.MAX_VALUE, 0, 2147483647" // ini hanya string, tidak dievaluasi
})
void tambahBerbagaiKasus(int a, int b, int ekspektasi) {
assertEquals(ekspektasi, kalkulator.tambah(a, b));
}
@MethodSource — Data dari Metode #
Untuk data kompleks (objek, null, daftar), gunakan @MethodSource yang merujuk ke metode factory.
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
@ParameterizedTest
@MethodSource("sumberDataBagi")
void bagiDenganBerbagaiInput(double pembilang, double penyebut, double ekspektasi) {
assertEquals(ekspektasi, kalkulator.bagi(pembilang, penyebut), 0.001);
}
// Metode factory — harus static, return Stream<Arguments>
static Stream<Arguments> sumberDataBagi() {
return Stream.of(
Arguments.of(10.0, 2.0, 5.0),
Arguments.of(9.0, 3.0, 3.0),
Arguments.of(7.0, 2.0, 3.5),
Arguments.of(0.0, 5.0, 0.0),
Arguments.of(-10.0, 2.0, -5.0)
);
}
// @NullSource dan @EmptySource untuk kasus null/kosong
@ParameterizedTest
@NullSource
@EmptySource
@ValueSource(strings = {" ", "\t"})
void stringTidakValid(String input) {
assertTrue(input == null || input.isBlank());
}
AssertJ — Assertions yang Lebih Ekspresif #
AssertJ adalah library assertions alternatif yang bisa dipakai bersama JUnit 5. Sintaksnya lebih fluent (berantai) dan pesan error otomatisnya lebih informatif dibanding Assertions bawaan JUnit.
Dependensi #
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
Perbandingan JUnit vs AssertJ #
// JUnit 5 bawaan
assertEquals("Laptop", produk.getNama());
assertTrue(produk.getHarga() > 0);
assertNotNull(produk.getId());
assertTrue(daftar.contains("Apel"));
// AssertJ — lebih mudah dibaca, pesan error lebih informatif
import static org.assertj.core.api.Assertions.*;
assertThat(produk.getNama()).isEqualTo("Laptop");
assertThat(produk.getHarga()).isPositive();
assertThat(produk.getId()).isNotNull();
assertThat(daftar).contains("Apel");
// AssertJ bersinar untuk koleksi
assertThat(daftar)
.hasSize(3)
.contains("Apel", "Mangga")
.doesNotContain("Durian")
.isSortedAccordingTo(String::compareTo);
// String assertions
assertThat(nama)
.isNotBlank()
.startsWith("Bu")
.endsWith("di")
.hasSize(4);
// Exception assertions
assertThatThrownBy(() -> kalkulator.bagi(1, 0))
.isInstanceOf(ArithmeticException.class)
.hasMessage("Pembagi tidak boleh nol");
// Object assertions
assertThat(produk)
.extracting(Produk::getNama, Produk::getHarga)
.containsExactly("Laptop", 12_000_000.0);
TestNG #
TestNG adalah alternatif JUnit dengan fitur tambahan seperti testing paralel, dependensi antar test, dan konfigurasi via XML. Lebih sering dipakai di tim yang butuh kontrol lebih atas eksekusi test.
Dependensi #
<!-- Maven -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.9.0</version>
<scope>test</scope>
</dependency>
Menulis Test dengan TestNG #
import org.testng.Assert;
import org.testng.annotations.*;
public class KalkulatorTestNG {
private Kalkulator kalkulator;
@BeforeClass // dipanggil sekali sebelum semua test di kelas ini
public void setUp() {
kalkulator = new Kalkulator();
}
@Test
public void tambahPositif() {
Assert.assertEquals(kalkulator.tambah(2, 3), 5, "2 + 3 harus 5");
}
@Test
public void kurangMenghasilkanNegatif() {
Assert.assertEquals(kalkulator.kurang(2, 3), -1);
}
// Test dengan ekspektasi exception
@Test(expectedExceptions = ArithmeticException.class,
expectedExceptionsMessageRegExp = ".*nol.*")
public void bagiDenganNol() {
kalkulator.bagi(10, 0);
}
// Test dengan timeout (dalam milidetik)
@Test(timeOut = 1000)
public void harusCepatSelesai() {
kalkulator.tambah(1, 1);
}
// Test yang dijalankan setelah test lain selesai
@Test(dependsOnMethods = "tambahPositif")
public void testBergantung() {
Assert.assertTrue(kalkulator.genap(kalkulator.tambah(2, 2)));
}
}
Parameterized Test di TestNG #
import org.testng.annotations.*;
public class KalkulatorParamTestNG {
private Kalkulator kalkulator = new Kalkulator();
// Data provider: sumber data untuk parameterized test
@DataProvider(name = "dataTambah")
public Object[][] sumberData() {
return new Object[][] {
{2, 3, 5},
{0, 0, 0},
{-5, 5, 0},
{100, -50, 50}
};
}
@Test(dataProvider = "dataTambah")
public void tambahDenganBerbagaiInput(int a, int b, int ekspektasi) {
Assert.assertEquals(kalkulator.tambah(a, b), ekspektasi);
}
}
Test Paralel dengan TestNG #
<!-- testng.xml: konfigurasi eksekusi paralel -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="Suite Paralel" parallel="methods" thread-count="4">
<test name="Test Kalkulator">
<classes>
<class name="com.example.KalkulatorTestNG"/>
</classes>
</test>
</suite>
Praktik Terbaik #
Penamaan Test yang Deskriptif #
// ANTI-PATTERN: nama tidak menjelaskan apa yang diuji
@Test
void test1() { /* ... */ }
@Test
void testBagi() { /* ... */ }
// BENAR: nama menjelaskan skenario dan ekspektasi
@Test
@DisplayName("bagi 10 dengan 2 menghasilkan 5.0")
void bagiDuaAngkaPositif_menghasilkanHasilYangBenar() { /* ... */ }
@Test
@DisplayName("bagi dengan nol harus lempar ArithmeticException")
void bagiDenganNol_lemparArithmeticException() { /* ... */ }
Satu Test, Satu Konsep #
// ANTI-PATTERN: satu test memverifikasi terlalu banyak hal
@Test
void testKalkulator() {
assertEquals(5, kalkulator.tambah(2, 3));
assertEquals(1, kalkulator.kurang(3, 2));
assertEquals(6, kalkulator.kali(2, 3));
assertThrows(ArithmeticException.class, () -> kalkulator.bagi(1, 0));
// Jika tambah gagal, kita tidak tahu apakah kurang, kali, bagi benar
}
// BENAR: satu test, satu skenario
@Test void tambahDuaAngkaPositif() { assertEquals(5, kalkulator.tambah(2, 3)); }
@Test void kurangMenghasilkanSatu() { assertEquals(1, kalkulator.kurang(3, 2)); }
@Test void kaliMenghasilkanEnam() { assertEquals(6, kalkulator.kali(2, 3)); }
@Test void bagiDenganNolLemparError() { assertThrows(...); }
Kelompokkan Test dengan @Nested #
import org.junit.jupiter.api.Nested;
class KalkulatorTest {
private Kalkulator kalkulator = new Kalkulator();
@Nested
@DisplayName("Operasi Tambah")
class TambahTest {
@Test void duaPositif() { assertEquals(8, kalkulator.tambah(5, 3)); }
@Test void duaNegatif() { assertEquals(-8, kalkulator.tambah(-5, -3)); }
@Test void positifNegatif(){ assertEquals(2, kalkulator.tambah(5, -3)); }
}
@Nested
@DisplayName("Operasi Bagi")
class BagiTest {
@Test void normal() { assertEquals(2.5, kalkulator.bagi(5, 2), 0.001); }
@Test void denganNol() { assertThrows(ArithmeticException.class,
() -> kalkulator.bagi(10, 0)); }
@Test void hasilNegatif() { assertEquals(-5.0, kalkulator.bagi(-10, 2), 0.001); }
}
}
Struktur Direktori Test #
src/
├── main/java/com/example/
│ ├── Kalkulator.java
│ ├── service/
│ │ └── ProdukService.java
│ └── repository/
│ └── ProdukRepository.java
└── test/java/com/example/
├── KalkulatorTest.java ← test untuk Kalkulator
├── service/
│ └── ProdukServiceTest.java ← test untuk ProdukService
└── repository/
└── ProdukRepositoryTest.java
Menjalankan Test #
# Maven — jalankan semua test
mvn test
# Maven — jalankan satu kelas test
mvn test -Dtest=KalkulatorTest
# Maven — jalankan satu metode test
mvn test -Dtest=KalkulatorTest#tambahDuaAngkaPositif
# Maven — jalankan test dengan pattern nama
mvn test -Dtest="*Kalkulator*"
# Maven — lewati test (untuk build cepat, tidak direkomendasikan)
mvn package -DskipTests
# Gradle — jalankan semua test
./gradlew test
# Gradle — jalankan test tertentu
./gradlew test --tests "com.example.KalkulatorTest"
./gradlew test --tests "com.example.KalkulatorTest.tambahDuaAngkaPositif"
# Lihat laporan test di browser
# Maven: target/surefire-reports/index.html
# Gradle: build/reports/tests/test/index.html
Kapan Menggunakan JUnit vs TestNG #
Gunakan JUNIT 5 jika:
✓ Proyek baru — ini standar de facto saat ini
✓ Butuh integrasi mulus dengan Spring Boot (@SpringBootTest)
✓ Tim familiar dengan JUnit (ekosistem lebih luas)
✓ Butuh ekstensi yang fleksibel dengan @ExtendWith
Gunakan TESTNG jika:
✓ Butuh testing paralel dengan kontrol granular via XML
✓ Dependensi antar test dengan @Test(dependsOnMethods)
✓ Tim sudah pakai TestNG di proyek yang ada
✓ Butuh data provider yang lebih kaya dengan @DataProvider
Gunakan ASSERTJ bersama JUnit 5 jika:
✓ Ingin pesan error yang lebih informatif saat assertion gagal
✓ Banyak test koleksi, string, atau objek kompleks
✓ Suka gaya fluent chaining yang mudah dibaca
Ringkasan #
- Ikuti pola AAA — setiap test punya tiga bagian jelas: Arrange (siapkan), Act (jalankan), Assert (verifikasi). Pisahkan dengan baris kosong atau komentar.
@BeforeEachuntuk inisialisasi — buat objek yang diuji baru sebelum setiap test agar tidak ada state yang bocor antar test.assertAll()untuk memverifikasi beberapa properti — semua assertions dijalankan bahkan jika ada yang gagal, sehingga kamu mendapat gambaran lengkap tanpa harus lari berulang kali.assertThrows()untuk menguji exception — tangkap objek exception yang dilempar dan verifikasi tipe serta pesannya secara eksplisit.@ParameterizedTestmenghilangkan duplikasi — satu test dengan banyak input jauh lebih baik dari banyak test yang hampir identik. Gunakan@CsvSourceuntuk data sederhana,@MethodSourceuntuk data kompleks.@DisplayNamemembuat laporan lebih mudah dibaca — nama metode bisa tetap pendek dalam kode, sementara nama yang ditampilkan di laporan bisa panjang dan deskriptif.@Nesteduntuk mengelompokkan skenario — kelas nested membuat struktur test lebih jelas, terutama saat satu kelas punya banyak operasi berbeda.- Satu test, satu konsep — test yang gagal harus langsung memberi tahu apa yang salah, bukan memaksamu debug dua puluh assertions sekaligus.
- AssertJ sebagai pelengkap JUnit — tidak menggantikan JUnit, tapi membuat assertions lebih ekspresif, terutama untuk koleksi, string, dan exception.