Mungkin Anda pernah mendengar istilah TDD (Test Driven Development) atau mungkin istilah-istilah seperti :
- Unit Tests
- Integration Tests
- Regression Tests
- Code Coverages
Semua yang saya sebutkan di atas tadi adalah metode testing yang saat ini banyak dipakai dalam pengembangan sebuah aplikasi. Dan tulisan kali ini akan membahas tentang software testing.
Ketika kita mengimplementasikan TDD, alur yang benar adalah adalah :
Test -> Code -> Test
Saya pribadi bukan penganut TDD yang baik, tapi saya penganut software test. Saya setuju dengan konsep software testing, karena sudah merasakan manfaat dari test ini.
Software testing yang dimaksud di sini bukan yang dilakukan oleh QA (Quality Assurance) yang melakukan test manual berdasarkan skenario yang sudah disiapkan -saya menyebutnya, “test klak klik”. Yang dimaksud di tulisan ini adalah metode testing software yang dijalankan secara otomatis dengan menggunakan script atau dengan continuous integration semacam Jenkins.
Saya akan fokus di tiga hal, yaitu:
- Unit Test
- Integration Test
- Code Coverage
Catatan: Ruang lingkup di tulisan ini adalah untuk aplikasi berbasis web, yang saat ini semakin lama semakin kompleks yaitu bisa saja untuk :
- API (REST) Development
- Internal Service Development (microservice)
- Data Logic (manipulasi data yang berhubungan langsung dengan database)
Contoh kode-kode dalam tulisan ini adalah dalam bahasa program Python.
Unit Tests
Test di sini ditujukan untuk bagian-bagian terkecil dari sebuah sistem / aplikasi. Contohnya misal untuk sebuah library atau helper. Test ini sangat cocok diimplementasikan jika kode library / helper kita tidak memiliki dependensi terhadap service di luar sistem aplikasi. Misal, kita memiliki sebuah helper untuk melakukan validasi terhadap sebuah string, dengan ekspektasi: melakukan trigger exception. Jika ternyata value dari variable string tersebut kosong, maka testnya bisa dibuat seperti ini:
def test_string_should_not_be_empty(self):
with self.assertRaises(ValidationError):
string_should_not_be_empty("")
Penjelasan kode di atas adalah Unit Test yang dibuat untuk melakukan test terhadap sebuah fungsi yang sudah di buat sebelumnya, dengan ekspektasi fungsi tersebut seharusnya akan men-trigger sebuah exception ValidationError jika kita memasukkan value string kosong (empty string).
Ok, ini mudah baik dari segi logic & test ekspektasinya. Sekarang bagaimana kalau kita ingin melakukan test untuk semacam Internal Queue? Queue di sini bukan queue yang berhubungan dengan RabbitMQ, Redis atau semacamnya. Queue yang saya jadikan contoh di sini adalah fitur queue yang ada di framework Python yang bernama Tornado. Queue di sini lebih ke arah design pattern Observer, hanya saja observer pattern di sini didesain untuk menangani aktivitas async (non blocking).
Dan berikut cara saya untuk membuat Unit Testnya :
class TestQueueProcessor(AsyncTestCase):
@gen_test
def test_queue(self):
@coroutine
def mock_listener(payload):
self.assertEqual('payload', payload)
raise Return(payload)
q = QueueProcessor(mock_listener, pause=pause)
yield q.broadcast(msg)
yield sleep(wait)
Penjelasan dari kode di atas adalah, saya membuat sebuah listener (subscriber) yang saya masukkan ke queue processor, dengan harapan listener ini seharusnya akan dipanggil ketika proses broadcast queue tersebut ditrigger. Tantangannya adalah, library queue processor yang saya buat ini didesain untuk berjalan di environment async (non blocking).
Lalu bagaimana jika kita ingin melakukan test terhadap sebuah library yang memiliki dependensi dengan library lainnya? Bagi saya pribadi, strategi yang saya pakai adalah mengimplementasikan pattern DI (Dependency Injection) dalam mengatur dependensi antar library. Berikut contoh testnya:
class MockHTTPService(object):
def __init__(self, expected_output):
self.output = expected_output
@coroutine
def fetch(self, base_endpoint, **kwargs):
raise Return(self.output)
class TestLib1(AsyncTestCase):
@gen_test
def test_service_unknown(self):
payload = dict(
key1 = "testing",
key2 = "[email protected]",
key3 = "testing"
)
expected_output = MockResponse()
expected_output.error = 'Something error'
http = MockHTTPService(expected_output)
lib1 = Lib1(http)
response = yield lib1.custom_method(payload)
self.assertTrue(response['is_error'])
self.assertEqual('Something error', response['error_message'])
Manfaat DI di sini sangat terasa. Dengan menggunakan DI, kita bisa melakukan mocking terhadap library yang menjadi dependensi. Dalam contoh ini, Lib1 adalah sebuah library yang bertugas untuk melakukan koneksi ke sebuah REST API, yang artinya Lib1 ini memiliki dependensi dengan library Http Transporter. Awalnya sebelum mengimplementasikan DI, Lib1 ini bagi saya pribadi tidak bisa dikategorikan ke dalam Unit Test, tetapi lebih ke Integration Test, karena ketika kita menjalankan test, test code kita akan melakukan koneksi real ke API yang bersangkutan.
Dan sebagai bonus saya berikan contoh unit test dari Redis : https://github.com/antirez/redis/blob/unstable/tests/unit/expire.tcl
test {Redis should lazy expire keys} {
r flushdb
r debug set-active-expire 0
r psetex key1 500 a
r psetex key2 500 a
r psetex key3 500 a
set size1 [r dbsize]
# Redis expires random keys ten times every second so we are
# fairly sure that all the three keys should be evicted after
# one second.
after 1000
set size2 [r dbsize]
r mget key1 key2 key3
set size3 [r dbsize]
r debug set-active-expire 1
list $size1 $size2 $size3
} {3 3 0}
Integration Test
Jika Unit Test adalah sebuah mekanisme testing yang kita tujukan untuk codebase yang berjalan di internal aplikasi, maka Integration Test adalah mekanisme testing dimana kita melakukan running sistem termasuk untuk berhubungan dengan service-service di luar sistem aplikasi kita, misal berhubungan dengan REST API atau mungkin berhubungan dengan service Thrift.
Contoh:
func TestDeleteHandlerSuccessResponse(t *testing.T) {
utiltest.ParseEnvFile()
router := util.HTTPTester()
MockRoutesUserDelete(router)
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/v2/users/%v", idUserDeleteTest), nil)
req.Header.Set("X-KEY-ID", "Testing")
req.Header.Set("X-KEY-Email", "[email protected]")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
body := resp.Body.Bytes()
respJSON, err := json.NewJson(body)
if err != nil {
log.Fatalln(err)
}
status := respJSON.Get("status").MustString()
assert.Equal(t, "success", status)
}
Kode di atas saya ambil dari salah satu proyek pribadi saya menggunakan bahasa pemrograman bahasa program Go. Apa yang terjadi di kode tersebut ? Saya melakukan test dengan menjalankan HTTP Server secara real dan juga melakukan koneksi ke service yang dihandle oleh Apache Thrift.
Seperti yang sudah saya jelaskan di atas, perbedaan antara Unit Test dan Integration Test adalah sebagai berikut: Unit Test adalah mekanisme testing unit-unit terkecil yang menjadi bagian dari aplikasi kita dan sebisa mungkin minimal dependensi dengan service di luar sistem aplikasi kita. Sedangkan Integration Test adalah sebuah mekanisme testing dimana kita benar-benar melakukan automate testing terhadap aplikasi kita, dengan menjalankan aplikasi kita termasuk dengan dependensi service dari aplikasi kita, misal database / service third party, dan semacamnya.
Dalam kondisi real aplikasi web saat ini yang rata-rata sudah memakai konsep MVC, ruang lingkup antara Unit Test dan Integration Test adalah :
- Controller & Model masuk ke konteks Integration Test
- Library / Helper atau sejenisnya masuk ke konteks Unit Test
Code Coverage
Code coverage adalah sebuah metode analisis untuk mengetahui seberapa dalamkah kita melakukan testing terhadap codebase kita sendiri?
Contoh code coverage :
nose.config: INFO: Set working dir to /vagrant/typhoon/tests/core
nose.config: INFO: Working directory /vagrant/typhoon/tests/core is a package; adding to sys.path
nose.config: INFO: Ignoring files matching ['^\\.', '^_', '^setup\\.py$']
nose.plugins.cover: INFO: Coverage report will include only packages: ['../core/builder.py', '../core/registry.py', '../core/session.py', '../core/exceptions/application.py']
Test mock call builder.env() and return yaml object ... ok
Test mock call Builder().env() ... ok
test_build_env_real_env_file (tests.core.test_builder.TestBuilder) ... ok
Test env should be trigger an exception when try to load ... ok
test_build_logs_set_level (tests.core.test_builder.TestBuilder) ... ok
test_build_settings_error (tests.core.test_builder.TestBuilder) ... ok
test_build_settings_no_session (tests.core.test_builder.TestBuilder) ... ok
test_build_settings_real (tests.core.test_builder.TestBuilder) ... ok
test_build_settings_success (tests.core.test_builder.TestBuilder) ... ok
test_build_registry (tests.core.test_registry.TestRegistry) ... ok
test_container_error (tests.core.test_registry.TestRegistry) ... ok
test_empty_dict (tests.core.test_session.TestSession) ... ok
test_memcache_dict (tests.core.test_session.TestSession) ... ok
test_redis_dict (tests.core.test_session.TestSession) ... ok
test_redis_optional_keys (tests.core.test_session.TestSession) ... ok
Name Stmts Miss Cover Missing
-------------------------------------------------------------------------------
/vagrant/typhoon/core/builder.py 24 0 100%
/vagrant/typhoon/core/exceptions/application.py 9 0 100%
/vagrant/typhoon/core/registry.py 27 0 100%
/vagrant/typhoon/core/session.py 23 0 100%
-------------------------------------------------------------------------------
TOTAL 83 0 100%
----------------------------------------------------------------------
Ran 15 tests in 0.558s
OK
Di sini saya memberikan contoh code coverage dari salah satu projek pribadi saya (lagi), yaitu menggunakan bahasa pemrograman Python dengan menggunakan tools:
- unittest
- nose
- coverage
Apa yang bisa kita lihat di sini ? Di bagian akhir kita bisa melihat empat file yang saya jadikan target dari test saya yaitu :
- builder.py
- exceptions/application.py
- registry.py
- session.py
Dan di sini kita bisa melihat angka 100% untuk coverage dari setiap file tersebut, yang artinya setiap line code produktif dari masing-masing file tersebut sudah saya test, dan ini bisa di lihat juga di kolom Missing, di mana tidak ada data di kolom ini, yang artinya tidak ada bagian line code produktif dari masing-masing baris yang belum ditest, karena jika ada satu line code saja belum di test akan muncul di kolom Missing untuk line numbernya dan persentase coverage akan berkurang.
Kombinasi antara Unit Test dan Integration Tests + Code Coverage akan menghasilkan analisis yang mendalam terhadap :
- Internal codebase (Unit Test)
- State sistem / aplikasi ketika benar-benar di run (Unit Test dan Integration test)
- Seberapa dalamkah test kita terhadap aplikasi kita sendiri (Code Coverage)
Ketiga hal ini menghasilkan data yang akurat tentang state aplikasi kita, jadi dengan ini bisa meminimalisir penggunaan “perasaan” di dalam sistem, semisal “menurut saya sistem saya cukup stable kok, kode saya aman & maintanable”, akan lebih baik jika kita memiliki data yang akurat & signifikan daripada hanya sekedar menggunakan perasaan.
Apa manfaat yang terasa jika mau berinvestasi di software testing? Saya pribadi memiliki cerita & pengalaman tentang software testing ini sendiri.
Rekor code tests yang pernah saya buat adalah sebanyak hampir 500 test. Dari 500 test ini, sekitar 60% bisa dikatakan fail atau error, apa yang kemudian terjadi di aplikasi yang saya bangun itu ? Kondisi test menggambarkan state aplikasi saya, dengan kata lain, ya ancur 😛
Jika kita benar-benar berinvestasi di dalam software testing, akan terasa sekali manfaatnya, beberapa di antaranya adalah :
- Percaya diri ketika melakukan deploy di production (minimal tidur kita menjadi lebih nyaman)
- Setiap perubahan di dalam sistem kita, sekecil apapun itu akan segera ketahuan jika ternyata “merusak” (break) bagian lain dari sistem, hal ini bermanfaat jika kita ternyata ingin melakukan proses refactoring.
Tips software testing :
- Sabar, software testing adalah sebuah investasi, kita mungkin akan kurang merasakan manfaatnya di awal development
- Software requirement pasti akan selalu berubah, yang artinya test kita juga akan mengikuti perubahan ini
- Hindari test yang tidak berguna sama sekali, semisal: self.assertTrue(true)
- RASAP (Refactor As Soon As Possible). Melalui mekanisme Unit & Integration Test, programmer akan dituntut untuk membuat kode yang efektif dan efisien sesuai requirement, jika kita kesulitan untuk melakukan test terhadap codebase kita sendiri, itu artinya saat yang tepat untuk segera melakukan refactor.
- Selalu recheck & retest setiap perubahan yang terjadi di dalam aplikasi kita (Regression Tests)
Tips untuk tim yang ingin berinvestasi di software testing :
- Sabar
- Diskusikan dulu dengan internal tim apakah memang benar-benar ingin mengimplementasikan software testing? Terkadang walaupun kita tahu manfaat dari sesuatu hal, belum tentu kita ingin melakukannya.
- Jangan pasang target coverage yang terlalu tinggi, sesuaikan dulu dengan kemampuan per individu.
- Jangan gara-gara software testing, produk kita malah menjadi terganggu perkembangannya
- Beri bimbingan / pelatihan terhadap tim yang mungkin masih belum familiar dengan software testing
- Imperfect tests is better than no tests
Happy testing..