Ketika test berbagi objek yang sama berulang kali — pengguna default, pesanan dengan beberapa item, koneksi database — kode setup menumpuk di setiap method. Test fixtures menyederhanakan hal ini dengan menyediakan setup bersama yang dapat dipakai ulang. Chapter ini memperkenalkan tiga pola: @BeforeEach untuk instance setup yang dipakai setiap test, fixture function untuk membuat objek dengan nilai default yang dapat di-override, dan test data builder untuk menyusun objek kompleks langkah demi langkah.

Tiga Pola Fixture #

PolaBentukCocok untuk
@BeforeEachMethod yang dijalankan runner sebelum tiap testSetup instance umum (kalkulator, koneksi, fixture)
Fixture functionFungsi dengan parameter defaultMembuat objek dengan nilai default yang di-override
Test data builderClass dengan method fluentMenyusun objek kompleks dengan banyak field opsional

Pilihan pola tergantung kompleksitas objek yang dibuat. Untuk objek datar dengan 2-3 field, fixture function cukup. Untuk objek dengan banyak field opsional yang sering dikombinasikan berbeda tiap test, builder memberikan fleksibilitas penuh.

@BeforeEach Shared Setup #

@BeforeEach menjalankan method sebelum setiap @Test. Pola ini cocok ketika banyak test membutuhkan instance awal yang sama — mis. kalkulator yang baru dibersihkan, list kosong, repository in-memory baru.

kotlin Contoh: @BeforeEach shared setup
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class KalkulatorTest {
    private lateinit var kalkulator: Kalkulator

    @BeforeEach
    fun setup() {
        kalkulator = Kalkulator() // instance baru per test
    }

    @Test fun `tambah dua angka`() { }
    @Test fun `bagi dengan nol melempar`() { }
}

Fixture Function #

Fixture function menyediakan parameter default yang dapat di-override per test. Pola ini ringkas dan ekspresif — kita hanya menyebutkan field yang berbeda dari default.

kotlin
// test_fixtures.kt
// Mendemonstrasikan shared setup: fixture function, test data builder,
// dan pola @BeforeEach untuk objek umum. Runnable tanpa dependency.

data class Pesanan(
    val id: Int,
    val pelanggan: String,
    val item: List<BarisItem>,
    val alamat: String,
    val diskon: Int = 0
)

data class BarisItem(val sku: String, val nama: String, val qty: Int, val harga: Int)

// === Fixture function === membuat objek umum dengan nilai default.
fun pesananFixture(
    id: Int = 1,
    pelanggan: String = "Budi",
    item: List<BarisItem> = listOf(BarisItem("B01", "Buku", 1, 10000)),
    alamat: String = "Jl. Mawar No. 1",
    diskon: Int = 0
): Pesanan = Pesanan(id, pelanggan, item, alamat, diskon)

// === Test Data Builder === objek yang akumulasikan parameter step by step.
class PesananBuilder {
    private var id: Int = 1
    private var pelanggan: String = "Budi"
    private var item: MutableList<BarisItem> = mutableListOf()
    private var alamat: String = "Jl. Mawar No. 1"
    private var diskon: Int = 0

    fun withId(id: Int) = apply { this.id = id }
    fun withPelanggan(nama: String) = apply { this.pelanggan = nama }
    fun tambahItem(item: BarisItem) = apply { this.item.add(item) }
    fun withAlamat(alamat: String) = apply { this.alamat = alamat }
    fun withDiskon(diskon: Int) = apply { this.diskon = diskon }
    fun build(): Pesanan = Pesanan(id, pelanggan, item.toList(), alamat, diskon)
}

// === Pola @BeforeEach === fungsi yang dipanggil sebelum tiap test,
// mengembalikan instance baru.
class FixtureContext {
    lateinit var pesananKosong: Pesanan
        private set
    lateinit var pesananSatu: Pesanan
        private set
    lateinit var pesananBesar: Pesanan
        private set

    fun beforeEach() {
        pesananKosong = pesananFixture(item = emptyList(), pelanggan = "Tamu")
        pesananSatu = pesananFixture(item = listOf(BarisItem("A1", "Apel", 2, 5000)))
        pesananBesar = PesananBuilder()
            .withId(99)
            .withPelanggan("Siti")
            .tambahItem(BarisItem("K1", "Kopi", 3, 15000))
            .tambahItem(BarisItem("G1", "Gula", 1, 8000))
            .withDiskon(10)
            .build()
    }
}

fun total(p: Pesanan): Int {
    val subtotal = p.item.sumOf { it.qty * it.harga }
    val diskon = subtotal * p.diskon / 100
    return subtotal - diskon
}

fun main() {
    // Simulasi @BeforeEach sebelum tiap test
    val ctx = FixtureContext()
    val tests = listOf(
        "pesanan kosong total 0" to { check(total(ctx.pesananKosong) == 0) },
        "pesanan satu item total 10000" to { check(total(ctx.pesananSatu) == 10000) },
        "pesanan besar diskon 10%" to { check(total(ctx.pesananBesar) == 47700) }
    )

    for ((nama, blok) in tests) {
        ctx.beforeEach() // shared setup
        blok()
        println("[LULUS] $nama")
    }

    // Demo Test Data Builder untuk skenario khusus
    println()
    val khusus = PesananBuilder()
        .withId(777)
        .withPelanggan("Dewi")
        .tambahItem(BarisItem("T1", "Teh", 5, 3000))
        .withAlamat("Jl. Anggrek No. 7")
        .build()
    println("Builder khusus: $khusus")
    println("Total: ${total(khusus)}")
}

Output yang Dihasilkan:

text
[LULUS] pesanan kosong total 0
[LULUS] pesanan satu item total 10000
[LULUS] pesanan besar diskon 10%

Builder khusus: Pesanan(id=777, pelanggan=Dewi, item=[BarisItem(sku=T1, nama=Teh, qty=5, harga=3000)], alamat=Jl. Anggrek No. 7, diskon=0)
Total: 15000

Mari kita bedah tiga pola yang muncul pada file di atas:

  1. pesananFixture(...): function dengan parameter default. Setiap test hanya menyebutkan field yang berbeda. Pola ini cocok untuk objek dengan beberapa field yang sering dipakai dengan nilai default sama.
  2. PesananBuilder: class dengan method withId, withPelanggan, tambahItem, dll. Setiap method mengembalikan this (apply) sehingga dapat dirantaikan. Cocok untuk objek dengan banyak field opsional yang sering dikombinasikan berbeda tiap test.
  3. FixtureContext.beforeEach(): simulasi @BeforeEach yang mempersiapkan tiga instance pesanan berbeda (kosong, satu item, besar). Setiap test menerima instance baru sehingga tidak ada kebocoran state.

Test Data Builder #

Builder pattern memberi fleksibilitas penuh. Berikut perbandingan singkat dengan fixture function:

kotlin Perbandingan fixture function vs builder
// Fixture function: ringkas untuk variasi kecil
val pesananA = pesananFixture(pelanggan = "Budi")

// Builder: fleksibel untuk objek kompleks
val pesananB = PesananBuilder()
    .withId(777)
    .withPelanggan("Siti")
    .tambahItem(BarisItem("T1", "Teh", 5, 3000))
    .tambahItem(BarisItem("K1", "Kopi", 2, 15000))
    .withDiskon(15)
    .build()

Builder menghasilkan kode yang lebih panjang tetapi membaca seperti kalimat deskriptif. Fixture function lebih ringkas tetapi memaksa kita mengingat urutan parameter.

Anti-Pattern Fixture #

Anti-patternMasalah
Setup besar di @BeforeEachTest jadi sulit dibaca — pembaca harus scroll untuk tahu state awal
Fixture function dengan 10 parameterSama rumitnya dengan constructor biasa; pakai builder
Builder tanpa defaultBuilder tidak berguna jika semua field wajib; tambah default
Shared state antar test via objectTest jadi non-deterministic — urutan eksekusi memengaruhi hasil

Referensi Lanjutan #

  • JUnit 5 user guide — panduan resmi JUnit 5 untuk test fixture, @BeforeEach, dan pola setup/teardown yang dapat dipakai ulang

Selanjutnya #

Setelah test terorganisir dengan fixture, langkah berikutnya adalah mengukur seberapa banyak kode yang benar-benar diuji. Chapter test-coverage akan memperkenalkan JaCoCo dan perbedaan line coverage dengan branch coverage.