Anatomi spesifikasi

Struktur spesifikasi Spock dengan blok given-when-then di atas extends Specification.

Daftar isi

Spock menata pengujian sebagai spesifikasi yang terbaca seperti kalimat, dan topik ini membedah anatomi spesifikasinya dari atas ke bawah. Pembahasan mencakup kelas turunan Specification, metode fitur dengan nama string, blok berlabel given, when, then, expect, cleanup, dan where, pernyataan implisit beserta pesan kegagalannya, serta anotasi deskriptif @Title, @Narrative, dan @Issue. Seluruh contoh berupa skrip Groovy utuh yang mengeksekusi spesifikasinya sendiri melalui peluncur JUnit Platform, sehingga setiap keluaran dapat direproduksi tanpa Gradle maupun Maven.

Spesifikasi dan metode fitur#

Spock adalah framework pengujian untuk Groovy dan Java yang berjalan di atas JUnit Platform, standar eksekusi pengujian pada JVM yang juga menaungi JUnit Jupiter. Sebuah spesifikasi adalah kelas yang mewarisi spock.lang.Specification, dan satu metode di dalamnya menyatakan satu fitur yang diuji. Nama metode fitur boleh berupa string, sehingga dapat ditulis sebagai kalimat utuh seperti def "mencatat dua nilai lalu merangkumnya"(), dan nama itulah yang muncul pada laporan eksekusi. Satu berkas pengujian dengan demikian terbaca sebagai daftar perilaku, bukan daftar nama metode teknis.

Tanpa Gradle maupun Maven, spesifikasi tetap dapat dieksekusi dari skrip Groovy biasa. Anotasi @Grab mengunduh artefak org.spockframework:spock-core:2.4-groovy-5.0 yang cocok untuk Groovy 5. Kelas peluncur (launcher) JUnit Platform sudah tersedia pada distribusi Groovy, sehingga skrip cukup memilih kelas spesifikasi yang diinginkan lalu mengeksekusinya. Sebuah listener anonim (TestExecutionListener) mencatat hasil setiap fitur, dan keluaran dirakit dari catatan itu.

Spesifikasi utuh pertama memakai kelas RekapNilai, penghitung rekap statistik nilai ujian, beserta tiga metode fitur yang mengujinya. Salin berkas berikut, lalu jalankan dengan perintah di bawahnya.

groovy uji-rekap.groovy
// uji-rekap.groovy: spesifikasi Spock dijalankan langsung dari skrip
@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.Issue
import spock.lang.Narrative
import spock.lang.Specification
import spock.lang.Title

class RekapNilai {
    private final daftar = []

    RekapNilai(int... nilaiAwal) {
        nilaiAwal.each { daftar << it }
    }

    int getJumlah() { daftar.size() }

    double getRataRata() { (daftar.sum() as double) / daftar.size() }

    int getTertinggi() { daftar.max() }

    void catat(int nilai) { daftar << nilai }

    void kosongkan() { daftar.clear() }
}

@Title('Rekap nilai ujian')
@Narrative('Menguji pencatatan nilai dan ringkasan statistiknya.')
class RekapNilaiSpec extends Specification {

    def "mencatat dua nilai lalu merangkumnya"() {
        given:
        def rekap = new RekapNilai()

        when:
        rekap.catat(80)
        rekap.catat(90)

        then:
        /* ringkasan diperiksa sekaligus lewat with */
        with(rekap) {
            jumlah == 2
            rataRata == 85.0
            tertinggi == 90
        }

        cleanup:
        rekap.kosongkan()
    }

    def "tertinggi diambil dari nilai terbesar yang dicatat"() {
        expect:
        new RekapNilai(75, 90).tertinggi == 90
    }

    @Issue('TCK-142')
    def "setiap pencatatan menambah jumlah satu"() {
        given:
        def rekap = new RekapNilai(80)

        when:
        rekap.catat(95)

        then:
        rekap.jumlah == old(rekap.jumlah) + 1
    }
}

def request = LauncherDiscoveryRequestBuilder.request()
    .selectors(DiscoverySelectors.selectClass(RekapNilaiSpec))
    .build()

def kontainer = []
def hasil = [:]
def listener = new TestExecutionListener() {
    void executionStarted(TestIdentifier id) {
        if (!id.isTest()) kontainer << id.displayName
    }

    void executionFinished(TestIdentifier id, TestExecutionResult hasilEksekusi) {
        if (id.isTest()) {
            def status = hasilEksekusi.status == TestExecutionResult.Status.SUCCESSFUL ? 'LULUS' : 'GAGAL'
            hasil[id.displayName] = status
        }
    }
}
LauncherFactory.create().execute(request, listener)

println "Kontainer: ${kontainer.join(', ')}"
hasil.each { nama, status -> println "$nama : $status" }
println "Narasi : ${RekapNilaiSpec.getAnnotation(Narrative).value()}"
def metodeIsu = RekapNilaiSpec.declaredMethods.find { it.isAnnotationPresent(Issue) }
println "Isu    : ${metodeIsu.getAnnotation(Issue).value().join(', ')}"
println "Total  : ${hasil.values().count('LULUS')} lulus, ${hasil.values().count('GAGAL')} gagal"
text Keluaran
Kontainer: JUnit Jupiter, Spock, Rekap nilai ujian
mencatat dua nilai lalu merangkumnya : LULUS
tertinggi diambil dari nilai terbesar yang dicatat : LULUS
setiap pencatatan menambah jumlah satu : LULUS
Narasi : Menguji pencatatan nilai dan ringkasan statistiknya.
Isu    : TCK-142
Total  : 3 lulus, 0 gagal

Berkas utuh tersebut dijalankan dengan satu perintah.

bash
# tidak-dijalankan
# menjalankan spesifikasi pertama, unduhan pertama butuh jaringan
groovy uji-rekap.groovy

Baris pertama keluaran memperlihatkan arsitektur JUnit Platform secara nyata. Peluncur menata eksekusi sebagai pohon kontainer, dan dua mesin pengujian (engine) ikut menanggapi seleksi tersebut, yaitu mesin JUnit Jupiter bawaan distribusi Groovy serta mesin Spock dari @Grab. Mesin Jupiter tidak memiliki satu pun fitur Spock sehingga tidak melaporkan hasil apa pun, sedangkan kelas RekapNilaiSpec tampil di bawah mesin Spock dengan nama Rekap nilai ujian dari anotasi @Title. Tiga metode fitur tercatat sebagai tiga hasil terpisah dengan nama stringnya, dan baris Total merangkum hitungannya.

Blok berlabel dan tugasnya#

Tubuh metode fitur dibagi menjadi blok berlabel, yaitu bagian yang diawali label berakhiran titik dua dan dipisahkan baris kosong. Label menyatakan peran setiap bagian secara eksplisit. Blok given menyiapkan data dan objek, when memanggil aksi yang diuji, then menampung pernyataan harapan, expect menggabungkan aksi dan pernyataan dalam satu blok, sedangkan cleanup merapikan sumber daya setelah fitur selesai, baik lulus maupun gagal. Pada spesifikasi RekapNilaiSpec, fitur pertama memakai given, when, then, dan cleanup sekaligus, sedangkan fitur kedua cukup dengan satu blok expect karena aksinya hanya membaca nilai.

BlokTugas pada fitur
givenmenyiapkan data, objek, dan kondisi awal
whenmenjalankan aksi yang sedang diuji
thenmenampung pernyataan atas hasil aksi
expectmenggabungkan aksi dan pernyataan dalam satu blok
cleanupmembersihkan sumber daya setelah fitur selesai
wheremenyediakan tabel data untuk banyak kasus

Blok where hanya disebutkan sekali di sini karena kajian tabel datanya luas dan layak mendapat pembahasan tersendiri. Bentuknya berpasangan dengan blok expect atau then, dan setiap baris tabelnya menggerakkan satu eksekusi fitur dengan data yang berbeda.

groovy Contoh: blok where dengan tabel data
// tidak-dijalankan
def "konversi Celsius ke Fahrenheit untuk beberapa titik"() {
    expect:
    konverter.keFahrenheit(celsius) == fahrenheit

    where:
    celsius | fahrenheit
    0       | 32
    25      | 77
    100     | 212
}

Komentar juga boleh ditulis di dalam metode fitur. Blok komentar /* */ yang mendahului with pada fitur pertama RekapNilaiSpec menjelaskan langkahnya, dan komentar baris // sah ditempel setelah label. Kerangka berikut merangkum seluruh label beserta keterangan komentarnya.

groovy Contoh: kerangka blok dalam satu metode fitur
// tidak-dijalankan
def "kerangka blok"() {
    given:  // persiapan data dan objek
    def rekap = new RekapNilai()

    when:   // aksi yang sedang diuji
    rekap.catat(80)

    then:   // pernyataan implisit, tanpa kata assert
    rekap.jumlah == 1

    cleanup:  // tetap berjalan meski pernyataan gagal
    rekap.kosongkan()
}

Pernyataan implisit dan pesan kegagalan#

Pernyataan pada blok then dan expect ditulis tanpa kata assert. Spock mengubah setiap ekspresi boolean di kedua blok tersebut menjadi assertion secara implisit, sehingga rekap.jumlah == 1 pada blok then berarti sama dengan assert rekap.jumlah == 1 pada kode Groovy biasa. Menulis assert secara manual tidak menimbulkan error, tetapi hanya menambah baris yang perannya sudah jelas.

Dua helper bawaan Specification merapikan pernyataan yang lebih panjang. Helper with mengelompokkan pernyataan pada satu objek, sehingga jumlah, rataRata, dan tertinggi pada fitur pertama diperiksa tanpa mengulang nama variabelnya. Helper old menangkap nilai ekspresi sebelum aksi dijalankan, lalu membandingkannya dengan keadaan setelah aksi berjalan, seperti pada fitur ketiga yang memastikan jumlah bertambah satu per pencatatan.

Ketika pernyataan gagal, Spock merender kondisinya bersama nilai setiap bagian yang terlibat. Spesifikasi berikut sengaja memuat harapan yang keliru, yakni fitur yang mengira frasa tiga kata berisi empat kata, agar kegagalan itu ikut tercetak pada keluaran. Skrip menangkap exception pada kegagalan tersebut lalu mencetak isi properti message-nya.

groovy uji-penghitung.groovy
// uji-penghitung.groovy: satu fitur gagal, pesan kegagalannya ditampilkan
@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 PenghitungKata {
    int hitung(String teks) {
        teks.trim().split(/\s+/).length
    }
}

class PenghitungKataSpec extends Specification {

    def "frasa tiga kata dihitung empat"() {
        given:
        def penghitung = new PenghitungKata()
        def jumlah = penghitung.hitung('groovy itu menyenangkan')

        expect:
        jumlah == 4
    }
}

def request = LauncherDiscoveryRequestBuilder.request()
    .selectors(DiscoverySelectors.selectClass(PenghitungKataSpec))
    .build()

def hasil = [:]
def pesan = [:]
def listener = new TestExecutionListener() {
    void executionFinished(TestIdentifier id, TestExecutionResult hasilEksekusi) {
        if (id.isTest()) {
            def lulus = hasilEksekusi.status == TestExecutionResult.Status.SUCCESSFUL
            hasil[id.displayName] = lulus ? 'LULUS' : 'GAGAL'
            if (!lulus) pesan[id.displayName] = hasilEksekusi.throwable.get().message
        }
    }
}
LauncherFactory.create().execute(request, listener)

hasil.each { nama, status ->
    println "$nama : $status"
    if (pesan[nama]) {
        println ''
        println pesan[nama]
    }
}
println "Total  : ${hasil.values().count('LULUS')} lulus, ${hasil.values().count('GAGAL')} gagal"
text Keluaran
frasa tiga kata dihitung empat : GAGAL

Condition not satisfied:

jumlah == 4
|      |
3      false

Total  : 0 lulus, 1 gagal

Baris Condition not satisfied menandai pernyataan yang gugur. Baris pertama setelahnya mengulang sumber kondisinya, yaitu jumlah == 4. Baris kedua menandai posisi nilai dengan garis vertikal, dan baris ketiga memuat nilainya, yaitu 3 untuk jumlah dan false untuk seluruh perbandingan. Dari tampilan itu terlihat langsung bahwa implementasi menghitung tiga kata sedangkan spesifikasi menuntut empat, sehingga sisi yang perlu diperbaiki mudah diputuskan. Pesan sedetail ini diperoleh tanpa membaca stack trace sama sekali.

Anotasi deskriptif pada spesifikasi#

Metadata deskriptif menempel pada spesifikasi lewat anotasi dari paket spock.lang. Anotasi @Title mengganti nama spesifikasi pada laporan, dan efeknya sudah terlihat pada baris Kontainer keluaran pertama, yaitu Rekap nilai ujian menggantikan nama kelas RekapNilaiSpec. @Narrative menyimpan keterangan panjang tentang maksud spesifikasi, sedangkan @Issue menautkan satu metode fitur pada nomor isu di pelacak isu proyek. Ketiga anotasi terpasang pada spesifikasi uji-rekap.groovy, dan skrip itu membaca kembali isinya lewat refleksi saat runtime.

Ketiganya tidak mengubah jalannya eksekusi. Perannya baru terasa pada konsumen laporan, misalnya laporan pengujian pada alat build, panel pengujian IDE, atau alat yang menyambungkan hasil uji dengan pelacak isu. Karena nilainya terbaca saat runtime, anotasi ini juga dapat dipakai skrip lain sebagai data, sebagaimana baris Narasi dan Isu pada keluaran pertama menampilkan isi anotasi lewat refleksi. Saat rangkaian pengujian membesar, keterangan semacam ini menjaga maksud spesifikasi tetap terbaca tanpa harus membuka isi setiap fitur.

Kesalahan umum#

Kesalahan pertama adalah menulis metode fitur pada kelas yang belum mewarisi Specification. Label blok tetap terbaca sebagai label Groovy biasa sehingga kelasnya lolos kompilasi, tetapi mesin Spock tidak mengenalinya sebagai spesifikasi. Contoh berikut mengeksekusi kelas semacam itu melalui peluncur yang sama dengan contoh sebelumnya.

groovy Contoh: kelas fitur tanpa extends Specification
// lupa-specification.groovy: kelas fitur tanpa extends Specification
@Grab('org.spockframework:spock-core:2.4-groovy-5.0')

import org.junit.platform.engine.discovery.DiscoverySelectors
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
import org.junit.platform.launcher.core.LauncherFactory
import org.junit.platform.launcher.listeners.SummaryGeneratingListener

class RekapSalahSpec {   // belum mewarisi Specification
    def "mencatat dua nilai"() {
        given:
        def jumlah = 2

        when:
        def hasil = jumlah * 2

        then:
        hasil == 4
    }
}

def request = LauncherDiscoveryRequestBuilder.request()
    .selectors(DiscoverySelectors.selectClass(RekapSalahSpec))
    .build()
def listener = new SummaryGeneratingListener()
LauncherFactory.create().execute(request, listener)
println "Fitur ditemukan : ${listener.summary.testsFoundCount}"
println "Lulus ${listener.summary.testsSucceededCount}, gagal ${listener.summary.testsFailedCount}"
text Keluaran
Fitur ditemukan : 0
Lulus 0, gagal 0

Keluarannya menunjukkan nol fitur ditemukan dan tidak ada satu pun pernyataan yang dieksekusi. Laporan semacam ini tampak sehat karena tidak ada kegagalan, padahal tidak ada perilaku yang benar-benar diuji. Deklarasi extends Specification pada kelas spesifikasi karenanya tidak boleh tertinggal, dan jumlah fitur pada ringkasan eksekusi layak diperiksa setiap kali rangkaian dijalankan.

Kesalahan kedua menyangkut konfigurasi dependensi Grape pada skrip spesifikasi.

Ringkasan#

  • Spesifikasi Spock adalah kelas yang mewarisi spock.lang.Specification, dan satu metode fitur dengan nama string menyatakan satu fitur yang dilaporkan terpisah.
  • Blok given, when, then, expect, dan cleanup membagi tubuh fitur menurut perannya, sedangkan blok where menyediakan tabel data untuk banyak kasus.
  • Blok then dan expect menegaskan setiap ekspresi boolean tanpa kata assert, dengan helper with untuk pernyataan pada satu objek dan old untuk nilai awal fitur, dan kegagalan ditampilkan sebagai Condition not satisfied beserta nilai tiap bagiannya.
  • Anotasi @Title, @Narrative, dan @Issue melengkapi metadata laporan, sementara kelas tanpa extends Specification menghasilkan nol fitur tanpa kegagalan yang terlihat.

Referensi#