Tampilan
Kenapa penting
Di pekerjaan nyata kamu lebih sering mengubah kode yang sudah ada daripada menulis dari nol — dan sebagian besar kode itu tidak punya test. Class seperti ini biasanya penuh angka misterius, if bertingkat, dan dependency yang dibuat langsung di dalam method. Menyentuhnya terasa menakutkan karena tidak ada yang tahu persis apa saja yang bergantung padanya. Proyek mini ini merangkum seluruh modul M05: kamu memakai JUnit dan AssertJ untuk mengunci perilaku, Mockito untuk memisahkan dependency, SLF4J untuk logging, dan Git untuk mencatat setiap langkah. Hasilnya bukan fitur baru, melainkan sesuatu yang lebih berharga: kode lama yang aman diubah.
Konsep inti
Kode warisan yang diberikan
Kamu mewarisi class OrderProcessor di layanan order sebuah toko online. Belum ada test sama sekali:
java
public class OrderProcessor {
public String process(String customerType, int qty, long unitPrice, String couponCode) {
double total = qty * unitPrice;
if (customerType.equals("GOLD")) {
total = total - total * 0.1;
} else if (customerType.equals("SILVER")) {
total = total - total * 0.05;
}
if (qty > 100) {
total = total - 50000;
}
if (couponCode != null && couponCode.equals("HEMAT10")) {
total = total * 0.9;
}
if (total < 0) {
total = 0;
}
long tax = (long) (total * 0.11);
long grand = (long) total + tax;
System.out.println("Order processed: " + grand);
new SmtpMailer("smtp.toko.local").send("finance@toko.local", "Order " + grand);
return "TOTAL=" + grand;
}
}
class SmtpMailer {
private final String host;
SmtpMailer(String host) {
this.host = host;
}
void send(String to, String message) {
// Di sistem asli: membuka koneksi SMTP. Di sini cukup simulasi.
System.out.println("[SMTP " + host + "] ke " + to + ": " + message);
}
}Masalahnya: angka 0.1, 0.05, 100, 50000, 0.9, 0.11 tanpa nama; satu method mengerjakan diskon, pajak, output, dan notifikasi; SmtpMailer dibuat langsung sehingga tidak bisa diganti di test; dan log ditulis ke System.out. Tugasmu merapikannya tanpa mengubah perilaku.
Aturan main refactoring
Refactoring berarti mengubah struktur kode tanpa mengubah perilaku yang terlihat dari luar. Bila di tengah jalan kamu menemukan bug, catat dulu — perbaiki di commit fix terpisah setelah refactor selesai dan perilakunya dikonfirmasi ke pemilik bisnis.
Langkah 1 — Characterization test
Kamu belum tahu output yang "benar", jadi biarkan kode yang memberi tahu. Tulis test dengan nilai harapan sembarang, jalankan, lalu salin nilai aktual dari pesan gagalnya:
java
// Potongan: test pertama, sengaja salah
assertThat(processor.process("GOLD", 3, 33_333, null)).isEqualTo("?");text
expected: "?"
but was: "TOTAL=99898"Ulangi untuk kasus yang mewakili setiap cabang: tiap tipe pelanggan, batas jumlah 100 dan 101, kupon valid dan huruf kecil, total yang jatuh di bawah nol, serta input null. Hasilnya menjadi test berparameter:
java
// Potongan: src/test/java/.../OrderProcessorCharacterizationTest.java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class OrderProcessorCharacterizationTest {
private final OrderProcessor processor = new OrderProcessor();
@ParameterizedTest(name = "{0}, {1} x {2}, kupon {3} -> {4}")
@CsvSource(nullValues = "NULL", value = {
"GOLD, 3, 33333, NULL, TOTAL=99898",
"SILVER, 150, 10000, NULL, TOTAL=1526250",
"REGULAR, 1, 20000, NULL, TOTAL=22200",
"REGULAR, 2, 50000, HEMAT10, TOTAL=99900",
"REGULAR, 2, 50000, hemat10, TOTAL=111000",
"gold, 1, 100000, NULL, TOTAL=111000",
"REGULAR, 100, 1000, NULL, TOTAL=111000",
"REGULAR, 101, 1000, NULL, TOTAL=56610",
"REGULAR, 101, 1, NULL, TOTAL=0"
})
@DisplayName("mengunci perilaku perhitungan total saat ini")
void locksCurrentTotals(String type, int qty, long price, String coupon, String expected) {
assertThat(processor.process(type, qty, price, coupon)).isEqualTo(expected);
}
@Test
@DisplayName("customerType null melempar NullPointerException (perilaku lama)")
void nullCustomerTypeThrows() {
assertThatThrownBy(() -> processor.process(null, 1, 10_000, null))
.isInstanceOf(NullPointerException.class);
}
}Test ini sudah membuka beberapa fakta yang tidak tertulis di mana pun: "gold" huruf kecil tidak mendapat diskon, kupon peka huruf besar-kecil, dan null membuat aplikasi crash. Catat ketiganya di tiket — mungkin bug, mungkin disengaja. Untuk saat ini, test menguncinya apa adanya.
Dependency berbahaya
Di sini SmtpMailer hanya mencetak teks, jadi test aman dijalankan. Bila dependency-nya mengirim email atau menulis ke database sungguhan, kerjakan Langkah 4 secara minimal lebih dulu (perubahan mekanis kecil yang mudah diperiksa mata), baru tulis characterization test.
Commit: test(order): characterization test untuk OrderProcessor.
Langkah 2 — Extract method
Pecah process menjadi method kecil yang masing-masing punya satu tugas. Pindahkan kode apa adanya — jangan sekalian "memperbaiki" rumus:
java
// Potongan: hasil extract method
public String process(String customerType, int qty, long unitPrice, String couponCode) {
double total = qty * unitPrice;
total = applyCustomerDiscount(total, customerType);
total = applyBulkDiscount(total, qty);
total = applyCoupon(total, couponCode);
if (total < 0) {
total = 0;
}
long grand = (long) total + taxOf(total);
// ... output dan notifikasi tetap seperti semula
return "TOTAL=" + grand;
}Jalankan test — hijau. Godaan terbesar di langkah ini adalah "sekalian merapikan". Misalnya mengganti cast (long) dengan Math.round agar terlihat lebih benar. Characterization test langsung menolak:
text
expected: "TOTAL=99898"
but was: "TOTAL=99899"Pajak untuk kasus itu 9899.901; cast membuangnya menjadi 9899, Math.round membulatkannya menjadi 9900. Itu perubahan perilaku yang memengaruhi uang pelanggan — kembalikan, dan catat sebagai usulan terpisah.
Commit: refactor(order): ekstrak perhitungan diskon dan pajak ke method.
Langkah 3 — Ganti magic number dengan konstanta
Beri nama pada setiap angka agar maksud bisnisnya terbaca:
java
static final double GOLD_DISCOUNT_RATE = 0.1;
static final double SILVER_DISCOUNT_RATE = 0.05;
static final int BULK_MIN_QTY_EXCLUSIVE = 100;
static final double BULK_DISCOUNT_AMOUNT = 50_000;
static final String COUPON_HEMAT10 = "HEMAT10";
static final double COUPON_HEMAT10_MULTIPLIER = 0.9;
static final double TAX_RATE = 0.11;
static final String FINANCE_EMAIL = "finance@toko.local";Pertahankan bentuk rumus total - total * GOLD_DISCOUNT_RATE; jangan diubah menjadi total * 0.9 walau secara aljabar sama — dengan double, urutan operasi bisa memengaruhi digit terakhir. Pengecekan kupon boleh ditulis COUPON_HEMAT10.equals(couponCode) karena hasilnya identik dengan couponCode != null && couponCode.equals(...). Sebaliknya, jangan mengubah customerType.equals("GOLD") menjadi "GOLD".equals(customerType): versi baru tidak lagi melempar NullPointerException, dan test nullCustomerTypeThrows akan gagal — persis tugasnya.
Commit: refactor(order): ganti magic number dengan konstanta bernama.
Langkah 4 — Pisahkan dependency agar bisa di-mock
Ekstrak interface dari apa yang dipakai OrderProcessor, lalu terima lewat constructor:
java
public interface Notifier {
void send(String to, String message);
}java
// Potongan: OrderProcessor setelah dependency dipisah
private final Notifier notifier;
/** Sementara, agar pemanggil lama tetap jalan. Hapus setelah semua pemanggil memberi Notifier. */
@Deprecated
public OrderProcessor() {
this(new SmtpMailer("smtp.toko.local"));
}
public OrderProcessor(Notifier notifier) {
this.notifier = notifier;
}SmtpMailer kini implements Notifier, dan process memanggil notifier.send(FINANCE_EMAIL, "Order " + grand). Constructor tanpa argumen menjaga pemanggil lama tetap terkompilasi; tandai @Deprecated agar IDE mengingatkan. Ubah characterization test memakai new OrderProcessor((to, message) -> { }) supaya tidak ada efek samping, lalu tambahkan test interaksi dengan Mockito:
java
// Potongan: OrderProcessorTest
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@Test
@DisplayName("mengirim notifikasi grand total ke finance")
void notifiesFinance() {
Notifier notifier = mock(Notifier.class);
OrderProcessor processor = new OrderProcessor(notifier);
processor.process("GOLD", 3, 33_333, null);
verify(notifier).send("finance@toko.local", "Order 99898");
}
@Test
@DisplayName("tidak mengirim notifikasi bila perhitungan gagal")
void noNotificationOnFailure() {
Notifier notifier = mock(Notifier.class);
OrderProcessor processor = new OrderProcessor(notifier);
assertThatThrownBy(() -> processor.process(null, 1, 10_000, null))
.isInstanceOf(NullPointerException.class);
verifyNoInteractions(notifier);
}Commit dua kali: refactor(order): injeksi Notifier lewat constructor, lalu test(order): verifikasi notifikasi finance dengan mock.
Langkah 5 — Tambah logging
Ganti System.out.println dengan SLF4J. Pesan memakai placeholder {} dan pasangan kunci=nilai agar mudah dicari:
java
// Potongan
private static final Logger log = LoggerFactory.getLogger(OrderProcessor.class);
// di dalam process(...)
log.info("Order diproses: customerType={}, qty={}, grandTotal={}", customerType, qty, grand);Teks di konsol berubah, tetapi itu bukan kontrak class ini — tidak ada test yang membaca System.out. Jangan me-log data sensitif seperti alamat email pelanggan atau nomor kartu.
Commit: refactor(order): ganti System.out dengan logger SLF4J.
Langkah 6 — Riwayat commit yang bercerita
Setiap langkah diakhiri test hijau lalu satu commit. Gunakan git add -p bila satu file berisi dua perubahan logis. Riwayatnya (dari repository latihan):
text
4b35413 refactor(order): ganti System.out dengan logger SLF4J
3a07669 test(order): verifikasi notifikasi finance dengan mock
ddb2933 refactor(order): injeksi Notifier lewat constructor
9e8c9f6 refactor(order): ganti magic number dengan konstanta bernama
e8644ff refactor(order): ekstrak perhitungan diskon dan pajak ke method
65c5093 test(order): characterization test untuk OrderProcessor
057d2ca chore(order): kode warisan OrderProcessor apa adanyaReviewer bisa memeriksa satu langkah per commit, dan bila langkah 5 bermasalah di production, cukup git revert 4b35413.
Contoh di dunia backend
Versi akhir OrderProcessor setelah enam langkah:
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderProcessor {
private static final Logger log = LoggerFactory.getLogger(OrderProcessor.class);
static final double GOLD_DISCOUNT_RATE = 0.1;
static final double SILVER_DISCOUNT_RATE = 0.05;
static final int BULK_MIN_QTY_EXCLUSIVE = 100;
static final double BULK_DISCOUNT_AMOUNT = 50_000;
static final String COUPON_HEMAT10 = "HEMAT10";
static final double COUPON_HEMAT10_MULTIPLIER = 0.9;
static final double TAX_RATE = 0.11;
static final String FINANCE_EMAIL = "finance@toko.local";
private final Notifier notifier;
/** Sementara, agar pemanggil lama tetap jalan. Hapus setelah semua pemanggil memberi Notifier. */
@Deprecated
public OrderProcessor() {
this(new SmtpMailer("smtp.toko.local"));
}
public OrderProcessor(Notifier notifier) {
this.notifier = notifier;
}
public String process(String customerType, int qty, long unitPrice, String couponCode) {
double total = qty * unitPrice;
total = applyCustomerDiscount(total, customerType);
total = applyBulkDiscount(total, qty);
total = applyCoupon(total, couponCode);
if (total < 0) {
total = 0;
}
long grand = (long) total + taxOf(total);
log.info("Order diproses: customerType={}, qty={}, grandTotal={}", customerType, qty, grand);
notifier.send(FINANCE_EMAIL, "Order " + grand);
return "TOTAL=" + grand;
}
private double applyCustomerDiscount(double total, String customerType) {
if (customerType.equals("GOLD")) {
return total - total * GOLD_DISCOUNT_RATE;
}
if (customerType.equals("SILVER")) {
return total - total * SILVER_DISCOUNT_RATE;
}
return total;
}
private double applyBulkDiscount(double total, int qty) {
return qty > BULK_MIN_QTY_EXCLUSIVE ? total - BULK_DISCOUNT_AMOUNT : total;
}
private double applyCoupon(double total, String couponCode) {
return COUPON_HEMAT10.equals(couponCode) ? total * COUPON_HEMAT10_MULTIPLIER : total;
}
private long taxOf(double total) {
return (long) (total * TAX_RATE);
}
}Untuk memastikan refactor ini benar-benar tidak mengubah perilaku, versi lama dan baru dibandingkan pada 1.600 kombinasi input (5 tipe pelanggan × 4 kupon × 10 jumlah × 8 harga): semua TOTAL dan pesan notifikasinya identik, dan input null tetap melempar NullPointerException. Teknik "bandingkan output lama vs baru untuk banyak input" ini disebut golden master dan berguna ketika cabang logikanya terlalu banyak untuk ditulis satu per satu.
Sekarang kode siap untuk perubahan perilaku yang disengaja, masing-masing dengan test baru dan commit fix/feat sendiri: menolak customerType kosong dengan pesan jelas, menyepakati apakah "gold" harus dianggap GOLD, dan berhenti menyimpan uang dalam double.
Latihan
Latihan kode untuk proyek ini ada di exercises/m05-build-testing/src/main/java/id/jalurjava/m05/order/OrderProcessor.java. Kode warisan tersedia utuh sebagai LegacyOrderProcessor (jangan diubah), Notifier sudah diekstrak, dan tugasmu menulis OrderProcessor versi refactor mengikuti langkah 2–5. Jaring pengamannya OrderProcessorTest: characterization test dari bab ini, perbandingan golden master dengan LegacyOrderProcessor untuk 1.600 kombinasi input, verifikasi notifikasi dengan Mockito, dan pemeriksaan log. Jalankan dari folder exercises/: ./mvnw -pl m05-build-testing test -Dtest=OrderProcessorTest. Commit setiap langkah seperti di Langkah 6.
Pertanyaan interview
1. Bagaimana kamu mulai mengubah class besar yang tidak punya test?
Saya mulai dengan characterization test yang mengunci perilaku saat ini lewat input yang mewakili setiap cabang, termasuk kasus aneh seperti null. Kalau ada dependency berbahaya, saya buat seam minimal dulu — misalnya mengekstrak interface dan menerimanya lewat constructor — agar test bisa jalan tanpa efek samping. Setelah itu baru refactor dalam langkah kecil yang masing-masing diakhiri test hijau dan commit.
2. Apa beda refactoring dengan memperbaiki bug, dan kenapa harus dipisah?
Refactoring mengubah struktur tanpa mengubah perilaku; memperbaiki bug justru sengaja mengubah perilaku. Bila dicampur dalam satu commit, reviewer tidak bisa membedakan perubahan yang disengaja dan yang tidak, dan bila ada masalah kamu tidak bisa me-revert salah satunya saja.
3. Kenapa dependency sebaiknya diterima lewat constructor, bukan dibuat dengan new di dalam method?
Dependency yang diterima dari luar bisa ditukar: implementasi asli di production, mock atau fake di test. Constructor juga membuat kebutuhan class terlihat jelas di satu tempat dan memungkinkan field-nya final. Ini dasar dependency injection yang nanti dikerjakan otomatis oleh Spring.
Jebakan umum
- Refactor sebelum ada test — tanpa jaring pengaman, perubahan perilaku kecil (seperti pembulatan) lolos ke production tanpa disadari.
- "Sekalian memperbaiki" saat refactor — mengganti cast dengan
Math.roundataux.equals("A")dengan"A".equals(x)terlihat sepele, tetapi mengubah perilaku. - Characterization test yang terlalu sedikit — hanya menguji satu jalur bahagia; cabang lain tetap tanpa pengaman. Cakup setiap
if, setiap batas, dan inputnull. - Menghapus constructor lama terlalu cepat — pemanggil lain di codebase ikut rusak. Tandai
@Deprecateddan hapus setelah semua pemanggil dimigrasi. - Satu commit raksasa untuk semua langkah — review jadi sulit dan
git reverttidak bisa memilih langkah yang bermasalah. - Me-log data sensitif saat mengganti
System.out— email, nomor kartu, atau token tidak boleh masuk log.
Ringkasan
- Characterization test mengunci perilaku kode saat ini; temukan nilainya dengan membiarkan test gagal lalu menyalin output aktual.
- Refactor dalam langkah kecil: extract method, beri nama magic number, pisahkan dependency — test hijau di setiap langkah.
- Dependency yang dibuat dengan
newdi dalam method diganti interface yang diterima lewat constructor agar bisa di-mock. - Ganti
System.outdengan SLF4J berplaceholder{}; jangan me-log data sensitif. - Satu langkah = satu commit Conventional Commits; perbaikan bug yang ditemukan dikerjakan terpisah.