Spec Driven Development Part 5: Testing dalam Spec-Driven Development
Sepanjang Part 1 sampai 4, acceptance criteria sudah berulang kali disebut sebagai sesuatu yang “harus bisa diverifikasi secara objektif”. Artikel ini membahas persisnya bagaimana verifikasi itu dilakukan: bagaimana acceptance criteria yang ditulis dalam notasi EARS diturunkan menjadi test otomatis, lapisan testing mana yang relevan untuk tiap jenis kriteria, dan masalah khusus yang muncul ketika kode dihasilkan oleh AI agent — di mana test bisa lolos sementara hasilnya tetap melenceng dari intent asli. Ini artikel penutup Bagian 1 series Spec-Driven Development, sebelum pembahasan berlanjut ke series Agentic Development.
Acceptance Criteria sebagai Sumber Test, Bukan Sebaliknya
Di TDD klasik, urutan kerjanya adalah: tulis test yang gagal, tulis kode minimal untuk membuatnya lolos, refactor. Test di sini adalah artefak yang ditulis duluan, dan spec — kalau ada — biasanya hanya berupa pemahaman informal di kepala developer.
SDD membalik urutan ini. Spec ditulis lebih dulu sebagai dokumen lengkap (intent, constraint, acceptance criteria, non-goals seperti dibahas di Part 2), dan test diturunkan dari acceptance criteria yang sudah ada di spec tersebut. Bedanya bukan sekadar urutan administratif — ini mengubah apa yang test sebenarnya verifikasi.
flowchart LR
subgraph TDD["TDD Klasik"]
A1[Tulis Test] --> A2[Tulis Kode Minimal]
A2 --> A3[Refactor]
end
subgraph SDD["Spec-Driven Development"]
B1[Tulis Spec: Intent + Acceptance Criteria] --> B2[Turunkan Test dari Acceptance Criteria]
B2 --> B3[Agent Implementasi]
B3 --> B4[Test Memverifikasi Sesuai Spec]
endPada TDD klasik, test mencerminkan asumsi developer tentang perilaku yang benar — asumsi yang bisa saja tidak lengkap karena belum ada dokumen spec yang dipikirkan secara menyeluruh sebelumnya. Pada SDD, test mencerminkan kontrak yang sudah direview dan disepakati sebelum implementasi dimulai. Ini penting khususnya ketika agent yang menulis kode: agent tidak punya konteks tersirat tentang “apa yang sebenarnya dimaksud” selain apa yang tertulis di spec, jadi test yang diturunkan langsung dari spec menjamin bahwa verifikasi mengukur hal yang sama dengan yang dimaksud penulis spec — bukan hal yang kebetulan diasumsikan benar oleh siapapun yang menulis test belakangan.
SDD tidak menghilangkan TDD — keduanya bisa berjalan bersamaan. Begitu acceptance criteria sudah jelas dari spec, siklus red-green-refactor tetap berlaku saat menulis test dan kode untuk memenuhi kriteria tersebut. Yang berubah adalah dari mana test itu berasal.
Dari EARS ke Test Case
Bentuk acceptance criteria dalam notasi EARS yang dibahas di Part 2 sengaja dirancang supaya hampir bisa langsung dipetakan ke struktur test. Setiap pola EARS punya padanan langsung di struktur given-when-then atau table-driven test.
Mengambil acceptance criteria reset password dari Part 2:
Acceptance Criteria (EARS):
- Ketika user mengirim email yang terdaftar ke endpoint reset password,
sistem harus mengirim email berisi link reset dalam waktu maksimal 5 detik
- Ketika user mengirim email yang tidak terdaftar, sistem harus tetap
merespons dengan pesan sukses yang sama
- Jika token reset sudah kedaluwarsa, sistem harus menolak request dengan
HTTP 410 dan pesan "link sudah tidak berlaku"
- Jika token reset sudah pernah dipakai, sistem harus menolak request kedua
dan langsung menghapus token dari database
Diterjemahkan menjadi test case dengan struktur given-when-then, setiap baris EARS menjadi satu skenario:
Skenario: Email terdaftar mengirim request reset
Given: user dengan email "[email protected]" terdaftar di sistem
When: POST /password-reset/request dengan email tersebut
Then: response 200 dengan accepted=true
And: email terkirim dalam waktu kurang dari 5 detik
Skenario: Email tidak terdaftar mengirim request reset
Given: email "[email protected]" tidak terdaftar
When: POST /password-reset/request dengan email tersebut
Then: response 200 dengan accepted=true (pesan identik dengan skenario sukses)
Skenario: Token sudah kedaluwarsa
Given: token reset dibuat 16 menit lalu (melewati batas 15 menit)
When: POST /password-reset/confirm dengan token tersebut
Then: response 410 dengan pesan "link sudah tidak berlaku"
Skenario: Token sudah pernah dipakai
Given: token reset sudah pernah berhasil dipakai sebelumnya
When: POST /password-reset/confirm dengan token yang sama
Then: response 410
And: token tetap terhapus dari database (idempotent)
Pola konversinya konsisten: klausa “Ketika [trigger]” dan “Jika [kondisi]” pada EARS menjadi bagian Given/When, sementara “sistem harus [perilaku]” menjadi bagian Then. Karena pola ini sudah baku sejak penulisan spec, penerjemahan ke test bukan pekerjaan interpretatif — siapapun (atau agent manapun) yang membaca acceptance criteria yang sama akan menghasilkan test case yang secara substansi identik.
func TestRequestReset_EmailNotRegistered_ReturnsGenericSuccess(t *testing.T) {
// Given: email tidak terdaftar
email := "[email protected]"
// When: request reset password
resp := requestPasswordReset(email)
// Then: response sama persis dengan skenario email terdaftar
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, true, resp.Body.Accepted)
}
func TestConfirmReset_TokenExpired_Returns410(t *testing.T) {
// Given: token dibuat 16 menit lalu
token := createExpiredToken(16 * time.Minute)
// When: confirm reset dengan token tersebut
resp := confirmPasswordReset(token, "NewPassword123")
// Then: ditolak dengan 410
assert.Equal(t, 410, resp.StatusCode)
assert.Contains(t, resp.Body.Message, "tidak berlaku")
}
Kalau menemukan acceptance criteria yang sulit diterjemahkan langsung ke struktur Given-When-Then, itu sinyal bahwa kriteria tersebut kemungkinan masih kurang spesifik — bukan masalah pada format testnya. Kembali ke Part 2 untuk memperjelas kriteria sebelum lanjut menulis test.
Lapisan Testing yang Relevan untuk SDD
Tidak semua acceptance criteria diverifikasi di lapisan testing yang sama. Spec yang baik mencakup berbagai jenis kriteria — dari perilaku fungsi tunggal sampai kontrak antar service — dan masing-masing punya lapisan testing yang paling cocok.
| Lapisan | Memverifikasi | Contoh dari Spec Reset Password |
|---|---|---|
| Unit test | Logika satu fungsi/komponen terisolasi | Validasi format password baru sesuai kebijakan |
| Integration test | Interaksi antar komponen dalam satu service | Endpoint confirm reset benar-benar menghapus token dari database setelah dipakai |
| Contract test | Implementasi tidak menyimpang dari skema (OpenAPI/JSON Schema dari Part 3) | Response endpoint benar-benar sesuai schema yang didefinisikan, termasuk field required dan tipe data |
| End-to-end test | Alur lengkap dari sudut pandang user | User menerima email, klik link, berhasil ganti password, bisa login dengan password baru |
Contract test adalah lapisan yang paling sering terlewat dalam praktik SDD, padahal paling relevan justru karena Part 3 membahas kontrak API sebagai elemen spec tersendiri. Contract test memverifikasi bahwa response API benar-benar cocok dengan skema yang sudah didefinisikan — bukan hanya “kode tidak error”, tapi “bentuk data persis sesuai kontrak yang disepakati semua konsumen”.
flowchart TD
A[Spec: Acceptance Criteria] --> B{Jenis kriteria?}
B -- Logika fungsi tunggal --> C[Unit Test]
B -- Interaksi antar komponen --> D[Integration Test]
B -- Bentuk data sesuai kontrak --> E[Contract Test]
B -- Alur lengkap user --> F[End-to-End Test]
C --> G[Verifikasi Otomatis]
D --> G
E --> G
F --> GPraktik yang umum adalah memetakan setiap acceptance criteria ke satu atau lebih lapisan ini sebagai bagian dari fase plan (dibahas di Part 4) — bukan diputuskan secara ad-hoc saat menulis test. Ini memastikan tidak ada acceptance criteria yang “terlewat” tanpa verifikasi otomatis sama sekali.
Mendeteksi Drift: Agent yang “Lolos” tapi Melenceng dari Intent
Ini masalah yang spesifik dan cukup berbahaya dalam konteks AI-generated code: agent bisa menghasilkan implementasi yang lolos seluruh test, tapi sebenarnya mengoptimalkan untuk lolos test itu sendiri — bukan untuk memenuhi intent di baliknya. Fenomena ini mirip dengan overfitting di machine learning: solusi yang sangat cocok dengan kasus uji yang ada, tapi gagal generalisasi ke kasus yang tidak tercakup test.
Contoh konkret: acceptance criteria menyatakan “jika email tidak terdaftar, sistem harus tetap merespons dengan pesan sukses yang sama” untuk mencegah email enumeration. Agent bisa saja membuat implementasi yang lolos test ini secara harfiah — response API memang identik — tapi tetap membocorkan informasi lewat jalur lain yang tidak tercakup test, misalnya response time yang berbeda signifikan antara email terdaftar dan tidak (karena hanya satu jalur yang benar-benar melakukan hashing password atau query database tambahan). Test lolos, tapi intent (mencegah enumeration) tidak benar-benar tercapai.
ANTI-PATTERN (lolos test, melenceng dari intent):
func RequestPasswordReset(email string) Response {
if !emailExists(email) {
return Response{Accepted: true} // langsung return, cepat
}
token := generateToken() // proses lambat hanya jika ada
saveTokenHash(token)
sendEmail(email, token)
return Response{Accepted: true}
}
// Response body identik, tapi response time membocorkan informasi
// melalui timing side-channel
BENAR (intent benar-benar terpenuhi):
func RequestPasswordReset(email string) Response {
// Selalu jalankan operasi dengan durasi serupa, terlepas dari
// apakah email terdaftar, untuk mencegah timing side-channel
token := generateToken()
if emailExists(email) {
saveTokenHash(token)
sendEmail(email, token)
}
// delay buatan jika diperlukan untuk menyamakan timing
return Response{Accepted: true}
}
Cara paling efektif mencegah drift semacam ini bukan menambah lebih banyak test secara reaktif setelah ditemukan, tapi menulis constraint di spec yang eksplisit menyebutkan ancaman yang ingin dicegah — seperti dibahas di Part 2, constraint keamanan harus dinyatakan eksplisit, bukan diasumsikan. Spec yang menyebutkan “harus mencegah email enumeration, termasuk melalui timing side-channel” memberi agent sinyal jelas bahwa response time juga bagian dari kontrak, bukan hanya response body.
Test yang hanya memverifikasi output akhir rentan dilewati oleh implementasi yang “lolos secara teknis” tapi salah secara intent. Untuk constraint yang sensitif (keamanan, privasi), tulis test yang memverifikasi properti yang lebih dalam dari sekadar output — misalnya konsistensi timing, bukan hanya kesamaan response body.
Test sebagai Guardrail Saat Agent Iterasi
Di Part 4, sudah dibahas bahwa eksekusi spec-driven berjalan bertahap per task group, dengan checkpoint review di antaranya. Test suite yang sudah ada dari task group sebelumnya berfungsi sebagai guardrail otomatis saat agent mengerjakan task group berikutnya — perubahan yang secara tidak sengaja merusak perilaku yang sudah terverifikasi akan langsung ketahuan, tanpa menunggu review manual menemukannya.
sequenceDiagram
participant Agent
participant TestSuite
participant Reviewer
Agent->>TestSuite: Implementasi Task Group 2
TestSuite-->>Agent: Semua test Task Group 1 tetap lolos
Agent->>Reviewer: Minta review Task Group 2
Reviewer->>Agent: Lanjut ke Task Group 3
Agent->>TestSuite: Implementasi Task Group 3
TestSuite-->>Agent: Test Task Group 1 GAGAL (regresi terdeteksi)
Agent->>Agent: Perbaiki sebelum lanjutGuardrail ini penting khususnya karena agent, berbeda dari developer manusia yang biasanya familiar dengan codebase secara keseluruhan, tidak selalu punya pemahaman implisit tentang bagian kode mana saja yang bergantung pada perilaku yang sedang diubah. Test suite yang komprehensif menggantikan pemahaman implisit itu dengan sinyal eksplisit: kalau perubahan task group baru membuat test task group lama gagal, itu tanda jelas ada regresi yang perlu ditangani sebelum lanjut — terlepas dari apakah agent “menyadari” hubungan antara dua bagian kode tersebut atau tidak.
Praktik yang disarankan: jalankan seluruh test suite (bukan hanya test untuk task group yang baru dikerjakan) di setiap checkpoint, dan jadikan “semua test lolos” sebagai syarat sebelum agent diizinkan lanjut ke task group berikutnya — konsisten dengan definition of done yang dibahas di Part 4.
Coverage yang Bermakna vs Coverage untuk Angka
Angka code coverage sering dipakai sebagai proxy untuk “seberapa baik kode sudah diverifikasi”. Dalam konteks SDD, angka ini bisa menyesatkan kalau dipakai tanpa kritis, karena coverage mengukur baris kode yang tereksekusi saat test berjalan — bukan mengukur apakah acceptance criteria di spec benar-benar terverifikasi.
Dua kode dengan coverage 100% bisa punya kualitas verifikasi yang sangat berbeda:
// Coverage 100%, tapi tidak benar-benar memverifikasi acceptance criteria
func TestResetToken(t *testing.T) {
token := generateToken()
if token == "" {
t.Fail()
}
// baris tereksekusi: ya. acceptance criteria terverifikasi: tidak.
// Tidak ada assertion soal expiry 15 menit, format UUID v4, atau
// bahwa token disimpan dalam bentuk hash
}
// Coverage mungkin sama, tapi acceptance criteria benar-benar terverifikasi
func TestResetToken(t *testing.T) {
token := generateToken()
assert.True(t, isValidUUIDv4(token))
assert.True(t, isStoredAsHash(token))
expiry := getTokenExpiry(token)
assert.WithinDuration(t, time.Now().Add(15*time.Minute), expiry, time.Second)
}
Ukuran yang lebih bermakna untuk SDD bukan “berapa persen baris kode tereksekusi”, melainkan “berapa persen acceptance criteria di spec yang punya test eksplisit yang memverifikasinya”. Pemetaan ini bisa dilakukan secara sederhana — daftar setiap baris acceptance criteria di spec, dan tandai test case mana yang memverifikasinya, mirip traceability matrix.
| Acceptance Criteria | Test Case Terkait | Status |
|---|---|---|
| Email terdaftar → email terkirim < 5 detik | TestRequestReset_Success_SendsEmailWithinTimeLimit | ✓ |
| Email tidak terdaftar → response identik | TestRequestReset_EmailNotRegistered_ReturnsGenericSuccess | ✓ |
| Token kedaluwarsa → HTTP 410 | TestConfirmReset_TokenExpired_Returns410 | ✓ |
| Token sudah dipakai → ditolak + token terhapus | — | ✗ belum ada test |
Traceability semacam ini lebih informatif daripada angka coverage tunggal, karena langsung menunjukkan gap spesifik — bukan sekadar “kurang 15% coverage” tanpa tahu bagian mana yang sebenarnya belum terverifikasi.
Sebelum menganggap suatu task group selesai, cek traceability dari acceptance criteria ke test, bukan hanya angka coverage. Coverage tinggi dengan assertion yang lemah memberi rasa aman palsu — persis seperti spec yang terlihat formal tapi sebenarnya ambigu, yang dibahas di Part 2.
Anti-Pattern Testing dalam SDD
Beberapa pola yang melemahkan fungsi test sebagai verifikasi spec, meski test itu sendiri “ada” dan “lolos”:
Test ditulis setelah kode lolos secara manual, bukan dari acceptance criteria. Urutan ini membalik prinsip dasar SDD yang dibahas di awal artikel — test yang ditulis untuk mencocokkan perilaku kode yang sudah ada cenderung memvalidasi apa yang kebetulan terjadi, bukan apa yang seharusnya terjadi menurut spec.
Test yang terlalu menempel ke detail implementasi. Test yang mengasersi struktur internal (misalnya nama variabel internal, urutan pemanggilan fungsi privat) alih-alih perilaku yang terlihat dari luar akan rapuh terhadap refactoring yang sah, dan menciptakan friksi yang tidak perlu setiap kali agent mengubah pendekatan implementasi tanpa mengubah perilaku yang sebenarnya dipersyaratkan spec.
Mengabaikan test untuk constraint non-fungsional. Constraint seperti performa (response time), keamanan (timing side-channel seperti contoh di atas), atau kompatibilitas sering tidak diuji sama sekali karena dianggap “sulit ditest” dibanding acceptance criteria fungsional. Padahal constraint inilah yang paling sering jadi sumber masalah serius ketika diasumsikan agent akan “otomatis benar”.
Menjalankan test hanya di akhir, bukan di setiap checkpoint. Konsisten dengan anti-pattern di Part 4 — menunda verifikasi sampai semua task group selesai menghilangkan fungsi test sebagai guardrail dini terhadap regresi.
Menganggap coverage tinggi setara dengan spec yang terverifikasi penuh. Seperti dibahas di atas, coverage adalah ukuran baris kode tereksekusi, bukan ukuran kesesuaian dengan acceptance criteria. Keduanya bisa berkorelasi, tapi tidak identik.
Ringkasan
- SDD membalik urutan TDD klasik: acceptance criteria di spec ditulis lebih dulu sebagai kontrak yang sudah direview, baru test diturunkan darinya — bukan test ditulis berdasarkan asumsi informal
- Notasi EARS dari Part 2 hampir bisa langsung dipetakan ke struktur Given-When-Then, membuat penerjemahan spec ke test case menjadi konsisten antar siapapun yang membacanya
- Setiap acceptance criteria sebaiknya dipetakan ke lapisan testing yang tepat — unit test untuk logika tunggal, integration test untuk interaksi komponen, contract test untuk kesesuaian dengan skema API dari Part 3, end-to-end test untuk alur lengkap
- Kode hasil AI agent bisa lolos test tapi tetap melenceng dari intent (mirip overfitting) — constraint sensitif seperti keamanan harus dinyatakan eksplisit di spec, dan test untuk constraint ini perlu memverifikasi properti yang lebih dalam dari sekadar output akhir
- Test suite yang ada berfungsi sebagai guardrail saat agent mengerjakan task group berikutnya — regresi pada task group sebelumnya harus langsung terdeteksi sebelum lanjut
- Code coverage tinggi tidak setara dengan acceptance criteria yang terverifikasi penuh — traceability dari tiap kriteria ke test case yang relevan lebih bermakna daripada angka persentase tunggal
- Hindari menulis test setelah kode “kelihatan benar” secara manual, test yang menempel ke detail implementasi, dan mengabaikan verifikasi untuk constraint non-fungsional seperti performa dan keamanan