Unit Test

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>
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.
  • @BeforeEach untuk 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.
  • @ParameterizedTest menghilangkan duplikasi — satu test dengan banyak input jauh lebih baik dari banyak test yang hampir identik. Gunakan @CsvSource untuk data sederhana, @MethodSource untuk data kompleks.
  • @DisplayName membuat laporan lebih mudah dibaca — nama metode bisa tetap pendek dalam kode, sementara nama yang ditampilkan di laporan bisa panjang dan deskriptif.
  • @Nested untuk 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.

← Sebelumnya: Web Server   Berikutnya: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact