Membangun dengan checkpoint

Membangun proyek bertahap dengan spesifikasi Spock sebagai checkpoint setiap tahap.

Daftar isi

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.

TahapYang dibangunYang diperiksa checkpointnya
1inti pustaka KalkulatorRekaprata-rata, ujung deret, klasifikasi huruf, penolakan daftar kosong
2DSL konfigurasi KonfigurasiRekapnilai default, hasil pembacaan DSL, efek batas pada klasifikasi
3antarmuka CLI picoclipenguraian 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.

groovy tahap1-inti.groovy
// 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(', ')}"
text Keluaran
Nilai       : 90, 85, 70, 60
Rata-rata   : 76.25
Tertinggi   : 90
Terendah    : 60
Klasifikasi : A, A, B, C

Keluaran 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.

groovy checkpoint-tahap1.groovy
// 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}"
    }
})
text Keluaran
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 pesan

Keempat 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.

groovy tahap2-dsl.groovy
// 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)"
text Keluaran
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.

groovy checkpoint-tahap2.groovy
// 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}"
    }
})
text Keluaran
LULUS nilai default dipakai tanpa blok konfigurasi
LULUS pasang membaca judul dan batas dari DSL
LULUS klasifikasi inti mengikuti batas hasil DSL

Perubahan 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.

groovy tahap3-cli.groovy
// 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)
text Keluaran
Rekap nilai ujian
Peserta    : 4
Rata-rata  : 76.25
Tertinggi  : 90
Terendah   : 60
Klasifikasi: A, A, B, C

Satu 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.

groovy checkpoint-tahap3.groovy
// 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}"
    }
})
text Keluaran
LULUS opsi --nilai diurai menjadi daftar angka
LULUS judul kembali ke nilai default ketika opsinya tidak dikirim
LULUS teks rekap memuat statistik dari inti pustaka

Penguraian 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.

bash
# 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 1

Pada 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 konfigurasi KonfigurasiRekap, 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 judul dan batas melalui delegasi closure, sedangkan inti pustaka membaca batas klasifikasi dari objek konfigurasi dengan nilai default 85 dan 70.
  • Antarmuka CLI picocli menguraikan --nilai dan --judul, dan seluruh checkpoint dijalankan ulang sebagai satu rangkaian setiap kali ada berkas yang disunting.

Referensi#