Proyek pemandu jalur ini kini dibangun menjadi program yang benar-benar berjalan, yakni CLI (command line interface) rekap nilai ujian. Pembangunannya dibagi tiga tahap, yaitu inti pustaka, DSL (domain-specific language) konfigurasi, dan antarmuka picocli. Setiap tahap ditutup dengan checkpoint, yakni spesifikasi Spock kecil yang wajib lulus seluruhnya sebelum tahap berikutnya dimulai. Setelah membaca topik ini, ketiga tahap beserta checkpointnya dapat ditiru pada proyek lain, mulai dari isi tiap tahap hingga cara menjalankan seluruh checkpoint sebagai satu rangkaian.
Mengapa checkpoint setiap tahap#
Checkpoint adalah spesifikasi Spock kecil yang melekat pada satu tahap pembangunan dan menjadi syarat pindah tahap. Aturannya tegas, yaitu tahap dianggap selesai hanya ketika checkpointnya lulus seluruhnya, sehingga kesalahan selalu tertahan pada tahap yang baru disunting dan tidak menumpuk menjelang akhir proyek. Dua manfaatnya saling melengkapi. Umpan baliknya cepat karena tiap spesifikasi berjalan beberapa detik tanpa build panjang. Regresinya terkendali karena checkpoint tahap lama dijalankan ulang setelah tahap baru selesai, sehingga perubahan yang merusak perilaku lama langsung ketahuan.
Proyek pemandu dibagi sebagai berikut.
| Tahap | Yang dibangun | Yang diperiksa checkpointnya |
|---|---|---|
| 1 | inti pustaka KalkulatorRekap | rata-rata, ujung deret, klasifikasi huruf, penolakan daftar kosong |
| 2 | DSL konfigurasi KonfigurasiRekap | nilai default, hasil pembacaan DSL, efek batas pada klasifikasi |
| 3 | antarmuka CLI picocli | penguraian opsi, nilai default judul, isi teks rekap |
Ketiga checkpoint memakai satu pola eksekusi yang sama, yaitu spesifikasi dijalankan langsung dari skrip Groovy melalui launcher JUnit Platform dengan dependensi @Grab pada spock-core, sebagaimana seluruh contoh pada modul pengujian Spock. Tidak ada konfigurasi build tambahan yang perlu disiapkan, sehingga checkpoint dapat berjalan di mana pun Groovy tersedia.
Tahap 1: inti pustaka dan checkpointnya#
Tahap pertama menyiapkan inti pustaka, yaitu kelas KalkulatorRekap yang akan dipakai seluruh lapisan di atasnya. Kelas ini menyediakan empat kemampuan: rataRata yang membagi jumlah nilai dengan banyaknya nilai, tertinggi dan terendah yang membaca ujung deret, serta klasifikasi yang memetakan nilai menjadi huruf A, B, atau C dengan batas 85 dan 70. Daftar nilai kosong tidak dihitung diam-diam, melainkan ditolak dengan IllegalArgumentException, karena rata-rata tanpa data selalu menyesatkan siapa pun yang membacanya.
// tahap1-inti.groovy: inti pustaka rekap nilai ujian beserta pemakaiannya
class KalkulatorRekap {
double rataRata(List<Integer> nilai) {
periksa(nilai)
(nilai.sum() as double) / nilai.size()
}
int tertinggi(List<Integer> nilai) {
periksa(nilai)
nilai.max()
}
int terendah(List<Integer> nilai) {
periksa(nilai)
nilai.min()
}
String klasifikasi(int nilai) {
if (nilai >= 85) return 'A'
if (nilai >= 70) return 'B'
'C'
}
private void periksa(List<Integer> nilai) {
if (nilai == null || nilai.isEmpty()) {
throw new IllegalArgumentException('daftar nilai kosong')
}
}
}
def kalkulator = new KalkulatorRekap()
def nilaiUjian = [90, 85, 70, 60]
println "Nilai : ${nilaiUjian.join(', ')}"
printf('Rata-rata : %.2f%n', kalkulator.rataRata(nilaiUjian))
println "Tertinggi : ${kalkulator.tertinggi(nilaiUjian)}"
println "Terendah : ${kalkulator.terendah(nilaiUjian)}"
println "Klasifikasi : ${nilaiUjian.collect { kalkulator.klasifikasi(it) }.join(', ')}"Nilai : 90, 85, 70, 60
Rata-rata : 76.25
Tertinggi : 90
Terendah : 60
Klasifikasi : A, A, B, CKeluaran di atas memperlihatkan angka yang dapat dihitung ulang, yaitu 305 dibagi empat peserta menghasilkan 76.25. Bukti semacam ini sayangnya hilang begitu terminal ditutup, sehingga keempat perilakunya perlu dikunci. Checkpoint tahap 1 melakukan persis itu, termasuk menguji klasifikasi tepat pada 85, 84, 70, dan 69, karena pergeseran satu angka batas adalah kesalahan yang paling mudah lolos tanpa disadari.
// checkpoint-tahap1.groovy: checkpoint tahap 1 untuk inti pustaka KalkulatorRekap
@Grab('org.spockframework:spock-core:2.4-groovy-5.0')
import org.junit.platform.engine.TestExecutionResult
import org.junit.platform.engine.discovery.DiscoverySelectors
import org.junit.platform.launcher.TestExecutionListener
import org.junit.platform.launcher.TestIdentifier
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
import org.junit.platform.launcher.core.LauncherFactory
import spock.lang.Specification
class KalkulatorRekap {
double rataRata(List<Integer> nilai) {
periksa(nilai)
(nilai.sum() as double) / nilai.size()
}
int tertinggi(List<Integer> nilai) {
periksa(nilai)
nilai.max()
}
int terendah(List<Integer> nilai) {
periksa(nilai)
nilai.min()
}
String klasifikasi(int nilai) {
if (nilai >= 85) return 'A'
if (nilai >= 70) return 'B'
'C'
}
private void periksa(List<Integer> nilai) {
if (nilai == null || nilai.isEmpty()) {
throw new IllegalArgumentException('daftar nilai kosong')
}
}
}
class KalkulatorRekapSpec extends Specification {
def kalkulator = new KalkulatorRekap()
def "rata-rata dihitung dari jumlah dibagi banyak nilai"() {
expect:
kalkulator.rataRata([80, 90]) == 85.0
}
def "tertinggi dan terendah berasal dari ujung deret nilai"() {
expect:
kalkulator.tertinggi([70, 90, 60]) == 90
kalkulator.terendah([70, 90, 60]) == 60
}
def "klasifikasi mengikuti batas 85 dan 70"() {
expect:
kalkulator.klasifikasi(85) == 'A'
kalkulator.klasifikasi(84) == 'B'
kalkulator.klasifikasi(70) == 'B'
kalkulator.klasifikasi(69) == 'C'
}
def "daftar nilai kosong ditolak dengan pesan"() {
when:
kalkulator.rataRata([])
then:
IllegalArgumentException e = thrown()
e.message == 'daftar nilai kosong'
}
}
// menjalankan checkpoint lewat launcher JUnit Platform
def launcher = LauncherFactory.create()
def permintaan = LauncherDiscoveryRequestBuilder.request()
.selectors(DiscoverySelectors.selectClass(KalkulatorRekapSpec))
.build()
launcher.execute(permintaan, new TestExecutionListener() {
void executionFinished(TestIdentifier id, TestExecutionResult hasil) {
if (!id.isTest() || id.isContainer()) return
String status = hasil.status == TestExecutionResult.Status.SUCCESSFUL ? 'LULUS' : 'GAGAL'
println "${status} ${id.displayName}"
}
})LULUS rata-rata dihitung dari jumlah dibagi banyak nilai
LULUS tertinggi dan terendah berasal dari ujung deret nilai
LULUS klasifikasi mengikuti batas 85 dan 70
LULUS daftar nilai kosong ditolak dengan pesanKeempat fitur lulus dan tahap 1 boleh ditutup. Spesifikasinya memakai pola modul pengujian Spock, yaitu anotasi @Grab pada spock-core, eksekusi lewat launcher JUnit Platform, dan listener yang mencetak LULUS atau GAGAL untuk setiap fitur. Dua berkas checkpoint lain pada topik ini menyalin pola yang sama, sehingga cara menjalankannya seragam dari awal hingga akhir.
Tahap 2: DSL konfigurasi dan checkpointnya#
Tahap kedua menyambut kebutuhan yang hampir pasti muncul, yaitu batas penilaian yang berbeda antar kelas tanpa menyentuh inti pustaka. Jawabannya berupa DSL konfigurasi, yakni bahasa kecil berbasis closure yang dibaca metode statis pasang. Blok konfigurasinya hanya mengenal dua perintah, yaitu judul untuk teks baris pertama rekap dan batas untuk pasangan huruf A dan B. Perintah itu bukan nama yang asing bagi Groovy, melainkan pemanggilan metode milik KonfigurasiRekap yang dicapai melalui delegasi, yakni properti delegate pada closure yang diarahkan ke objek konfigurasi beserta strategi DELEGATE_FIRST.
Ketika blok konfigurasi tidak dikirim, seluruh pengaturan kembali ke nilai defaultnya, yaitu judul Rekap nilai ujian dengan batas 85 dan 70. Inti pustaka pun berubah seperlunya, karena KalkulatorRekap kini menerima objek konfigurasi pada konstrukturnya dan membaca batas klasifikasi dari situ, sedangkan nilai default pada konstruktor menjaga pemanggilan lama tanpa konfigurasi tetap berjalan.
// tahap2-dsl.groovy: DSL konfigurasi rekap beserta pengaruhnya pada klasifikasi
class KonfigurasiRekap {
String judul = 'Rekap nilai ujian'
int batasA = 85
int batasB = 70
static KonfigurasiRekap pasang(Closure blok) {
KonfigurasiRekap konfigurasi = new KonfigurasiRekap()
blok.delegate = konfigurasi
blok.resolveStrategy = Closure.DELEGATE_FIRST
blok()
konfigurasi
}
void judul(String teks) {
this.judul = teks
}
void batas(Map<String, Integer> nilai) {
batasA = nilai.A
batasB = nilai.B
}
}
class KalkulatorRekap {
final KonfigurasiRekap konfigurasi
KalkulatorRekap(KonfigurasiRekap konfigurasi = new KonfigurasiRekap()) {
this.konfigurasi = konfigurasi
}
double rataRata(List<Integer> nilai) {
periksa(nilai)
(nilai.sum() as double) / nilai.size()
}
int tertinggi(List<Integer> nilai) {
periksa(nilai)
nilai.max()
}
int terendah(List<Integer> nilai) {
periksa(nilai)
nilai.min()
}
String klasifikasi(int nilai) {
if (nilai >= konfigurasi.batasA) return 'A'
if (nilai >= konfigurasi.batasB) return 'B'
'C'
}
private void periksa(List<Integer> nilai) {
if (nilai == null || nilai.isEmpty()) {
throw new IllegalArgumentException('daftar nilai kosong')
}
}
}
def bawaan = new KalkulatorRekap()
def kustom = new KalkulatorRekap(KonfigurasiRekap.pasang {
judul 'Rekap Ujian Akhir'
batas A: 90, B: 75
})
println "Judul bawaan : ${bawaan.konfigurasi.judul}"
println "Nilai 85 : ${bawaan.klasifikasi(85)} (batas 85/70)"
println "Judul kustom : ${kustom.konfigurasi.judul}"
println "Nilai 85 : ${kustom.klasifikasi(85)} (batas 90/75)"Judul bawaan : Rekap nilai ujian
Nilai 85 : A (batas 85/70)
Judul kustom : Rekap Ujian Akhir
Nilai 85 : B (batas 90/75)Nilai 85 berpindah dari A ke B semata karena batasnya diubah lewat DSL, dan perpindahan inilah yang dikunci checkpointnya. Spesifikasi tahap 2 memeriksa tiga hal, yaitu nilai default tanpa blok konfigurasi, hasil pembacaan judul dan batas dari DSL, serta efek batas baru pada klasifikasi yang diuji pada 90, 89, 75, dan 74.
// checkpoint-tahap2.groovy: checkpoint tahap 2 untuk DSL konfigurasi KonfigurasiRekap
@Grab('org.spockframework:spock-core:2.4-groovy-5.0')
import org.junit.platform.engine.TestExecutionResult
import org.junit.platform.engine.discovery.DiscoverySelectors
import org.junit.platform.launcher.TestExecutionListener
import org.junit.platform.launcher.TestIdentifier
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
import org.junit.platform.launcher.core.LauncherFactory
import spock.lang.Specification
class KonfigurasiRekap {
String judul = 'Rekap nilai ujian'
int batasA = 85
int batasB = 70
static KonfigurasiRekap pasang(Closure blok) {
KonfigurasiRekap konfigurasi = new KonfigurasiRekap()
blok.delegate = konfigurasi
blok.resolveStrategy = Closure.DELEGATE_FIRST
blok()
konfigurasi
}
void judul(String teks) {
this.judul = teks
}
void batas(Map<String, Integer> nilai) {
batasA = nilai.A
batasB = nilai.B
}
}
class KalkulatorRekap {
final KonfigurasiRekap konfigurasi
KalkulatorRekap(KonfigurasiRekap konfigurasi = new KonfigurasiRekap()) {
this.konfigurasi = konfigurasi
}
double rataRata(List<Integer> nilai) {
periksa(nilai)
(nilai.sum() as double) / nilai.size()
}
int tertinggi(List<Integer> nilai) {
periksa(nilai)
nilai.max()
}
int terendah(List<Integer> nilai) {
periksa(nilai)
nilai.min()
}
String klasifikasi(int nilai) {
if (nilai >= konfigurasi.batasA) return 'A'
if (nilai >= konfigurasi.batasB) return 'B'
'C'
}
private void periksa(List<Integer> nilai) {
if (nilai == null || nilai.isEmpty()) {
throw new IllegalArgumentException('daftar nilai kosong')
}
}
}
class KonfigurasiRekapSpec extends Specification {
def "nilai default dipakai tanpa blok konfigurasi"() {
when:
def konfigurasi = new KonfigurasiRekap()
then:
konfigurasi.judul == 'Rekap nilai ujian'
konfigurasi.batasA == 85
konfigurasi.batasB == 70
}
def "pasang membaca judul dan batas dari DSL"() {
when:
def konfigurasi = KonfigurasiRekap.pasang {
judul 'Rekap Ujian Akhir'
batas A: 90, B: 75
}
then:
konfigurasi.judul == 'Rekap Ujian Akhir'
konfigurasi.batasA == 90
konfigurasi.batasB == 75
}
def "klasifikasi inti mengikuti batas hasil DSL"() {
when:
def kalkulator = new KalkulatorRekap(KonfigurasiRekap.pasang {
batas A: 90, B: 75
})
then:
kalkulator.klasifikasi(90) == 'A'
kalkulator.klasifikasi(89) == 'B'
kalkulator.klasifikasi(75) == 'B'
kalkulator.klasifikasi(74) == 'C'
}
}
// menjalankan checkpoint lewat launcher JUnit Platform
def launcher = LauncherFactory.create()
def permintaan = LauncherDiscoveryRequestBuilder.request()
.selectors(DiscoverySelectors.selectClass(KonfigurasiRekapSpec))
.build()
launcher.execute(permintaan, new TestExecutionListener() {
void executionFinished(TestIdentifier id, TestExecutionResult hasil) {
if (!id.isTest() || id.isContainer()) return
String status = hasil.status == TestExecutionResult.Status.SUCCESSFUL ? 'LULUS' : 'GAGAL'
println "${status} ${id.displayName}"
}
})LULUS nilai default dipakai tanpa blok konfigurasi
LULUS pasang membaca judul dan batas dari DSL
LULUS klasifikasi inti mengikuti batas hasil DSLPerubahan tahap 2 sekaligus membuktikan manfaat checkpoint lama. Spesifikasi tahap 1 tetap lulus ketika dijalankan ulang terhadap inti yang baru, karena batas defaultnya tidak berpindah. Inilah regresi terkendali, yaitu tahap baru boleh menambah perilaku, tetapi tidak boleh merusak janji tahap lama.
Tahap 3: antarmuka CLI dan checkpointnya#
Tahap ketiga membungkus dua lapis sebelumnya menjadi program yang dipanggil dari terminal. Kelas RekapNilaiPerintah beranotasi @Command picocli dengan opsi --nilai yang menerima deret angka yang dipisah koma melalui atribut split, serta opsi --judul yang kembali ke nilai defaultnya ketika tidak dikirim. Logika pencetakan ditaruh pada metode tersendiri teksRekap() yang mengembalikan teks, sedangkan call() hanya mencetaknya, sehingga isi rekap dapat diperiksa tanpa perlu menangkap keluaran terminal.
// tahap3-cli.groovy: antarmuka CLI picocli di atas inti pustaka dan DSL konfigurasi
@Grab('info.picocli:picocli:4.7.6')
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import java.util.concurrent.Callable
class KonfigurasiRekap {
String judul = 'Rekap nilai ujian'
int batasA = 85
int batasB = 70
static KonfigurasiRekap pasang(Closure blok) {
KonfigurasiRekap konfigurasi = new KonfigurasiRekap()
blok.delegate = konfigurasi
blok.resolveStrategy = Closure.DELEGATE_FIRST
blok()
konfigurasi
}
void judul(String teks) {
this.judul = teks
}
void batas(Map<String, Integer> nilai) {
batasA = nilai.A
batasB = nilai.B
}
}
class KalkulatorRekap {
final KonfigurasiRekap konfigurasi
KalkulatorRekap(KonfigurasiRekap konfigurasi = new KonfigurasiRekap()) {
this.konfigurasi = konfigurasi
}
double rataRata(List<Integer> nilai) {
periksa(nilai)
(nilai.sum() as double) / nilai.size()
}
int tertinggi(List<Integer> nilai) {
periksa(nilai)
nilai.max()
}
int terendah(List<Integer> nilai) {
periksa(nilai)
nilai.min()
}
String klasifikasi(int nilai) {
if (nilai >= konfigurasi.batasA) return 'A'
if (nilai >= konfigurasi.batasB) return 'B'
'C'
}
private void periksa(List<Integer> nilai) {
if (nilai == null || nilai.isEmpty()) {
throw new IllegalArgumentException('daftar nilai kosong')
}
}
}
@Command(name = 'rekap-nilai', mixinStandardHelpOptions = true,
description = 'Merekap statistik nilai ujian dari deret nilai.')
class RekapNilaiPerintah implements Callable<Integer> {
@Option(names = ['--nilai'], required = true, split = ',',
description = 'Deret nilai ujian yang dipisah koma.')
List<Integer> nilai
@Option(names = ['--judul'], defaultValue = 'Rekap nilai ujian',
description = 'Judul yang dicetak pada baris pertama.')
String judul
String teksRekap() {
def kalkulator = new KalkulatorRekap()
String barisRata = sprintf('Rata-rata : %.2f', kalkulator.rataRata(nilai))
String barisKlas = nilai.collect { kalkulator.klasifikasi(it) }.join(', ')
[
judul,
"Peserta : ${nilai.size()}",
barisRata,
"Tertinggi : ${kalkulator.tertinggi(nilai)}",
"Terendah : ${kalkulator.terendah(nilai)}",
"Klasifikasi: $barisKlas",
].join('\n')
}
Integer call() {
println teksRekap()
0
}
}
int kode = new CommandLine(new RekapNilaiPerintah()).execute('--nilai', '90,85,70,60')
System.exit(kode)Rekap nilai ujian
Peserta : 4
Rata-rata : 76.25
Tertinggi : 90
Terendah : 60
Klasifikasi: A, A, B, CSatu perintah --nilai 90,85,70,60 kini menghasilkan rekap lengkap, termasuk klasifikasi huruf yang membawa batas dari DSL konfigurasinya. Checkpoint tahap 3 mengunci tiga perilakunya, yaitu penguraian --nilai menjadi daftar Integer, nilai default --judul, dan isi teks rekap yang memuat statistik dari inti pustaka.
// checkpoint-tahap3.groovy: checkpoint tahap 3 untuk antarmuka CLI picocli
@Grab('org.spockframework:spock-core:2.4-groovy-5.0')
@Grab('info.picocli:picocli:4.7.6')
import org.junit.platform.engine.TestExecutionResult
import org.junit.platform.engine.discovery.DiscoverySelectors
import org.junit.platform.launcher.TestExecutionListener
import org.junit.platform.launcher.TestIdentifier
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
import org.junit.platform.launcher.core.LauncherFactory
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import spock.lang.Specification
import java.util.concurrent.Callable
class KonfigurasiRekap {
String judul = 'Rekap nilai ujian'
int batasA = 85
int batasB = 70
static KonfigurasiRekap pasang(Closure blok) {
KonfigurasiRekap konfigurasi = new KonfigurasiRekap()
blok.delegate = konfigurasi
blok.resolveStrategy = Closure.DELEGATE_FIRST
blok()
konfigurasi
}
void judul(String teks) {
this.judul = teks
}
void batas(Map<String, Integer> nilai) {
batasA = nilai.A
batasB = nilai.B
}
}
class KalkulatorRekap {
final KonfigurasiRekap konfigurasi
KalkulatorRekap(KonfigurasiRekap konfigurasi = new KonfigurasiRekap()) {
this.konfigurasi = konfigurasi
}
double rataRata(List<Integer> nilai) {
periksa(nilai)
(nilai.sum() as double) / nilai.size()
}
int tertinggi(List<Integer> nilai) {
periksa(nilai)
nilai.max()
}
int terendah(List<Integer> nilai) {
periksa(nilai)
nilai.min()
}
String klasifikasi(int nilai) {
if (nilai >= konfigurasi.batasA) return 'A'
if (nilai >= konfigurasi.batasB) return 'B'
'C'
}
private void periksa(List<Integer> nilai) {
if (nilai == null || nilai.isEmpty()) {
throw new IllegalArgumentException('daftar nilai kosong')
}
}
}
@Command(name = 'rekap-nilai', mixinStandardHelpOptions = true,
description = 'Merekap statistik nilai ujian dari deret nilai.')
class RekapNilaiPerintah implements Callable<Integer> {
@Option(names = ['--nilai'], required = true, split = ',',
description = 'Deret nilai ujian yang dipisah koma.')
List<Integer> nilai
@Option(names = ['--judul'], defaultValue = 'Rekap nilai ujian',
description = 'Judul yang dicetak pada baris pertama.')
String judul
String teksRekap() {
def kalkulator = new KalkulatorRekap()
String barisRata = sprintf('Rata-rata : %.2f', kalkulator.rataRata(nilai))
String barisKlas = nilai.collect { kalkulator.klasifikasi(it) }.join(', ')
[
judul,
"Peserta : ${nilai.size()}",
barisRata,
"Tertinggi : ${kalkulator.tertinggi(nilai)}",
"Terendah : ${kalkulator.terendah(nilai)}",
"Klasifikasi: $barisKlas",
].join('\n')
}
Integer call() {
println teksRekap()
0
}
}
class RekapNilaiPerintahSpec extends Specification {
def "opsi --nilai diurai menjadi daftar angka"() {
when:
def perintah = CommandLine.populateCommand(new RekapNilaiPerintah(), '--nilai', '90,85,70,60')
then:
perintah.nilai == [90, 85, 70, 60]
}
def "judul kembali ke nilai default ketika opsinya tidak dikirim"() {
when:
def perintah = CommandLine.populateCommand(new RekapNilaiPerintah(), '--nilai', '70')
then:
perintah.judul == 'Rekap nilai ujian'
}
def "teks rekap memuat statistik dari inti pustaka"() {
when:
def perintah = CommandLine.populateCommand(new RekapNilaiPerintah(), '--nilai', '90,85,70,60')
def teks = perintah.teksRekap()
then:
teks.contains('Peserta : 4')
teks.contains('Rata-rata : 76.25')
teks.contains('Tertinggi : 90')
teks.contains('Terendah : 60')
teks.contains('Klasifikasi: A, A, B, C')
}
}
// menjalankan checkpoint lewat launcher JUnit Platform
def launcher = LauncherFactory.create()
def permintaan = LauncherDiscoveryRequestBuilder.request()
.selectors(DiscoverySelectors.selectClass(RekapNilaiPerintahSpec))
.build()
launcher.execute(permintaan, new TestExecutionListener() {
void executionFinished(TestIdentifier id, TestExecutionResult hasil) {
if (!id.isTest() || id.isContainer()) return
String status = hasil.status == TestExecutionResult.Status.SUCCESSFUL ? 'LULUS' : 'GAGAL'
println "${status} ${id.displayName}"
}
})LULUS opsi --nilai diurai menjadi daftar angka
LULUS judul kembali ke nilai default ketika opsinya tidak dikirim
LULUS teks rekap memuat statistik dari inti pustakaPenguraian opsinya diuji lewat CommandLine.populateCommand, yaitu metode yang hanya menguraikan argumen tanpa menjalankan perintahnya. Field kelas perintah dengan demikian dapat diperiksa langsung pada blok then tanpa keluaran terminal yang ikut tercetak.
Menjalankan seluruh checkpoint#
Ketiga berkas checkpoint disimpan pada folder yang sama dengan skrip tahapnya, lalu dijalankan berurutan dari tahap 1 hingga 3. Rangkaian dianggap lulus hanya ketika ketiganya mencetak LULUS untuk seluruh fiturnya, dan kegagalan pertama menghentikan pemindahan tahap sampai penyebabnya ditemukan.
# tidak-dijalankan
# menjalankan seluruh checkpoint dari folder proyek, berhenti pada kegagalan pertama
groovy checkpoint-tahap1.groovy || exit 1
groovy checkpoint-tahap2.groovy || exit 1
groovy checkpoint-tahap3.groovy || exit 1Pada eksekusi pertama, @Grab mengunduh jar spock-core dan picocli sekali, lalu menyimpannya di cache Grape sehingga eksekusi berikutnya selesai dalam beberapa detik. Karena setiap checkpoint berdiri sendiri, rangkaian yang sama juga mudah dipasang pada server CI (continuous integration) yang menjalankan pekerjaan otomatis, cukup dengan langkah yang sama persis.
Bila salah satu checkpoint gagal, baris GAGAL menunjuk fitur mana yang rusak, dan pekerjaan baru berlanjut setelah fitur itu lulus kembali.
Kesalahan umum#
Kesalahan pertama adalah mengunci cara kerja, bukan janjinya, misalnya menuntut bahwa teksRekap() memanggil rataRata tepat satu kali. Uji semacam itu gugur oleh penyuntingan yang sah seperti penataan ulang metode internal, padahal angka yang dijanjikan tidak berubah. Perbaikannya berupa pemeriksaan pada hasil janji saja, yaitu angka dan teks rekap yang menjadi kontrak programnya, sebagaimana ketiga checkpoint pada topik ini.
Kesalahan kedua adalah menimbang checkpoint dengan ukuran perubahannya. Perubahan yang terlihat kecil, misalnya mengganti satu nilai default, justru paling sering dilewati tanpa pemeriksaan, padahal nilai itu dibaca oleh lapisan lain. Aturan yang lebih aman berlaku dua arah, yaitu tahap baru dianggap selesai setelah checkpointnya lulus dan seluruh rangkaian dijalankan ulang sebelum perubahan dipindahkan ke tahap berikutnya.
Ringkasan#
- Proyek pemandu dibangun dalam tiga tahap, yaitu inti pustaka
KalkulatorRekap, DSL konfigurasiKonfigurasiRekap, dan antarmuka CLI picocli, dan setiap tahap ditutup spesifikasi Spock kecil yang wajib lulus sebelum pindah tahap. - Checkpoint memberi umpan balik cepat dan mengendalikan regresi karena seluruhnya dieksekusi langsung lewat launcher JUnit Platform dalam hitungan detik tanpa konfigurasi build.
- DSL konfigurasi membaca perintah
juduldanbatasmelalui delegasi closure, sedangkan inti pustaka membaca batas klasifikasi dari objek konfigurasi dengan nilai default 85 dan 70. - Antarmuka CLI picocli menguraikan
--nilaidan--judul, dan seluruh checkpoint dijalankan ulang sebagai satu rangkaian setiap kali ada berkas yang disunting.
Referensi#
- Domain-Specific Languages — Apache Groovy
— panduan resmi penulisan DSL berbasis closure dan delegasi yang menjadi dasar metode
pasang. - Dokumentasi Spock 2.4 — Spock Framework — dokumentasi resmi spesifikasi Spock yang menjadi bentuk ketiga checkpoint.
- Dokumentasi picocli — picocli — manual kelas perintah beranotasi yang menjadi antarmuka tahap ketiga.
- LauncherFactory — JUnit Team — API launcher JUnit Platform yang mengeksekusi spesifikasi langsung dari skrip Groovy.