Melakukan Unit Testing Di Android

Walaupun sudah memakai class, aplikasi yang saya buat di artikel Belajar Memakai Implicit Intent Di Android dirancang dengan mengikuti filosofi prosedural. Ini adalah cara yang paling sering saya pakai saat mempelajari sesuatu yang baru karena dengan cara ini, saya bisa menelusuri aliran eksekusi program secara jelas. Akan tetapi, pada aplikasi nyata yang kompleks, melakukan pemisahan atau pengkategorian berdasarkan unit yang disebut class akan memberikan lebih banyak keuntungan. Salah satunya adalah aplikasi menjadi lebih mudah diuji.

Sebagai contoh, saya memisahkan beberapa fungsi yang sebelumnya tersebar di activity dan meletakkannya ke dalam sebuah domain class yang saya beri nama snake.com.myhexviewer.Viewer yang isinya seperti berikut ini:

public class Viewer {

    byte[] data

    public Viewer(byte[] data) {
        this.data = data
    }

    public int getMaxPages() {
        data? (data.length / 1024): 0
    }

    public String getHexDumpForPage(int pageNumber) {
        ... // sama seperti sebelumnya
    }

}

Di activity, saya dapat memakai kode program seperti viewer.getHexDumpForPage(progress) untuk menampilkan hasil hexdump.

Sebagai latihan, saya akan menambahkan sebuah fasilitas untuk melakukan pencarian kombinasi huruf dan angka dengan minimal jumlah karakter tertentu. Hal ini sangat berguna untuk mencari informasi yang dapat dibaca di sebuah file. Untuk itu, saya menambahkan sebuah method baru dengan nama findStrings() seperti berikut ini:

public class Viewer {

    byte[] data

    public Viewer(byte[] data) {
        this.data = data
    }

    public int getMaxPages() {
        data? (data.length / 1024): 0
    }

    public String getHexDumpForPage(int pageNumber) {
        ... // sama seperti sebelumnya
    }

    public List<String> findStrings(int minimumLength) {
        ... // kode program diabaikan
    }

}

Method findStrings() akan mengembalikan sebuah array yang berisi daftar string yang ditemukan di dalam sebuah file. Kode program yang ada didalamnya cukup rumit dan saya tidak yakin sepenuhnya bahwa kode program tersebut benar! Saya akan melakukan pendekatan trial dan error sampai saya memperoleh hasil yang diharapkan! Selama pendekatan trial dan error, saya harus bisa menguji program.

Melakukan pengujian secara manual dengan menjalankan aplikasi berulang kali akan sangat melelahkan. Apakah ada cara yang lebih singkat? Yup! Saya dapat membuat sebuah test case yang berisi skenario pengujian. Sebagai contoh, saya membuat sebuah class baru dengan nama snake.com.myhexviewer.domain.ViewerTest di folder app/src/androidTest/groovy yang isinya seperti berikut ini:

package snake.com.myhexviewer.domain

import android.app.Application
import android.test.ApplicationTestCase
import snake.com.myhexviewer.Viewer

public class ViewerTest extends ApplicationTestCase<Application> {

    Viewer viewer

    public ViewerTest() {
        super(Application)
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp()
        viewer = new Viewer([
            0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x08, 0x08,
            0x08, 0x00, 0x82, 0x50, 0x33, 0x46, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x13, 0x00, 0x00, 0x00, 0x5b, 0x43,
            0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x54,
            0x79, 0x70, 0x65, 0x73, 0x5d, 0x2e, 0x78, 0x6d,
            0x6c, 0xcd, 0x54, 0xdb, 0x6e, 0xc2, 0x30, 0x0c,
        ] as byte[])
    }

    public void testFindStrings() {
        List<String> hasil = viewer.findStrings(0)
        assertTrue(hasil.empty)

        hasil = viewer.findStrings(2)
        assertEquals(5, hasil.size())
        assertEquals(hasil[0], 'PK')
        assertEquals(hasil[1], 'P3F')
        assertEquals(hasil[2], 'Content')
        assertEquals(hasil[3], 'Types')
        assertEquals(hasil[4], 'xml')

        hasil = viewer.findStrings(3)
        assertEquals(4, hasil.size())
        assertEquals(hasil[0], 'P3F')
        assertEquals(hasil[1], 'Content')
        assertEquals(hasil[2], 'Types')
        assertEquals(hasil[3], 'xml')

        hasil = viewer.findStrings(4)
        assertEquals(2, hasil.size())
        assertEquals(hasil[0], 'Content')
        assertEquals(hasil[1], 'Types')

        hasil = viewer.findStrings(6)
        assertEquals(1, hasil.size())
        assertEquals(hasil[0], 'Content')

        hasil = viewer.findStrings(8)
        assertTrue(hasil.empty)
    }

}

Pada saat saya menulis skenario pengujian di atas, saya bahkan belum mengetik kode program yang benar di dalam method findStrings(). Ini adalah praktek yang sering disebut sebagai Test Driven Development (TDD). Landasan dari TDD adalah pandangan dimana programmer biasanya menganalisa input dan output terlebih dahulu sebelum merancang proses. Dengan menulis pengujian terlebih dahulu, programmer mencari input yang mungkin diberikan dan output yang diharapkan sebelum membuat kode program.

Berbeda dengan program Java, pengujian di Android harus dilakukan pada emulator atau perangkat. Hal ini karena program Android dijalankan pada ART (atau Dalvik) yang berbeda dari Java Virtual Machine. Sebagai contoh, semua JVM baik dari Oracle maupun pihak ketiga (seperti OpenJDK) pasti mendukung Java SE yang memiliki class GUI seperti Swing (seperti javax.swing.JButton) dan engine scripting (seperti javax.script.ScriptEngine). Akan tetapi tidak demikian halnya dengan Android SDK! Sebaliknya, Android SDK memiliki konsep activity, fragment, membatasi komunikasi dengan Parcelable dan sejenisnya yang tidak dijumpai di Java SE, Java ME maupun Java EE. Ini juga yang menjadi penyebab tuntutan Oracle kepada Google: Android dianggap menciptakan Java ‘jenis baru’ yang tidak cocok dengan Java yang sudah ada. Programmer Java sering tertipu karena mengira write once run anywhere berlaku di Android ūüėÄ

Untuk menjalankan pengujian, saya akan memilih drop down Select Run/Debug Configurations di toolbar Android Studio dan men-klik menu Edit Configurations. Saya kemudian men-klik tombol plus dan memilih JUnit seperti pada gambar berikut ini:

Menambah launch configuration baru

Menambah launch configuration baru

Saya kemudian mengisi dialog yang muncul dengan informasi seperti berikut ini:

Launch configuration untuk pengujian Android

Launch configuration untuk pengujian Android

Setelah itu, saya men-klik tombol Ok. Sekarang, saya dapat memilih untuk menjalankan aplikasi atau hanya menjalankan pengujian di Select Run/Debug Configurations, seperti yang terlihat pada gambar berikut ini:

Memilih launch configuration

Memilih launch configuration

Bila saya menjalankan pengujian, saya akan memperoleh hasil seperti pada gambar berikut ini:

Hasil pengujian yang gagal

Hasil pengujian yang gagal

Pengujian tidak sukses karena saya belum mengimplementasikan kode program dengan baik. Oleh sebab itu, saya segera melakukan implementasi kode program dan kembali melakukan pengujian. Bila pengujian gagal, saya akan memperbaiki kode program dan kembali menjalankan pengujian. Demikian seterusnya sampai saya memperoleh hasil seperti pada gambar berikut ini:

Hasil pengujian yang sukses

Hasil pengujian yang sukses

Salah satu keuntungan membuat pengujian adalah saya tidak dipusingkan dengan UI. Saya tidak perlu mengisi input dan men-klik tombol untuk men-ngetes kode program. Proses debugging juga bisa langsung dilakukan, misalnya saya bisa menghentikan eksekusi pada baris yang dikehendaki.

Sampai disini, saya bisa yakin bahwa kemungkinan besar kode program akan bekerja dengan baik. Saya hanya perlu melanjutkan ke bagian view seperti membuat fragment baru dan memikirkan bagaimana mengakses object Viewer yang sama dari beberapa fragment yang berbeda.

Menguji sebuah class yang benar-benar tidak terkait dengan Android dengan cara seperti di atas adalah sesuatu yang mubazir, bukan? Akan lebih baik bila saya bisa menjalankan pengujian memakai TestCase bawaan JUnit (tanpa harus membuat proyek baru). Berdasarkan informasi di http://tools.android.com/tech-docs/unit-testing-support, Android Studio dan Android plugin for Gradle versi 1.1 akan mendukung pengujian melalui JVM lokal tanpa melalui emulator atau perangkat. Untuk saat ini, fasilitas tersebut masih bersifat eksperimental.

Iklan

Pengujian Aplikasi Griffon Secara Otomatis Melalui VirtualBox

Salah satu istilah yang diperkenalkan oleh metode pengembangan Extreme Programming (XP) adalah continuous integration, continuous delivery, dan continuous deployment. Penggunaan kata continuous disini menunjukkan semangat XP dimana hasil harus bisa diperoleh secepat mungkin.

Continuous integration (CI) adalah pengujian yang dilakukan secara otomatis setiap kali developer men-push perubahan yang mereka lakukan. Karena CI dilakukan secara otomatis atau berkala, perubahan kode program yang menyebabkan aplikasi tidak dapat berjalan akan diketahui secepat mungkin.

Continous delivery (CD) memastikan bahwa kode program yang telah dibuat dapat segera dijalankan di production bila dibutuhkan. Dengan adanya CD, bila klien meminta demo aplikasi terbaru atau tampilan web terbaru secara tiba-tiba pada hari itu juga, tidak akan ada yang panik.

Continous deployment adalah continous delivery yang direalisasikan secara berkala dan otomatis ke production, misalnya pengguna memperoleh update terbaru setiap hari atau halaman web diperbaharui setiap minggu. Continuous delivery hanya menyatakan sebuah kesiapan tapi continuous deployment adalah realisasi dari continuous delivery secara rutin.

Kata kunci dari semua yang berbau continuous disini adalah otomatis, terstruktur, dan berkala. Oleh sebab itu, ada banyak tools pihak ketiga seperti Travis CI, Jenkins CI, Vagrant, Packer, Chef dan sebagainya yang dapat dipakai untuk mewujudkan berbagai continuous tersebut.

Salah satu fasilitas menarik yang saya temui dari Travis CI adalah kemampuan untuk menguji kode program di beberapa virtual machine berbeda (misalnya versi Java berbeda atau sistem operasi berbeda). Travis kemudian akan melaporkan hasil pengujian dalam bentuk matrix. Jenkins CI juga memungkinkan hal serupa dengan memakai plugin Vagrant. Vagrant adalah sebuah tools untuk mengelola virtual machine (seperti menyalakan, mematikan, memberi perintah, dan sebagainya) secara standar dan dilengkapi dukungan VirtualBox secara bawaan. Selain itu, Vagrant juga memiliki banyak image virtual machine siap pakai di Vagrant Cloud (https://vagrantcloud.com).

Pada artikel ini, saya tidak akan menerapkan semua jenis continuous pada XP (dan sesungguhnya saya bukan fans XP ūüôā ). Saya juga tidak akan memakai Vagrant. Saya hanya ingin menjalankan pengujian aplikasi Griffon di VirtualBox secara otomatis. Untuk itu, saya menyiapkan 2 image yang mewakili Windows 7 dan Linux Mint. VirtualBox dapat dikendalikan dengan menggunakan COM API. Namun, ia juga menyediakan beberapa front-end lain yang lebih mudah dipakai seperti web services (SOAP) dan perintah VBoxManage. Pada kesempatan ini, saya akan mencoba mengendalikan VirtualBox dengan menggunakan VBoxManage yang dipanggil melalui CLI.

Langkah pertama yang saya lakukan adalah membuat sebuah perintah baru yang dapat dipakai di aplikasi Griffon dengan memberikan perintah berikut ini:

C:\proyek> griffon create-script vbox-test

Perintah dalam Griffon adalah sebuah Gant script. Mungkin ini tidak akan berlaku lagi setelah Griffon 2 yang mendukung Gradle dirilis. Saya akan menemukan sebuah file baru bernama VboxTest.groovy di folder scripts. Saya kemudian mengubah isi script tersebut menjadi seperti berikut ini:

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

def hasil = [:]

def tambahGagal = { String vBoxImage, def jumlahGagal ->
    if (!hasil.containsKey(vBoxImage)) {
        hasil[vBoxImage] = jumlahGagal
    } else {
        hasil[vBoxImage] = hasil[vBoxImage] + jumlahGagal
    }
}

def exec = { String command, String vBoxImage, boolean silentFail, Object... args ->
    List commands = [buildConfig.vbox.vboxManage, command, vBoxImage]
    args?.each { arg ->
        if (arg instanceof Map) {
            arg.each { k, v ->
                commands << ("--" + k)
                commands << v
            }
        } else {
            commands << arg as String
        }
    }
    ProcessBuilder builder = new ProcessBuilder(commands)
    builder.redirectErrorStream(true)
    if (!silentFail) {
        println "Mengerjakan ${commands}"
    }
    Process p = builder.start()
    if (silentFail) {
        p.waitForOrKill(10000000)
    } else {
        p.waitForProcessOutput(System.out, System.out)
        if (p.exitValue() != 0) {
            throw new RuntimeException("Gagal mengerjakan perintah. Exit code: ${p.exitValue()}")
        }
    }
    p.exitValue()
}

def execute = { String vBoxImage, String image, def config, def args = [] ->
    def p = [image: image, username: config.username]
    if (config.containsKey('password')) p.password = config.password
    exec('guestcontrol', vBoxImage, false, 'execute', p, '--wait-exit', '--wait-stdout', '--wait-stderr', *args)
}

def executeSilent = { String vBoxImage, String image, def config, def args = [] ->
    def p = [image: image, username: config.username]
    if (config.containsKey('password')) p.password = config.password
    exec('guestcontrol', vBoxImage, true, 'execute', p, '--wait-exit', '--wait-stdout', '--wait-stderr', *args)
}

def copyTo = { String vBoxImage, def source, def dest, def config ->
    def p = ['username': config.username]
    if (config.containsKey('password')) p.password = config.password
    String strSource = source instanceof Path? source.toAbsolutePath().toString(): source
    String strDest = dest instanceof Path? dest.toAbsolutePath().toString(): dest
    exec('guestcontrol', vBoxImage, false, 'copyto', strSource, strDest, p)
}

def copyFrom = { String vBoxImage, def source, def dest, def config ->
    def p = ['username': config.username]
    if (config.containsKey('password')) p.password = config.password
    String strSource = source instanceof Path? source.toAbsolutePath().toString(): source
    String strDest = dest instanceof Path? dest.toAbsolutePath().toString(): dest
    exec('guestcontrol', vBoxImage, false, 'copyFrom', strSource, strDest, p)
}

def snapshotRestore = { String vBoxImage, def config ->
    exec('controlvm', vBoxImage, false, 'poweroff')
    exec('snapshot', vBoxImage, false, 'restorecurrent')
}

def concat = { String path1, String path2, String separator ->
    if (path1.endsWith(separator)) {
        return path1 + path2
    } else {
        return path1 + separator + path2
    }
}

def proses = {String vBoxImage, def config  ->
    def separator = (config.os == 'windows')? '\': '/'

    exec('startvm', vBoxImage, false, [type: 'headless'])

    // menunggu sistem operasi guest siap
    print "Menunggu virtual machine siap  "
    int exitCode = -1
    while (exitCode != 0) {
        exitCode = executeSilent(vBoxImage, config.testAliveCmd, config)
        print "."
        sleep(5000)
    }
    println ""
    println "Virtual machine sudah siap"

    // membuat file ZIP untuk di-copy ke host
    println "Membuat file ZIP untuk dipindahkan ke guest"
    def baseDir = griffonSettings.baseDir.path
    Path tempZip = Files.createTempDirectory("griffonvboxtemp").resolve('project.zip')
    ant.zip(destFile: tempZip.toAbsolutePath().toString(), basedir: baseDir)
    tempZip.toFile().deleteOnExit()

    // mencopy file project ke host
    println "Mencopy file ZIP ke guest"
    if (config.os == 'windows') {
        copyTo(vBoxImage, tempZip, config.targetDir, config)
    } else {
        copyTo(vBoxImage, tempZip, config.targetDir + separator + 'project.zip', config)
    }

    // men-extract file ZIP di host
    println "Men-extract file ZIP di guest"
    def targetZip = concat(config.targetDir, 'project.zip', separator)
    def targetExtractZip = concat(config.targetDir, 'project', separator)
    execute(vBoxImage, config.sevenZip, config, ['--', 'x', targetZip, '-y', '-o' + targetExtractZip])

    // mengerjakan test-app
    println "Mengerjakan test-app"
    try {
        def envs = ""GRIFFON_HOME=${config.griffonHome} JAVA_HOME=${config.javaHome}"".toString()
        execute(vBoxImage, config.griffonExec, config, ['--environment', envs, '--',
                                                        '-Dbase.dir=' + targetExtractZip, 'test-app'])
    } catch (RuntimeException ex) {
        println "PENTING: Pengujian untuk proyek ini gagal!"
        tambahGagal(vBoxImage, 1)
    }

    // mengambil hasil pengujian di guest di folder targettest-reportsTESTS-TestSuites.xml
    println "Men-copy file hasil pengujian dari guest"
    def lokasiTestReports = Paths.get(baseDir, 'target', 'vbox-test-reports')
    if (!lokasiTestReports.toFile().exists()) lokasiTestReports.toFile().mkdirs()
    def dest = lokasiTestReports.resolve("TESTS-$vBoxImage-TestSuites-${new Date().format('yyyyMMddhhmm')}.xml")
    def source = concat(targetExtractZip, 'target' + separator + 'test-reports' + separator + 'TESTS-TestSuites.xml', separator)
    copyFrom(vBoxImage, source, dest, config)
    println "File $dest berhasil dibuat"

    // membaca hasil pengujian
    def testsuites = new XmlSlurper().parse(dest.toFile())
    testsuites.testsuite.each { node ->
        if (node.@errors.toInteger() > 0) {
            tambahGagal(vBoxImage, node.@errors.toInteger())
        }
        if (node.@failures.toInteger() > 0) {
            tambahGagal(vBoxImage, node.@failures.toInteger())
        }
    }

}

target(name: 'vboxtest', description: "Run test in VirtualBox", prehook: null, posthook: null) {

    buildConfig.vbox.images.each { k, v ->
        proses(k,v)

        // rollback ke state terakhir
        println "Mengembalikan snapshot seperti semula"
        snapshotRestore(k, v)
    }

    println "nHasil pengujian:"
    println '-'*40
    printf "%-30s %5sn", 'VM Image', 'Gagal'
    println '-'*40
    hasil.each { k,v ->
        printf "%-30s %5dn", k, v
    }
    println '-'*40
    println "nSelesai.n"

}

setDefaultTarget('vboxtest')

Pada script yang saya buat di atas, saya memiliki asumsi bahwa Java, Griffon dan database yang dibutuhkan telah ter-install dimasing-masing image. Saya perlu mendaftarkan setiap pengaturan yang berkaitan dengan masing-masing image dengan menuliskannya di file BuildConfig.groovy. Sebagai contoh, saya menambahkan baris berikut ini pada BuildConfig.groovy:

vbox {

    vboxManage = 'C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe'

    images {
        'Windows 7 Test' {
            os = 'windows'
            username = 'Tester'
            testAliveCmd = 'C:\\Windows\\System32\\ipconfig.exe'
            targetDir = 'C:\\Users\\Tester\\Desktop\\'
            sevenZip = 'C:\\Progra~1\\7-Zip\\7z.exe'
            griffonExec = 'C:\\Progra~1\\Griffon-1.5.0\\bin\\griffon.bat'
            griffonHome = 'C:\\Progra~1\\Griffon-1.5.0'
            javaHome = 'C:\\Progra~1\\Java\\jdk1.7.0_21'
        }
        'Linux Mint Test' {
            os = 'linux'
            username = 'tester'
            password = 'toor'
            testAliveCmd = '/bin/ls'
            targetDir = '/home/tester/Desktop'
            sevenZip = '/usr/bin/7z'
            griffonExec = '/home/tester/griffon-1.5.0/bin/griffon'
            griffonHome = '/home/tester/griffon-1.5.0'
            javaHome = '/usr/lib/jvm/java-7-openjdk-i386'
        }
    }
}

Konfigurasi di atas juga memungkinkan sebuah image yang sama dijalankan untuk diuji lebih dari sekali, misalnya dengan nilai griffonHome dan/atau javaHome berbeda.

Virtual machine akan dijalankan dalam modus headless dimana tidak ada layar UI sehingga pengguna tidak bisa berinteraksi dengannya secara langsung. Untuk mengendalikan virtual machine yang berada dalam keadaan headless seperti ini, saya akan memanggil VBoxManage.exe pada script yang saya buat.

Perintah guestcontrol pada VBoxManage.exe sangat bergantung pada Guest Additions. Salah satu penyebab pesan kesalahan random seperti Session is not in started state adalah versi Guest Additions yang sudah kadaluarsa. Untuk men-install versi Guest Additions terbaru, saya dapat memilih menu Devices, Install Guest Additions CD image…. Versi Guest Additions bisa berbeda karena saat saya men-upgrade VirtualBox ke versi terbaru, masing-masing image tetap memakai Guest Additions versi lama (yang harus di-upgrade secara manual).

Saya tidak bisa langsung memberikan perintah pada virtual machine yang baru dinyalakan. Oleh sebab itu, terdapat nilai testAliveCmd di konfigurasi yang berisi sebuah perintah yang dapat dipakai untuk menguji apakah proses startup sistem operasi virtual sudah selesai atau belum. Bila perintah ini sukses dikerjakan, maka saya bisa segera lanjut mengirim file zip berisi kode program proyek ke virtual machine dan meng-extract file tersebut di dalam virtual machine. Saya mengandaikan bahwa 7zip telah ter-install di guest. 7zip biasanya sudah ada dalam distro Linux populer, tetapi harus di-install terpisah di Windows.

Setelah itu, di dalam virtual machine, saya mengerjakan perintah test-app dari Griffon untuk menguji aplikasi. Setelah pengujian selesai, saya mengambil file XML yang dihasilkan dan meletakkannya di folder target/vbox-test-reports milik proyek di host (proyek yang menjalankan pengujian bukan yang diuji). Tidak lupa saya memberikan perintah agar virtual machine dikembalikan seperti pada snapsnot semula. Disini saya mengasumsikan bahwa virtual machine telah diberi snapshot sebelumnya. Dengan demikian, setiap kali saya menjalankan pengujian, sistem operasi virtual akan selalu berada dalam kondisi yang sama. Ini adalah salah satu kelebihan melakukan pengujian pada virtual machine.

Untuk menjalankan script di atas, saya akan memberikan perintah berikut ini:

C:\proyek> griffon vbox-test
...
Waiting for VM "Windows 7 Test" to power on...
VM "Windows 7 Test" has been successfully started.
Menunggu virtual machine siap  .
Virtual machine sudah siap
Membuat file ZIP untuk dipindahkan ke guest
...
Mencopy file ZIP ke guest
...
Men-extract file ZIP di guest
...
Mengerjakan test-app
...
Men-copy file hasil pengujian dari guest
...
Mengembalikan snapshot seperti semula
...
Waiting for VM "Linux Mint Test" to power on...
VM "Linux Mint Test" has been successfully started.
Menunggu virtual machine siap  .
Virtual machine sudah siap
Membuat file ZIP untuk dipindahkan ke guest
...
Mencopy file ZIP ke guest
...
Men-extract file ZIP di guest
...
Mengerjakan test-app
...
Men-copy file hasil pengujian dari guest
...
Mengembalikan snapshot seperti semula
...

Hasil pengujian:
----------------------------------------
VM Image                       Gagal
----------------------------------------
Windows 7 Test                    12
Linux Mint Test                   12
----------------------------------------

Selesai.

Perintah di atas menunjukkan ada 12 pengujian yang gagal, baik di Windows mapun di Linux. Untuk mendapatkan informasi lebih lanjut, saya dapat membaca file XML hasil pengujian yang terletak di folder target/vbox-test-reports seperti yang terlihat pada gambar berikut ini:

Informasi hasil pengujian di setiap image

Informasi hasil pengujian di setiap image

Apa Manfaat Facade Di Laravel?

Salah satu pertanyaan yang paling sering ditanyakan oleh mereka yang baru mencoba Laravel adalah apa gunanya facade? Seperti yang diketahui, facade adalah sebuah fitur dari Laravel yang mengimplementasikan facade pattern (http://en.wikipedia.org/wiki/Facade_pattern). Pada facade pattern, terdapat sebuah class yang disebut facade class yang akan mengakses class lain. Class pengguna (client) tidak akan mengakses class yang di-‘facade’ secara langsung melainkan mengaksesnya melalui facade class. Tujuan dari pattern ini adalah untuk menyembunyikan kerumitan.

Pada Laravel, facade diimplementasikan dalam bentuk sebuah class dengan method static yang bila dipanggil akan memanggil method di object lain yang di-‘facade’ olehnya. Sebagai contoh facade Log::debug() akan memanggil method debug() dari sebuah objek Monolog\Logger di IoC Container. Dalam hal ini, facade dapat dianggap sebagai cara singkat dalam mengakses sebuah object di container.

Tapi facade memiliki peran penting lainnya yang berkaitan dengan unit testing (atau tepatnya integration testing atau functional testing). Untuk menunjukkannya, saya akan membuat sebuah functional test yang isinya seperti berikut ini:

<?php

class PelangganControllerTest extends TestCase {

    public function testTambahSukses() {
        // Mengirim semua data yang dibutuhkan untuk disimpan
        $response = $this->call('GET', 'pelanggan/tambah',
            ['email'=>'solid@snake.com', 'nama'=>'Solid Snake']);

        $this->assertJson($response->getContent());
        $this->assertEquals('solid@snake.com', $response->getData()->email);
    }

    public function testTambahGagal() {
        // Hanya mengirim 'nama' tanpa 'email'
        $response = $this->call('GET', 'pelanggan/tambah',
            ['nama'=>'Solid Snake']);        

        $this->assertJson($response->getContent());
        $this->assertEquals('Email tidak boleh kosong', $response->getData()->kesalahan);
    }

}

?>

Seperti biasa, bila saya menjalankan unit test di atas, saya akan memperoleh pesan kesalahan karena saya belum membuat controller yang bersangkutan. Untuk itu, saya segera membuat controller dengan nama PelangganController.php di direktori app/controllers yang isinya seperti berikut ini:

<?php

class PelangganController extends BaseController {

    public function getTambah()
    {       
        $email = Input::get('email');
        $nama = Input::get('nama');
        if (empty($email)) {
            $hasil['kesalahan'] = 'Email tidak boleh kosong';
        } else {
            mail($email, 'Registrasi', "Selamat, $email.  Anda sudah terdaftar!", "From:me");
            $hasil['email'] = $email;
        }
        return Response::json($hasil);
    }

}

?>

Pada kode program di atas, bila pengguna mengisi alamat email, maka kode tersebut akan mengirim pesan ke alamat email yang bersangkutan melalui function mail() dari PHP. Agar controller di atas dapat dipakai, saya akan mendaftarkannya di routes.php dengan menambahkan kode program berikut ini:

Route::controller('pelanggan', 'PelangganController');

Setelah ini, bila saya menjalankan unit test, saya akan menemukan sebuah kegagalan seperti yang terlihat pada gambar berikut ini:

Test yang gagal

Test yang gagal

Hal ini terjadi karena saya memanggil mail() dari PHP dan saya tidak men-setup mail server di komputer lokal. Walaupun seandainya saya sudah men-setup mail server, hal ini tetap jadi masalah karena setiap kali saya menjalankan unit test, ia akan berusaha mengirim email yang sesungguhnya.

Untuk mengatasi masalah seperti ini, saya dapat mengubah kode program di atas agar tidak memanggil mail() secara langsung, melainkan memanggil facade yang telah disediakan oleh Laravel, yaitu Mail. Oleh sebab itu, saya mengubah kode program PelangganController.php menjadi seperti berikut ini:

<?php

class PelangganController extends BaseController {

    public function getTambah()
    {       
        $email = Input::get('email');
        $nama = Input::get('nama');
        if (empty($email)) {
            $hasil['kesalahan'] = 'Email tidak boleh kosong';
        } else {
            Mail::send('emails.registrasi', ['email'=>$email], function($message) use ($email) {
                $message->from('me@snake.com', 'Solid Snake');
                $message->to($email)->subject('Registrasi!');
            });         
            $hasil['email'] = $email;
        }
        return Response::json($hasil);
    }

}

?>

Class Mail adalah facade ke sebuah objek Illuminate\Mail\Mailer. Perhatikan bahwa saya memakai class secara static tapi sebenarnya ada sebuah object yang saya akses yaitu instance dari Illuminate\Mail\Mailer. Object tersebut dibuat oleh Illuminate\Mail\MailServiceProvider dengan perintah seperti new Mailer().

Bila saya menjalankan pengujian, saya tetap akan memperoleh pesan kesalahan. Lalu, apa bedanya? Seluruh facade di Laravel dilengkapi dengan kemampuan mocking dengan menggunakan Mockery, misalnya melalui method shouldReceive() dan sebagainya. Tapi karena saya tidak memakai Mockery, saya bisa memakai fasilitas mocking dari PHPUnit. Sebagai contoh, saya mengubah kode program pengujian menjadi seperti berikut ini:

<?php

class PelangganControllerTest extends TestCase {

    public function testTambahSukses() {
        // Mocking dari Mailer
        $mailer = $this->getMock('Mailer', array('send'));
        $mailer->expects($this->once())->method('send');

        // Mendaftarkan hasil mock ke facade
        $mailer = Mail::swap($mailer);

        // Mengirim semua data yang dibutuhkan untuk disimpan
        $response = $this->call('GET', 'pelanggan/tambah',
            ['email'=>'solid@snake.com', 'nama'=>'Solid Snake']);

        // Mengembalikan facade seperti semula
        $mailer = Mail::swap($mailer);          

        // Pengujian
        $this->assertJson($response->getContent());
        $this->assertEquals('solid@snake.com', $response->getData()->email);     
    }

    public function testTambahGagal() {
        // Hanya mengirim 'nama' tanpa 'email'
        $response = $this->call('GET', 'pelanggan/tambah',
            ['nama'=>'Solid Snake']);        

        $this->assertJson($response->getContent());
        $this->assertEquals('Email tidak boleh kosong', $response->getData()->kesalahan);
    }

}

?>

Mock adalah sebuah class ‘palsu’ yang menyerupai aslinya. Dengan class ‘palsu’ ini, saya bisa melakukan assertions seperti memastikan ada method tertentu yang dipanggil tanpa harus benar-benar memanggil method bersangkutan. Sebagai contoh, saya membuat mock dari Illuminate\Mail\Mailer dan memastikan bahwa method send() akan dipanggil sekali. Karena ini hanya class ‘palsu’, tidak ada proses pengiriman email yang akan terjadi, hanya saja saya menyakinkan diri bahwa method send() akan dipanggil.

Sekarang, bila saya menjalankan pengujian, saya akan memperoleh hasil sukses seperti pada gambar berikut ini:

Hasil pengujian yang sukses

Hasil pengujian yang sukses

Terlihat bahwa facade mempermudah pengujian bila dibandingkan dengan saya memanggil fungsi yang diharapkan langsung pada kode program.

Sebagai informasi tambahan, khusus untuk Mail, sebenarnya Laravel sudah memiliki cara yang lebih singkat dan lebih mudah untuk tidak benar-benar mengirim email. Saya bisa mengubah file konfigurasi di app/config/testing/mail.php (atau men-copy-nya bila belum ada) dimana nilai 'pretend' di-set menjadi true, seperti pada contoh berikut ini:

<?php

return array(

    ...

    'pretend' => true,

);

Melakukan Unit Test Di Laravel

Satu hal yang membuat saya sulit beradaptasi dengan Eloquent (data mapper milik Laravel) adalah relasi seperti one-to-many di-hard code langsung dalam bentuk pemanggilan function. Pada JPA di dunia Java, relasi one-to-many tetap sebuah Collection biasa yang memiliki getter dan setter. Implementasi JPA akan menggunakan proxy (method yang dipanggil secara transparan tanpa sepengetahuan pengguna) atau bytecode generation untuk melakukan fetching. Dengan demikian, model di JPA (tepatnya entity) akan terlihat seperti sebuah class normal yang dapat diuji secara biasa. Tapi, pada Eloquent, pengujian model yang memiliki relasi dengan model lainnya (seperti one-to-many) akan melibatkan database.

Sebagai contoh, berikut ini adalah contoh definisi model yang diwakili class Faktur:

<?php

class Faktur extends Eloquent {

    protected $dates = array('tanggal');

}

?>

Class Pelanggan akan memiliki relasi one-to-many dengan Faktur dimana faktur yang belum dilunasi oleh pelanggan bisa diakses melalui Pelanggan. Oleh sebab itu, saya membuat class Pelanggan yang isinya seperti berikut ini:

<?php

class Pelanggan extends Eloquent {

    public function fakturBelumLunas() {
        return $this->hasMany('Faktur');
    }

    public function sisaPiutang() {
        $sisa = 0;
        foreach($this->fakturBelumLunas as $faktur) {
            $sisa += $faktur->total;
        }
        return $sisa;
    }

}

?>

Berikut ini adalah contoh test case yang valid untuk melakukan pengujian yang harus terhubung ke database:

<?php

use CarbonCarbon;

class PelangganTest extends TestCase {

    public function testSisaPiutang() {
        $pelanggan = new Pelanggan();
        $pelanggan->nama = 'Test';
        $pelanggan->save();

        $faktur1 = new Faktur();
        $faktur1->nomor = 'FA-01';
        $faktur1->tanggal = Carbon::createFromDate(2014,05,01);
        $faktur1->total = 100000;
        $pelanggan->fakturBelumLunas()->save($faktur1);

        $faktur2 = new Faktur();
        $faktur2->nomor = 'FA-02';
        $faktur2->tanggal = Carbon::createFromDate(2014,05,02);      
        $faktur2->total = 200000;
        $pelanggan->fakturBelumLunas()->save($faktur2);

        $this->assertEquals(300000, $pelanggan->sisaPiutang());       

    }

}

?>

Masalah timbul karena fakturBelumLunas() mengembalikan Illuminate\Database\Eloquent\Relations\HasMany yang mewakili sebuah query. Domain class sudah tercemari oleh database! Itu adalah resiko memakai data mapper yang menerapkan active record pattern seperti Eloquent, Grails, Active Record (Ruby on Rails), dan sejenisnya. Pola active record, walaupun mudah dipakai, secara tidak langsung sudah mencemari domain class dengan method infrastruktur yang bukan bagian dari business logic (termasuk save(), delete(), dan sebagainya). Saya lebih senang memakai pendekatan seperti yang saya lakukan pada simple-jpa dimana saya menambahkan ‘method ajaib’ hanya pada repository bukan pada domain class sehingga domain class benar-benar ‘polos’.

Untuk membuktikan bahwa pengujian ini harus terhubung ke database, saya akan menjalankan test case setelah mematikan database. Saya akan menjumpai kesalahan seperti yang terlihat pada gambar berikut ini:

Pengujian gagal bila tidak terkoneksi ke database

Pengujian gagal bila tidak terkoneksi ke database

Salah satu masalah dalam pengujian yang saya lakukan adalah isi tabel di database akan selalu bertambah akibat pemanggilan save(). Isi database bisa tercemar. Untuk mengatasi hal tersebut, saya perlu memanggil perintah Artisan migrate:refresh. Agar lebih otomatis, saya dapat memanggil perintah Artisan melalui Artisan::call(). Pada PHPUnit, saya dapat meletakkan kode program yang akan selalu dikerjakan sebelum test dimulai pada method setUp() sehingga kode program saya terlihat seperti:

<?php

use CarbonCarbon;

class PelangganTest extends TestCase {

    public function setUp() {
        parent::setUp();
        Artisan::call('migrate:refresh');
    }

    public function testSisaPiutang() {                 
        $pelanggan = new Pelanggan();
        $pelanggan->nama = 'Test';
        $pelanggan->save();

        $faktur1 = new Faktur();
        $faktur1->nomor = 'FA-01';
        $faktur1->tanggal = Carbon::createFromDate(2014,05,01);
        $faktur1->total = 100000;
        $pelanggan->fakturBelumLunas()->save($faktur1);

        $faktur2 = new Faktur();
        $faktur2->nomor = 'FA-02';
        $faktur2->tanggal = Carbon::createFromDate(2014,05,02);
        $faktur2->total = 200000;
        $pelanggan->fakturBelumLunas()->save($faktur2);

        $this->assertEquals(300000, $pelanggan->sisaPiutang());       
    }


}

?>

Sekarang, setiap kali menjalankan pengujian, isi database akan selalu dikosongkan terlebih dahulu sehingga pengujian akan dilakukan pada kondisi database yang konsisten.

Dalam banyak kasus, test case sebaiknya tidak menyentuh database production secara langsung, melainkan database lokal atau database in-memory. Salah satu alasan untuk memakai database yang sama di lokal (misalnya sama-sama MySQL) adalah bisa dipastikan bahwa environment-nya sama persis. Dengan demikian, tidak mungkin ada masalah SQL yang jalan di lokal tapi tidak jalan di production. Kelemahannya adalah penggunaan database seperti MySQL membuat pengujian menjadi lambat. Padahal pengujian adalah sesuatu yang sangat sering dilakukan. Untuk mengatasi hal tersebut, saya dapat menggunakan database in-memory. Sebagai contoh, database SQLite dapat dipakai sebagai database in-memory.

Untuk memakai database SQLite sebagai database in-memory hanya pada pengujian, saya perlu men-copy file database.php pada lokasi app/config/testing seperti yang terlihat pada gambar berikut ini:

Konfigurasi untuk enviroment testing

Konfigurasi untuk enviroment testing

Sama seperti di Griffon dan framework populer lainnya, Laravel juga memiliki konsep environment pada konfigurasinya. Masing-masing subdirektori pada direktori config akan mewakili sebuah environment. Laravel akan membaca file konfigurasi sesuai dengan environment yang sedang aktif. Salah satu environment bawaan Laravel yang hanya akan aktif pada saat pengujian adalah testing. Dengan demikian, saat melakukan koneksi database di pengujian, file konfigurasi yang dibaca bukan app/config/database.php melainkan app/config/testing/database.php. Saya akan mengubah isi file tersebut menjadi:

<?php

return array(

    'fetch' => PDO::FETCH_CLASS,

    'default' => 'sqlite',

    'connections' => array(

        'sqlite' => array(
            'driver'   => 'sqlite',
            'database' => ':memory:',
            'prefix'   => '',
        ),

    ),

);

?>

Selain itu, saya juga mengubah Artisan::call('migrate:refresh') menjadi Artisan::call('migrate') di PelangganTest.php. Saya tidak perlu menghapus tabel secara manual karena pada database in-memory, setelah selesai dikerjakan, semuanya akan hilang.

Sampai disini, bila saya menjalankan pengujian, maka database yang dipakai adalah database SQLite sebagai database in-memory. Tapi sebenarnya, untuk kasus saya, karena pada kode program di atas saya memakai dynamic properties dalam bentuk Illuminate\Database\Eloquent\Collection, saya bisa menguji tanpa harus terhubung ke database. Cara ini tidak bisa diterapkan bila saya memanggil method secara langsung seperti $this->fakturBelumLunas()->getResults() di kode program diuji. Sebagai contoh, saya bisa mengubah test case menjadi seperti berikut ini:

<?php

use IlluminateDatabaseEloquentCollection;

class PelangganTest extends TestCase {

    public function testSisaPiutang() {     

        $faktur1 = new Faktur();
        $faktur1->nomor = 'FA-01';     
        $faktur1->total = 100000;        

        $faktur2 = new Faktur();
        $faktur2->nomor = 'FA-02';         
        $faktur2->total = 200000;

        $fakturs = new Collection([$faktur1, $faktur2]);    
        $pelanggan = new Pelanggan();   
        $pelanggan->fakturBelumLunas = $fakturs;                         

        $this->assertEquals(300000, $pelanggan->sisaPiutang());               

    }

}

?>

Bila saya menjalankan pengujian di atas, saya akan memperoleh hasil sukses tanpa harus memakai database sama sekali seperti yang terlihat pada gambar berikut ini:

Pengujian sukses tanpa harus terhubung ke database

Pengujian sukses tanpa harus terhubung ke database

Unit Testing Di JavaScript Dengan QUnit

Pada artikel ini, saya akan mencoba menggunakan QUnit untuk melakukan unit testing pada kode program JavaScript. Berbeda dengan Java dimana unit adalah sebuah class, pada JavaScript, unit adalah sebuah function. Salah satu tool yang dapat dipakai untuk melakukan unit testing pada JavaScript adalah QUnit. Tool ini juga dipakai untuk melakukan pengujian pada proyek jQuery dan jQuery UI. Untuk memakai QUnit, saya dapat men-download-nya di http://qunitjs.com.

Sebagai latihan, saya akan membuat sebuah library sederhana yang mengimplementasikan fungsi require() seperti pada spesifikasi module oleh CommonJS. JavaScript (sebelum ES6) tidak memiliki konsep module. Oleh sebab itu, developer JavaScript seringkali mengalami masalah berkaitan dengan penggunaan banyak library berbeda dan kesalahan penamaan yang sama. Pada Java, hal ini diatasi melalui konsep package dan keyword import. ECMAScript 6 nanti juga akan memperkenalkan konsep module pada JavaScript. Saat ini, untuk sementara ada beberapa spesifikasi yang dapat dipakai dalam mengatur kode program JavaScript agar lebih standar dalam mengelola module. Salah satunya adalah yang dibuat oleh CommonJS (http://www.commonjs.org/specs/modules/1.0/). Yang ditawarkan adalah koleksi design pattern yang bisa diterapkan oleh setiap developer JavaScript. Karena ini hanya latihan, saya tidak akan membuat sesuatu yang mengikuti seluruh yang ada di spesifikasi (selain itu, implementasi CommonJS sudah banyak, salah satunya adalah Node.js).

Kali ini saya akan membuat unit test dengan mengikuti filosofi test driven development (TDD). Dengan TDD, saya mulai dengan membuat test case terlebih dahulu baru kemudian membuat implementasi kode programnya. Yup! Membuat unit test dulu baru membuat kode program! Saya pernah mendengar cerit bahwa seorang dosen S2 yang sedang mendidik dosen S1 guna sertifikasi mengatakan bahwa harus ada GUI (tampilan atau output) terlebih dahulu baru bisa menguji kode program. Tentu saja ia salah. Lalu, bagaimana cara mengujinya?

Saya akan mulai dengan membuat test case yang mengikuti spesifikasi CommonJS dengan membuat 2 file JavaScript yang mewakili module, yaitu moduleA.js dan moduleB.js. Isi dari moduleA.js adalah:

exports.foo = require("moduleB").foo;

Sementara itu, isi dari moduleB.js adalah:

exports.foo = function() {};

Pada spesifikasi CommonJS, fungsi require() dipakai untuk menyertakan modul lain. Sebuah modul yang ingin men-export function atau nilai agar dapat dipakai oleh modul lainnya harus mengisinya pada exports yang selalu tersedia. Pada contoh di atas, moduleA akan memakai moduleB dimana function foo() dari moduleA persis sama seperti function foo() dari moduleB.

Berikutnya, saya akan membuat sebuah halaman HTML sederhana yang mewakili sebuah test case dengan nama test.html yang isinya seperti berikut ini:

<!doctype html>
<html>
<head>
    <title>Latihan QUnit</title>
    <link rel="stylesheet" href="qunit-1.14.0.css" />
    <script src="qunit-1.14.0.js"></script>
    <script src="myrequire.js"></script>
    <script>
        test("Menguji module", function() {
            var moduleA = require("moduleA");
            var moduleB = require("moduleB");
            ok(moduleA!=undefined, "moduleA harus punya nilai");
            ok(moduleB!=undefined, "moduleB harus punya nilai");
        });
        test("Menguji hasil export di module", function() {
            var moduleA = require("moduleA");
            var moduleB = require("moduleB");
            ok(moduleA.foo!=undefined, "moduleA.foo harus ada");          
            ok(moduleB.foo!=undefined, "moduleB.foo harus ada");
        });
        test("Menguji function yang di-export", function() {
            var moduleA = require("moduleA");
            var moduleB = require("moduleB");     
            equal(moduleA.foo, moduleB.foo);
        });
    </script>
</head>
<body>
    <div id="qunit"></div>
</body>
</html>

Bila saya menjalankan HTML di atas, saya akan menemui tampilan seperti pada gambar berikut ini:

Hasil awal pengujian menunjukkan semua test gagal

Hasil awal pengujian menunjukkan semua test gagal

Jangan kaget! Di TDD, pada awalnya seluruh test akan gagal. Tujuan dari TDD adalah bagaimana membuat test yang gagal tersebut menjadi benar dan sukses (ini adalah arti kata test-driven dimana pemicu pembuatan kode program adalah test case). Progress atau perkembangan proyek juga bisa dilihat berdasarkan jumlah test yang sukses atau gagal.

Sebagai langkah berikutnya, saya akan membuat sebuah file JavaScript baru bernama myrequire.js yang mewakili kode program yang hendak diuji dengan isi seperti berikut ini:

(function(global) {
    function require(moduleId) {            
        var module = {};
        return module;
    }       

    global.require = require;
}(this));

Saya kemudian menambahkan baris berikut ini pada test.html:

<script src="myrequire.js"></script>

Sekarang, bila saya menampilkan file tersebut pada browser, akan ada 1 test case yang sukses, namun yang lainnya masih gagal, seperti yang terlihat pada gambar berikut ini:

Hasil pengujian menunjukkan test case pertama sudah sukses

Hasil pengujian menunjukkan test case pertama sudah sukses

Saya kembali melakukan perubahan di myrequire.js agar test case berikutnya menjadi sukses. Sebagai contoh, saya mengubahnya menjadi seperti berikut ini:

(function(global) {
    function require(moduleId) {            
        var module = {};
        module.foo = function() {};
        return module;
    }       

    global.require = require;
}(this));

Sekarang, bila saya menjalankan pengujian QUnit, saya akan memperoleh hasil seperti pada gambar berikut ini:

Hasil pengujian menunjukkan test case pertama dan kedua sudah sukses

Hasil pengujian menunjukkan test case pertama dan kedua sudah sukses

Sudah semakin baik bukan? Hanya saja, agar semua test sukses, kode program saya perlu membaca file JavaScript yang mewakili module. Untuk itu, saya perlu memperkenalkan sebuah function baru yang membaca file JavaScript dengan menggunakan XHR. Seperti biasa, pada TDD, sebelum membuat kode program, saya terlebih dahulu membuat test case-nya. Untuk itu, saya membuat file testBacaFile.html yang isinya seperti berikut ini:

<!doctype html>
<html>
<head>
    <title>Latihan QUnit</title>
    <link rel="stylesheet" href="qunit-1.14.0.css" />
    <script src="qunit-1.14.0.js"></script>
    <script src="myrequire.js"></script>
    <script>
        test("Membaca module dalam direktori yang sama", function() {         
            equal('exports.foo = require("moduleB").foo;', bacaFile("moduleA.js"));
            equal('exports.foo = function() {};', bacaFile("moduleB.js"));
        });             
        test("Membaca module yang tidak ada", function() {
            throws(function() {
                bacaFile("entahDimana.js");
            });
        });
    </script>
</head>
<body>
    <div id="qunit"></div>
</body>
</html>

Pada pengujian di atas, saya memastikan bahwa bacaFile() akan mengembalikan string yang sesuai dengan isi file JavaScript. Bila saya berusaha membaca file yang tidak ada, function tersebut harus mengembalikan sebuah exception. Pada QUnit, saya menggunakan throws() untuk memeriksa apakah ada exception yang dikembalikan (dan saya juga bisa memeriksa jenis exception yang dikembalikan bila perlu).

Seperti biasa, bila saya menjalankan pengujian di atas, saya akan menemukan kegagalan karena function bacaFile() belum ada. Oleh sebab itu, saya perlu membuatnya dengan mengubah kode program myrequire.js menjadi seperti berikut ini:

(function(global) {

    function require(moduleId) {            
        var module = {};
        module.foo = function() {};
        return module;
    }       

    function bacaFile(namaFile) {
        var xhr = new XMLHttpRequest();
        xhr.open("GET", namaFile, false); 
        return xhr.responseText;        
    };

    global.require = require;
}(this));

Saya kemudian kembali menjalankan unit test di testBacaFile.html, tapi saya kembali mendapatkan pesan kesalahan yang sama bahwa bacaFile is not defined. Mengapa demikian? Hal ini karena function bacaFile() berada dalam closure sehingga tidak dapat diakses dari luar (termasuk oleh test case). Saya tidak bisa menguji sesuatu yang tidak bisa saya akses. Untuk itu, saya akan mengganti penggunakan closure menjadi sesuatu yang lebih OOP dengan mengubah kode program di myrequire.js menjadi seperti berikut ini:

function MyRequire(exports) {
    this.exports = exports;
};

MyRequire.prototype = {

    require: function require(moduleId) {           
        var module = {};
        module.foo = function() {};
        return module;
    },

    bacaFile: function bacaFile(namaFile) {
        var xhr = new XMLHttpRequest();
        xhr.open("GET", namaFile, false); 
        return xhr.responseText;        
    },

}

this.myRequire = new MyRequire(typeof exports === 'object' && exports || this);
this.require = function(s) { return this.myRequire.require(s); };

Walaupun cara diatas menyebabkan ada variabel global myRequire, setidaknya ini akan mempermudah pengujian yang merupakan kriteria penting dalam TDD. Sekarang, saya bisa mengubah kode program di testBacaFile.html menjadi seperti berikut ini:

<!doctype html>
<html>
<head>
    <title>Latihan QUnit</title>
    <link rel="stylesheet" href="qunit-1.14.0.css" />
    <script src="qunit-1.14.0.js"></script>
    <script src="myrequire.js"></script>
    <script>
        test("Membaca module dalam direktori yang sama", function() {         
            equal('exports.foo = require("moduleB").foo;', myRequire.bacaFile("moduleA.js"));
            equal('exports.foo = function() {};', myRequire.bacaFile("moduleB.js"));
        }); 
        test("Membaca module yang tidak ada", function() {
            throws(function() {
                myRequire.bacaFile("entahDimana.js")
            });
        });         
    </script>
</head>
<body>
    <div id="qunit"></div>
</body>
</html>

Tapi bila saya menampilkan HTML tersebut, kode program saya masih salah. File masih belum dibaca dengan benar. Mengapa demikian? Frustasi adalah hal biasa bagi developer. Setidaknya TDD membantu saya mengetahui bahwa ada yang salah dengan kode program sebelum library ini di-integrasi-kan pada proyek web yang lebih kompleks lagi.

Saya akhir menemukan bahwa saya lupa memanggil xhr.send()! Pantas saja gagal, saya segera menambahkan xhr.send() setelah xhr.open() di function bacaFile(). Sekarang, bila saya menampilkan testBacaFile.html, maka test case pertama akan sukses seperti pada gambar berikut ini:

Hasil pengujian menunjukkan test pertama sudah sukses

Hasil pengujian menunjukkan test pertama sudah sukses

Berikutnya, bagaimana caranya agar test case kedua tidak gagal? Saya harus men-throw sesuatu bila terjadi kegagalan saat membaca file. Sebagai contoh, saya mengubah kode program bacaFile() di myrequire.js menjadi seperti berikut ini:

...
bacaFile: function bacaFile(namaFile) {
    var xhr = new XMLHttpRequest();     
    xhr.open("GET", namaFile, false);
    xhr.send();
    if (xhr.status===200) {
        return xhr.responseText;
    } else {
        throw "Can't read file: " + namaFile;
    }       
},
...

Sekarang, seluruh pengujian untuk method bacaFile() akan sukses seperti yang terlihat pada gambar berikut ini:

Hasil pengujian menunjukkan seluruh test case sudah sukses

Hasil pengujian menunjukkan seluruh test case sudah sukses

Dampak dari unit test adalah membuat saya lebih percaya diri. Saya setidaknya bisa yakin bahwa bacaFile() sudah bekerja sesuai dengan yang diharapkan.

Perkembangannya terasa dengan jelas, bukan? Sampai disini saya bisa istirahat sejenak, menikmati segelas teh hangat atau jalan-jalan sebentar. Setelah saya kembali, saya tahu apa yang harus dilakukan. Masih ada test case yang gagal ūüôā

Agar semua test case di test.html sukses, saya mengubah kode program pada myrequire.js menjadi seperti berikut ini:

function MyRequire(exports) {
    this.exports = exports;
    this.modules = new Map();
};

MyRequire.prototype = {

    require: function require(moduleId) {           
        var module = {id: moduleId, exports: {}};           

        // TIdak melakukan loading bila sudah pernah di-load    
        if (this.modules.has(moduleId)) return this.modules.get(moduleId).exports;

        // Membaca file JS yang mewakili module
        new Function("module", "exports", this.bacaFile(moduleId + ".js"))
            .call(this.exports, module, module.exports);
        this.modules.set(moduleId, module);                     

        return module.exports;
    },

    bacaFile: function bacaFile(namaFile) {
        var xhr = new XMLHttpRequest();     
        xhr.open("GET", namaFile, false);
        xhr.send();
        if (xhr.status===200) {
            return xhr.responseText;
        } else {
            throw "Can't read file: " + namaFile;
        }       
    },

}

this.myRequire = new MyRequire(typeof exports === 'object' && exports || this);
this.require = function(s) { return this.myRequire.require(s); };

Sekarang, bila saya menjalankan kode program, seluruh test yang ada akan sukses seperti pada yang terlihat pada gambar berikut ini:

Hasil pengujian menunjukkan seluruh test case sudah sukses

Hasil pengujian menunjukkan seluruh test case sudah sukses

Sebagai informasi, QUnit juga mendukung pola module di CommonJS sehingga saya dapat memanggilnya melalui require(). Sebagai contoh, saya akan membuat unit test lagi yang diambil dari halaman dokumentasi CommonJS di http://www.commonjs.org/specs/modules/1.0/. Saya membuat file math.js yang isinya seperti berikut ini (sesuai dengan example di website tersebut):

exports.add = function() {
    var sum = 0, i = 0, args = arguments, l = args.length;
    while (i < l) {
        sum += args[i++];
    }
    return sum;
}

Kemudian, saya membuat file increment.js yang isinya seperti berikut ini (sesuai dengan example di website CommonJS):

var add = require('math').add;
exports.increment = function(val) {
    return add(val, 1);
};

Setelah itu, saya akan membuat unit test di file testSample.html yang isinya seperti berikut ini:

<!doctype html>
<html>
<head>
    <title>Latihan QUnit</title>
    <link rel="stylesheet" href="qunit-1.14.0.css" /> 
    <script src="myrequire.js"></script>
    <script>
        var QUnit = require('qunit-1.14.0');

        test('Sample Common JS', function() {
            var inc = require('increment').increment;
            var a = 1;
            equal(2, inc(a));
            var b = 5;
            equal(6, inc(b));
        });             
    </script>
</head>
<body>
    <div id="qunit"></div>
</body>
</html>

Bila saya menampilkan halaman di atas pada browser, saya akan memperoleh tampilan yang menunjukkan bahwa kode program saya bekerja sesuai harapan seperti yang terlihat pada gambar berikut ini:

Hasil pengujian menunjukkan seluruh test case sudah sukses

Hasil pengujian menunjukkan seluruh test case sudah sukses

Cara di atas, dimana saya membuat unit test setelah selesai membuat kode program, adalah sesuatu yang melanggar prinsip test driven development (TDD). Pada TDD, saya harus membuat atau mengembangkan unit test terlebih dahulu sebelum membuat kode program. Tapi tentu saja menambah unit test belakangan bukan sesuatu yang salah. Semakin banyak test case yang ada akan membuat sebuah proyek menjadi semakin teruji dan handal.

Melakukan Fuzz Testing Dengan Kali Linux

Pada saat membuat program, developer mengharapkan pengguna memasukkan input sesuai dengan yang seharusnya. Pada kenyataannya, pengguna (terutama yang iseng) mungkin saja memberikan input yang tidak diharapkan. Lalu, apa yang akan terjadi dengan program bila pengguna memberikan input yang tidak diharapkan? Fuzz testing adalah pengujian yang dilakukan untuk menjawab pertanyaan tersebut. Fuzz testing adalah jenis pengujian black box dimana penguji memberikan berbagai jenis input yang tidak valid pada program.

Kali Linux mem-bundle beberapa tools untuk keperluan fuzz testing yang dapat ditemukan di menu Kali Linux, Vulnerability Analysis, Fuzzing Tools. Saya akan mencoba beberapa tools tersebut.

Tool pertama yang saya temukan, BED, adalah sebuah script Perl yang dipakai untuk menguji penanganan protokol. BED mendukung protokol seperti FTP, SMTP, POP, HTTP, IRC, IMAP, PJL, LPD, FINGER, SOCKS4, dan SOCKS5. Dengan demikian, BED tidak dipakai untuk menguji program seperti aplikasi web, melainkan menguji infrastruktur seperti web server, mail server, FTP server dan sebagainya. BED akan berhenti bila berhasil membuat server yang diuji menjadi down atau crash. Sebagai contoh, saya ingin menguji seberapa handal FTP server di router yang saya pakai. Karena kemampuan pemrosesan di router ekonomis yang terbatas dan jarang sekali upgrade firmware, saya pantas merasa ragu dengan keamanannya. Oleh sebab itu, saya dapat melakukan pengujian dengan perintah seperti berikut ini:

# bed -s FTP -u admin -v password -t 192.168.1.1

BED 0.5 by mjm ( www.codito.de ) &amp; eric ( www.snake-basket.de )

+ Buffer overflow testing:

    ...

+ Formatstring testing:

    ...

* Normal tests
 + Buffer overflow testing:

    ...

 + Formatstring testing:

    ...

 + Unicode testing:

    ...

 + random number testing:

    ...

 + testing misc strings 1:

    ...

 + testing misc strings 2:

    ...

 + testing misc strings 3:

    ...

 + testing misc strings 4:

    ...

 + testing misc strings 5:

 ...


Script BED akan menguji dengan memakai input berupa overflow string ("A" x 1023 atau "\\" x 200), format string (seperti %s%s%s%s atau %.2048d), unicode string (seperti "x99" x 512), large number (seperti -268435455), dan misc string (seperti "/" atau "\r\n"). Khusus pada FTP, ia juga akan menguji apakah directory traversal dengan input seperti "/././.." dimungkinkan atau tidak.

Tool lainnya yang berada dalam kategori ini adalah powerfuzzer. Tool berbasis GUI ini dapat dipakai untuk menguji aplikasi web. Sebagai contoh, saya akan melakukan pengujian pada router saya yang memiliki halaman administrasi dalam bentuk aplikasi web. Saya dapat memberikan perintah berikut ini untuk menjalankan powerfuzzer:

# powerfuzzer

Saya mengisi bagian credentials dengan nama user dan password router. Setelah itu, saya mengisi Target URL dengan lokasi router yaitu di http://192.168.1.1 dan men-klik tombol Scan. powerfuzzer akan bekerja dan menampilkan hasil pemeriksaan setelah selesai. powerpuzzer pada dasarnya adalah script Python yang bekerja mengisi parameter yang dipakai pada parameter pada request GET dan POST dengan nilai yang tidak valid seperti lokasi file (misalnya /etc/passwd atau http://www.google.com/), script (misalnya "a;env", "a);env", dan "/e"), injeksi (dengan payload berupa "\xbf'\"("), javascript (untuk memeriksa XSS), dan string CRLF seperti "http://www.google.com\r\nPowerfuzzer: v1 BETA".

Menariknya, hasil pengujian menunjukkan terdapat celah keamanan XSS pada web administrasi router saya. Bukan hanya itu saja! Dengan mengirim payload tertentu menggunakan method POST pada URL tersebut, router akan crash dan di-restart kembali.

Selain memakai tools yang ada di kategori Fuzzing Tools, bila melakukan fuzz testing pada aplikasi web, saya dapat menggunakan tool yang ada di menu Kali Linux, Web Applications, Web Application Fuzzers. Disini saya dapat menggunakan tool yang lebih ‘user-friendly’ dan berbasis GUI seperti Burp dan Zed Attack Proxy (ZAP).

Fuzz testing pada Burp dilakukan dengan memilih tab Intruder dan memilih salah satu jenis payload yang diujikan seperti Illegal Unicode. Pada ZAP, saya dapat memilih tab Active Scan untuk mencari celah keamanan pada URL yang telah diperoleh oleh spider. ZAP akan melakukan beberapa pemeriksaan, termasuk diantaranya adalah Cross Site Scripting (XSS), SQL Injection dan CRLF injection. Untuk melakukan fuzz testing pada URL tunggal di ZAP, saya perlu memilih sebuah parameter atau header di bagian Request, kemudian men-klik kanan dan memilih Fuzz…. Pada dialog yang muncul, saya dapat memilih fuzzer yang akan dipakai, seperti jbrofuzz/Buffer Overflows, jbrofuzz/Format String Errors, jbrofuzz/Integer Overflows, jbrofuzz/SQL Injection, dan sebagainya.

Memakai PHPUnit Di Zend Studio

Seorang mahasiswa menjalankan programnya, men-klik beberapa kali, lalu muncul pesan kesalahan. Ia kemudian mencoba memperbaiki kode programnya. Setelah itu, ia kembali lagi menjalankan programnya, men-klik beberapa kali, ternyata pesan kesalahan yang sama masih muncul. Ia kembali melakukan perubahan pada kode programnya. Siklus ini berlangsung terus menerus hingga sang mahasiswa tidak menemukan pesan kesalahan lagi. Ini adalah salah satu contoh melakukan pengujian secara manual.

Teknik pengujian yang lebih otomatis adalah dengan menggunakan unit testing dimana seorang programmer menguji kebenaran kode programnya melalui kode program. Keuntungannya? Proses pengujian bisa dilakukan secara berulang kali tanpa melelahkan si programmer. Bahkan, pada metodologi Test-driven Development (TDD), melakukan unit testing adalah hal yang wajib karena pengujian menjadi pemicu pengembangan kode program! Test-driven Development (TDD) adalah salah satu konsep dalam Extreme Programming (XP). XP sendiri adalah metode pengembangan software yang mengikuti nilai dan prinsip dalam Agile Manifesto (baca: Apa itu Agile Manifesto?).

Bila unit testing di Java dilakukan dengan menggunakan JUnit, maka pada PHP, saya dapat menggunakan PHPUnit. Beruntungnya, Zend Studio 10.5 dilengkapi dengan integrasi PHPUnit sehingga saya tidak perlu men-download PHPUnit secara manual.

Unit testing sebaiknya dilakukan per unit eksekusi terpisah yaitu class. Setiap class memiliki test case tersendiri yang berdiri sendiri. Biasanya yang saya uji adalah kode program di domain model. Pengujian pada controller dapat dianggap sebagai integration testing karena harus melibatkan request HTTP, database dan beberapa class berbeda. Sementara itu, pengujian pada view merupakan front-end testing yang dilakukan dengan memakai tool sejenis Selenium yang berjalan di browser.

Sebagai contoh, pada sebuah proyek terdapat domain model pada file domain.php seperti berikut ini:

<?php

/**
 * Class ini dipakai untuk mewakili sebuah penjualan.
 *
 * @author Solid Snake
 *
 */
class Penjualan {

   /**
    * Nomor untuk faktur penjualan ini.
    * 
    * @var string
    */
   public $nomor;

   /**
    * Tanggal yang terterak di faktur.
    * 
    * @var string   
    */
   public $tanggal;

   /**
    * Array yang berisi satu atau lebih `Item` untuk faktur ini.
    * 
    * @var Item[]
    */
   public $arrItem;

   /**
    * Diskon untuk penjualan ini.
    *  
    * @var Diskon
    */
   public $diskon;

   /**
    * 
    * @param string $nomor
    * @param date $tanggal
    */
   public function __construct($nomor, $tanggal, $diskon = NULL) {
       $this->nomor = $nomor;
       $this->tanggal = $tanggal;
       $this->arrItem = [];
       $this->diskon = $diskon?: new Diskon();
   }

   /**
    * Menambah `Item` baru untuk penjualan ini.
    * 
    * @param Item $item
    */
   public function tambahItem($item) {
       $this->arrItem[] = $item;
   }

   /**
    * Mengembalikan total untuk transaksi ini.
    * 
    * @return double
    */
   public function getTotal() {
      $total = 0;
      foreach ($this->arrItem as $item) {
           $total += $item->getTotal();
      }
      return $this->diskon->setelahDiskon($total);      
   }       

}

/**
 * Class ini dipakai untuk mewakili item pada setiap transaksi yang ada.
 * 
 * Sebuah transaksi seperti pembelian atau penjualan dapat terdiri atas
 * lebih dari satu Item.
 * 
 * Harga barang yang diperdagangkan tidak harus sama dengan yang tertera
 * di barang (misalnya harga berdasarkan kesepakatan kerja sama 
 * dengan pihak terkait).
 * 
 * Setiap Item boleh memiliki diskon masing-masing
 * (boleh lebih dari satu nilai persentase diskon untuk sebuah Item).
 * 
 * @author SolidSnake
 *
 */
class Item {

   /**
    * Merujuk pada `Barang` yang diperdagangkan.
    * 
    * @var Barang
    */
   public $barang;

   /**
    * Jumlah yang diperdagangkan untuk `Barang` ini.
    *  
    * @var int
    */
   public $jumlah;

   /**
    * Harga yang disepakati untuk transaksi `Barang` ini.
    * 
    * @var double
    */
   public $harga;

   /**
    * Diskon untuk item ini.
    * 
    * @var Diskon 
    */
   public $diskon;

   /**
    *
    * @param Barang $barang
    * @param int $jumlah
    * @param double $harga
    */
   public function __construct($barang, $jumlah, $harga = NULL, $diskon = NULL) {
       $this->barang = $barang;
       $this->jumlah = $jumlah;     
       $this->harga = $harga?: $barang->harga;               
       $this->diskon = $diskon?: new Diskon();
   }

   /**
    * Mengembalikan total harga untuk Item ini.  Perhitungan dilakukan
    * berdasarkan jumlah item * harga, kemudian dikurangi diskon.
    * 
    * @return double
    */
   public function getTotal() {        
       return $this->diskon->setelahDiskon($this->harga * $this->jumlah);
   }

}

/**
 * Class ini mewakili diskon untuk sebuah transaksi atau item transaksi.
 * 
 * @author SolidSnake
 *
 */
class Diskon {

   /**
    * Sebuah array yang berisi nilai diskon untuk item ini.
    * Sebuah item dapat memiliki lebih dari satu diskon, misalnya
    * diskon member dan diskon promosi natal.
    *
    * Diskon dalam bentuk angka, misalnya 25 untuk 25%,
    * 10 untuk 10% dan sebagainya.
    *
    * @var int[]
    */

   public $arrDiskon;

   /**
    * 
    * @param int[] $arrDiskon
    */
   public function __construct($arrDiskon = NULL) {
       if ($arrDiskon == NULL) {
           $this->arrDiskon = [];
       } else if (is_array($arrDiskon)) {
           $this->arrDiskon = $arrDiskon;
       } else {
           $this->arrDiskon = [$arrDiskon];
       }
   }

   /**
    * Menambah nilai diskon baru, misalnya 10 untuk 10% dan 25 untuk 25%.
    * 
    * Bila nilai `diskon` berupa array, maka lebih dari satu nilai akan
    * ditambahkan ke nilai diskon saat ini.
    * 
    * @param mixed $diskon
    */
   public function tambahDiskon($diskon) {
       $this->arrDiskon[] = $diskon;
   }

   /**
    * Menghitung jumlah diskon untuk sebuah harga.  Sebagai contoh,
    * diskon 10% untuk 100.000 akan mengembalikan nilai 10.000.
    * 
    * @param double $harga
    */
   public function jumlahDiskon($harga) {      
       $total = 0;             
       foreach ($this->arrDiskon as $diskon) {
           if ($total==0) {
               $total = $harga * ($diskon/100);
               $harga -= $total;
           } else {
               $diskon = $harga * ($diskon/100);
               $total += $diskon;
               $harga -= $diskon;                          
           }
       }
       return $total;
   }

   /**
    * Menghitung sebuah harga setelah dikurangi diskon ini.
    * Sebagai contoh, diskon 10% untuk 100.000 akan mengembalikan
    * nilai 90.000.
    *  
    * @param double $harga 
    */
   public function setelahDiskon($harga) {
       if (count($this->arrDiskon)==0) return $harga;
       $total = 0;
       foreach ($this->arrDiskon as $diskon) {
           if ($total==0) {
               $total = $harga * (1 - $diskon/100);
           } else {
               $total *= (1 - $diskon/100);
           }
       }
       return $total;
   }

}

/**
 * Class ini dipakai untuk mewakili barang yang diperdagangkan.
 * 
 * @author SolidSnake
 *
 */
class Barang {

   /**
    * Nama barang
    * 
    * @var string
    */
   public $nama;

   /**
    * Harga barang
    * 
    * @var double
    */
   public $harga;

   /**
    * 
    * @param string $nama
    * @param double $harga
    */
   public function __construct($nama, $harga) {
       $this->nama = $nama;
       $this->harga = $harga;
   }
}

?>

Pada kode program di atas, saya mendefinisikan seluruh class pada satu file yang sama yaitu domain.php. Hal ini tidak disarankan karena lebih baik memisahkan masing-masing class pada file PHP tersendiri. Bagi saya secara pribadi, salah satu alasannya adalah karena monitor zaman sekarang cenderung lebih lebar secara horizontal (widescreen) dan lebih sempit secara vertikal. Dengan demikian, membuka beberapa tab secara bersamaan dilengkapi navigasi yang nyaman (Ctrl+PageUp dan Ctrl+PageDown untuk berpindah tab di Zend Studio) akan lebih efektif dibandingkan dengan melakukan scrolling naik turun di file yang sama.

Domain model di atas terdiri atas 4 class yaitu Barang, Diskon, Item dan Penjualan. Mereka memiliki logic untuk menghitung total yang sudah termasuk diskon, dimana masing-masing Item dan Penjualan dapat memiliki satu atau lebih nilai diskon. Sampai disini, saya tentu tidak tahu apakah kode program di atas benar atau salah karena belum ada output yang dapat dilihat! Semakin banyak kode program yang ditulis, saya semakin bimbang melangkah demi masa depan. Agar lebih yakin dan lebih percaya diri, saya segera membuat unit test dengan menggunakan PHPUnit.

Saya mulai dengan men-klik kanan nama proyek, memilih menu New, PHPUnit Test Case seperti yang terlihat pada gambar berikut ini:

Membuat test case baru

Membuat test case baru

Pada dialog yang muncul, saya akan mengisinya seperti berikut ini:

Test case untuk class Diskon

Test case untuk class Diskon

Saya akan menguji mulai dari class yang lebih sederhana. Karena kode program pada class Barang tidak ada yang istimewa, maka saya mulai dengan class Diskon. Setelah men-klik tombol Finish, Zend Studio akan menambahkan dependency ke library PHPUnit. Selain itu, ia akan membuat file DiskonTest.php yang sudah memiliki skeleton yang tinggal saya ubah menjadi seperti berikut ini:

<?php
require_once 'domain.php';
require_once 'PHPUnit/Framework/TestCase.php';

class DiskonTest extends PHPUnit_Framework_TestCase {

  protected function setUp() {
      parent::setUp ();
  }

  protected function tearDown() {
      parent::tearDown ();
  }

  public function testConstruct() {
      $diskon = new Diskon();
      $this->assertNotNull($diskon->arrDiskon);
      $this->assertEmpty($diskon->arrDiskon);

      $diskon = new Diskon(10);
      $this->assertEquals(10, $diskon->arrDiskon[0]);

      $diskon = new Diskon([10, 20]);
      $this->assertEquals([10, 20], $diskon->arrDiskon);        
  }

  public function testTambahDiskon() {
      $diskon = new Diskon();

      $diskon->tambahDiskon(10);       
      $this->assertEquals([10], $diskon->arrDiskon);

      $diskon->tambahDiskon(20);
      $this->assertEquals([10, 20], $diskon->arrDiskon);
  }

  public function testJumlahDiskon() {
      $diskon = new Diskon(10);
      $this->assertEquals(15000, $diskon->jumlahDiskon(150000));

      $diskon = new Diskon([10, 20]);
      $this->assertEquals(42000, $diskon->jumlahDiskon(150000));

      $diskon = new Diskon([20, 10]);
      $this->assertEquals(42000, $diskon->jumlahDiskon(150000));

      $diskon = new Diskon([20, 10, 5]);
      $this->assertEquals(47400, $diskon->jumlahDiskon(150000));

      $diskon = new Diskon();
      $this->assertEquals(0, $diskon->jumlahDiskon(150000));
  }

  public function testSetelahDiskon() {
      $diskon = new Diskon(10);
      $this->assertEquals(135000, $diskon->setelahDiskon(150000));

      $diskon = new Diskon([10, 20]);
      $this->assertEquals(108000, $diskon->setelahDiskon(150000));

      $diskon = new Diskon([20, 10]);
      $this->assertEquals(108000, $diskon->setelahDiskon(150000));

      $diskon = new Diskon([20, 10, 5]);
      $this->assertEquals(102600, $diskon->setelahDiskon(150000));

      $diskon = new Diskon();
      $this->assertEquals(150000, $diskon->setelahDiskon(150000));
  }

}
?>

Walaupun kode program sebuah test case terasa repetitif dan sangat membosankan, saya memiliki kesempatan untuk me-mandang dari sisi pengguna class. Pengguna class bisa saja adalah saya sendiri saat membuat controller nanti atau mungkin saja developer lain. Salah satu resep untuk mempelajari penggunaan class yang ada di proyek orang lain (misalnya proyek open-source) adalah dengan melihat test case yang mereka sediakan.

Unit testing juga penting karena saat memikirkan apa saja yang harus diuji pada sebuah class, saya sering kali menemukan ada yang kurang atau ada yang harus saya modifikasi pada class tersebut. Prinsip Test-driven Development (TDD) bahkan mensyaratkan untuk membuat test case terlebih dahulu sebelum membuat implementasinya. Jadi, bila mengikuti metodologi TDD, saya harus membuat DiskonTest.php terlebih dahulu. Saat ini akan ada banyak pesan kesalahan karena class yang diuji belum ada! Lalu, selangkah demi selangkah, saya membuat implementasi Diskon.php hingga setiap pesan kesalahan yang ada hilang.

Untuk menjalankan test case ini, saya men-klik kanan file DiskonTest.php, lalu memilih menu Run As, PHPUnit Test seperti yang terlihat pada gambar berikut ini:

Menjalankan test case

Menjalankan test case

Pada window PHPUnit, saya dapat melihat hasil eksekusi PHPUnit. Sebagai contoh, bila terdapat perilaku pada class Diskon yang tidak sesuai dengan harapan, saya akan memperoleh hasil seperti pada gambar berikut ini:

Hasil pengujian yang mengandung kegagalan

Hasil pengujian yang mengandung kegagalan

Selain menampilkan kegagalan, window PHPUnit juga menampilkan informasi code coverage. Sebagai contoh, saat ini hanya 52% kode program di file domain.php yang sudah dijangkau oleh pengujian (karena saya belum menguji class lain yang juga didefinisikan di file tersebut). Idealnya, 100% kode program harus diuji. Tetapi terkadang ada bagian kode program yang selalu benar dalam setiap kondisi sehingga pengujian padanya tidak wajib dilakukan.

Saya kemudian memperbaiki kesalahan yang berkaitan dengan method getJumlahDiskon() dan setelahDiskon() (bila terdapat kesalahan seperti pada gambar di atas). Saya dapat men-klik tombol Run last test untuk mengulangi pengujian. Bila masih ada kesalahan, saya kembali memperbaiki kode program untuk class Diskon. Saya terus mengulangi siklus ini hingga akhirnya saya memperoleh hasil sukses seperti yang terlihat pada gambar berikut ini:

Hasil pengujian yang sukses semua

Hasil pengujian yang sukses semua

Berikutnya, saya akan membuat test case untuk class Item yang isinya adalah seperti berikut ini:

<?php
require_once 'domain.php';
require_once 'PHPUnit/Framework/TestCase.php';

class ItemTest extends PHPUnit_Framework_TestCase {

  protected function setUp() {
    parent::setUp ();
  }

  protected function tearDown() {
    parent::tearDown ();
  }

  public function testConstruct() {
    $barang = new Barang("ITEM-01", 100000);

    $item = new Item($barang, 10, 120000, new Diskon(10));      
    $this->assertEquals($barang, $item->barang);
    $this->assertEquals(10, $item->jumlah);
    $this->assertEquals(120000, $item->harga);
    $this->assertEquals(10, $item->diskon->arrDiskon[0]);

    $item = new Item($barang, 10);
    $this->assertEquals($barang, $item->barang);
    $this->assertEquals(10, $item->jumlah);
    $this->assertEquals($barang->harga, $item->harga);
    $this->assertEmpty($item->diskon->arrDiskon);
  }

  public function testGetTotal() {
    $barang = new Barang("ITEM-01", 100000);        

    // Harga sama, tanpa diskon
    $item = new Item($barang, 5);
    $this->assertEquals(500000, $item->getTotal());

    // Harga beda, tanpa diskon
    $item = new Item($barang, 5, 120000);
    $this->assertEquals(600000, $item->getTotal());

    // Harga sama, dengan diskon
    $item = new Item($barang, 5, NULL, new Diskon(10));
    $this->assertEquals(450000, $item->getTotal());

    // Harga beda, dengan diskon
    $item = new Item($barang, 5, 120000, new Diskon(10));
    $this->assertEquals(540000, $item->getTotal());
  }
}
?>

Seperti biasa, saya menjalankan test case di atas dan memastikan semuanya baik-baik saja.

Setelah itu, saya kembali membuat test case untuk class Penjualan seperti berikut ini:

<?php
require_once 'domain.php';
require_once 'PHPUnit/Framework/TestCase.php';

class PenjualanTest extends PHPUnit_Framework_TestCase {

  protected function setUp() {
    parent::setUp ();
  }

  protected function tearDown() {
    parent::tearDown ();
  }

  public function testTambahItem() {
    $barang1 = new Barang("BRG-01", 100000);
    $barang2 = new Barang("BRG-02", 200000);

    $penjualan = new Penjualan("INV-01", "2013-12-10");
    $penjualan->tambahItem(new Item($barang1, 10));
    $penjualan->tambahItem(new Item($barang2, 20));

    $this->assertCount(2, $penjualan->arrItem);
    $this->assertEquals($barang1, $penjualan->arrItem[0]->barang);
    $this->assertEquals(10, $penjualan->arrItem[0]->jumlah);
    $this->assertEquals($barang2, $penjualan->arrItem[1]->barang);
    $this->assertEquals(20, $penjualan->arrItem[1]->jumlah);
  }

  public function testGetTotal() {
    $barang1 = new Barang("BRG-01", 100000);
    $barang2 = new Barang("BRG-02", 200000);

    // tanpa diskon
    $penjualan = new Penjualan("INV-01", "2013-12-10");
    $penjualan->tambahItem(new Item($barang1, 10));
    $penjualan->tambahItem(new Item($barang2, 20));
    $this->assertEquals(5000000, $penjualan->getTotal());

    // dengan diskon
    $penjualan = new Penjualan("INV-02", "2013-12-10", new Diskon(10));     
    $penjualan->tambahItem(new Item($barang1, 10));
    $penjualan->tambahItem(new Item($barang2, 20));
    $this->assertEquals(4500000, $penjualan->getTotal());

    // dengan diskon per item + diskon
    $penjualan = new Penjualan("INV-03", "2013-12-10", new Diskon(10));
    $penjualan->tambahItem(new Item($barang1, 10, 120000, new Diskon(5)));
    $penjualan->tambahItem(new Item($barang2, 20));
    $this->assertEquals(4626000, $penjualan->getTotal());
  }

}

Sekarang saya bisa percaya bahwa kode program di domain class yang saya buat dapat bekerja. Ini tidak berarti tidak ada bug pada kode program tersebut, karena test case yang saya buat tidak mencakup seluruh kemungkinan yang terjadi. Tapi setidaknya, setiap kali saya melakukan perubahan pada class tersebut, saya dapat menjalankan ulang test case untuk memastikan bahwa perubahan yang saya buat tidak menimbulkan dampak buruk pada class lainnya.

Pertanyaannya adalah menjalankan 3 test case satu per satu cukup merepotkan, apakah ada cara lain yang lebih singkat? Saya dapat membuat sebuah test suite! Saya akan mulai dengan men-klik kanan nama proyek, memilih New, PHPUnit Test Suite. Saya mengisi dialog yang muncul sehingga terlihat seperti berikut ini:

Membuat test suite

Membuat test suite

Setelah men-klik Finish, Zend Studio akan menghasilkan sebuah file bernama latihanSuite.php yang didalamnya mendaftarkan test case yang telah saya buat sebelumnya. Saya dapat menjalankan test suite ini dengan men-klik kanan pada nama file tersebut, memilih menu Run As, PHPUnit Test. Saya akan memperoleh hasil seperti pada gambar berikut ini:

Menjalankan test suite

Menjalankan test suite

Dengan test suite, saya dapat menjalankan beberapa test case berbeda dengan hanya satu kali pemanggilan.

Melakukan Pengujian Java Swing Dengan FEST

Pada aplikasi web, saya dapat melakukan pengujian view HTML dengan menggunakan Selenium.  Bagaimana dengan aplikasi desktop? Apakah saya juga bisa melakukan pengujian user interface?  Yup, bisa!  Salah satu tool yang dapat saya pergunakan adalah FEST (Fixtures for Easy Software Testing).

Untuk memakai FEST, saya perlu men-download fest-swing-1.2.zip dari http://code.google.com/p/fest. Di dalam file tersebut, saya akan menemukan file fest-swing-1.2.jar yang harus disertakan dalam proyek yang akan diuji. Selain itu, saya juga perlu menyertakan jar yang ada di dalam folder lib seperti fest-util-1.1.2.jar dan fest-assert-1.2.jar.  Btw, di saat sedang serius, saya lebih memilih memakai Maven untuk melakukan proses download secara otomatis dengan menyertakan dependency ke org.easytesting.fest-swing.

Saya akan membuat sebuah tampilan Swing yang akan diuji dengan menggunakan MigLayout seperti berikut ini:

package com.snake.view;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPasswordField;
import javax.swing.JTextField;

import net.miginfocom.swing.MigLayout;

public class LoginView extends JFrame {

	private static final long serialVersionUID = -1977819063823609321L;

	private JTextField txtNama;
	private JPasswordField txtPassword;
	private JButton btnLogin;
	private JButton btnKeluar;

	public LoginView() {
		super("Login");

		setLayout(new MigLayout("", "[right][grow,fill]", "[][][nogrid]"));
		add(new JLabel("Nama Pengguna:"));
		add(txtNama = new JTextField(20), "wrap");		
		add(new JLabel("Password:"));
		add(txtPassword = new JPasswordField(20), "wrap");
		add(btnLogin = new JButton("Login"), "align center, gaptop 10");
		add(btnKeluar = new JButton("Keluar"));

		txtNama.setName("txtNama");
		txtPassword.setName("txtPassword");
		btnLogin.setName("btnLogin");
		btnKeluar.setName("btnKeluar");

		btnLogin.addActionListener(new ActionListener() {

			@Override
			public void actionPerformed(ActionEvent e) {
				if (txtNama.getText().equals("solid") &&
						String.valueOf(txtPassword.getPassword()).equals("snake")) {
					JOptionPane.showMessageDialog(LoginView.this, "Ok, login sukses!");
				} else {
					JOptionPane.showMessageDialog(LoginView.this, "Maaf, kamu bukan dia!");
				}
			}

		});

		setSize(500,500);
		pack();		
	}

	public static void main(String[] args) {
		LoginView loginView = new LoginView();
		loginView.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		loginView.setVisible(true);
	}

}

Lalu, untuk melakukan pengujian, saya membuat sebuah JUnit test seperti berikut ini:

package com.snake.view;

import org.fest.swing.edt.FailOnThreadViolationRepaintManager;
import org.fest.swing.edt.GuiActionRunner;
import org.fest.swing.edt.GuiQuery;
import org.fest.swing.fixture.FrameFixture;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;

public class LoginViewTest {

	private FrameFixture window;

	@BeforeClass
	public static void beforeClass() {
		FailOnThreadViolationRepaintManager.install();
	}

	@Before
	public void before() {

		LoginView loginView = GuiActionRunner.execute(new GuiQuery<LoginView>() {
			@Override
			protected LoginView executeInEDT() throws Throwable {
				return new LoginView();
			}
		});
		window = new FrameFixture(loginView);
		window.show();
	}

	@After
	public void after() {
		window.cleanUp();
	}

	@Test
	public void testTampilanAwal() {
		window.textBox("txtNama").requireEditable().requireEmpty();
		window.textBox("txtPassword").requireEditable().requireEmpty();
		window.button("btnLogin").requireVisible();
		window.button("btnKeluar").requireVisible();
	}

	@Test
	public void testLoginSukses() {
		window.textBox("txtNama").enterText("solid");
		window.textBox("txtPassword").enterText("snake");
		window.button("btnLogin").click();
		// Menunggu JOptionPane muncul dengan timeout default 100ms
		window.optionPane().requireMessage("Ok, login sukses!");
	}

	@Test
	public void testLoginGagal() {
		window.textBox("txtNama").enterText("user aneh");
		window.textBox("txtPassword").enterText("password ajaib");
		window.button("btnLogin").click();
		// Menunggu JOptionPane muncul dengan timeout default 100ms
		window.optionPane().requireMessage("Maaf, kamu bukan dia!");
	}
}

Pada pengujian di atas, saya memberikan statement FailOnThreadViolationRepaintManager.install() untuk mendeteksi apakah ada kode program yang mengakses Swing tetapi tidak berjalan di EDT. Saya membahas tentang EDT di tulisan Multithreading Dengan Mudah Di Griffon.   Pada tulisan tersebut saya memakai bahasa Griffon, sementara disini saya memakai bahasa Java.  Tapi keduanya sama-sama berjalan di platform Java sehingga prinsip dasar-nya tetap sama.    Bila terdapat pelanggaran saat JUnit dijalankan, pada console akan terdapat output seperti org.fest.swing.exception.EdtViolationException: EDT violation detected.

Ini adalah contoh hasil yang saya peroleh saat menjalankan JUnit test case di atas:

Hasil Pengujian

Hasil Pengujian

Pada saat memakai JUnit dengan Selenium, browser akan muncul dan men-klik “sana-sini” secara otomatis. ¬†Begitu juga dengan sekarang. ¬†Saat memakai FEST, JFrame yang diuji akan muncul, setiap JTextField akan di-isi, kemudian JButton akan di-click.

Melakukan Front-End Testing Dengan Selenium Tanpa “Mengotori” Database

Pada tulisan Menguji Halaman Web Secara Otomatis, saya menuliskan bagaimana cara memakai Selenium untuk menguji web front end secara otomatis.   Permasalahan yang timbul dengan memakai cara tersebut adalah selama pengujian, saya harus menjalankan aplikasi di server development secara manual dan memakai database yang sama dengan yang dipakai selama development.  Hal ini akan menyebabkan masalah konsistensi data jika seandainya pengujian gagal di tahap tertentu.  Selain itu, bila saya menguji halaman seperti Pemesanan, saya harus menambah Stok baru, menambah User baru, kemudian membuka halaman Pemesanan, lalu menghapus Stok, dan menghapus User.

Langkah yang ditempuh sangat melelahkan sekali, membuat saya menetaskan keringat dingin saat menulis kode program untuk pengujian web front end.  Entah bagaimana para extreme programmer dengan Test Driven Development (TDD)-nya bisa bertahan hidup.  Akhirnya muncul sebuah ide untuk memakai database in-memory yang terpisah dari database development.   Selain itu, saya ingin mengisi database tersebut dengan data secara otomatis dan konsisten setiap kali sebuah method di test class dikerjakan.  Dengan demikian, saya bisa langsung berkonsentrasi pada halaman yang akan diuji, tanpa perlu menambah item-item yang diperlukan di halaman lain.   Saya tidak tahu apakah ini masih masuk dalam kategori functional testing atau unit testing, tapi cara ini akan mempermudah hidup saya (lagipula saya bukan penganut extreme programming!).

Ok, teknologi apa yang saya butuhkan?

  1. Jetty untuk menjalankan aplikasi web secara otomatis selama pengujian front-end.
  2. H2 Database sebagai database in-memory embedded untuk pengujian.
  3. DbUnit untuk mengisi database H2 berdasarkan data dari file Microsoft Excel yang disediakan.  Data  di-isi secara otomatis  setiap kali method yang dikerjakan (dengan kata lain, isi database akan kembali sama seperti isi file Microsoft Excel setiap kali sebuah method dikerjakan).
  4. Selenium, tentunya, untuk melakukan pengujian di browser.

Untuk men-download semua artifact JAR yang dibutuhkan secara otomatis, saya akan menambahkan dependency berikut ini pada Maven:

  1. org.eclipse.jetty.aggregate : jetty-all : 7.6.7.v20120910 [test]
  2. org.apache.tomcat : juli : 6.0. 36 [test] (karena saya memakai implementasi JSP dari Tomcat)
  3. com.h2database : h2 : 1.3.167
  4. org.dbunit : dbunit : 2.4.8 [test]
  5. org.apache.poi : poi : 3-2-FINAL [test]
  6. org.seleniumhq.selenium : selenium-java : 2.25.0 [test]
  7. org.seleniumhq.selenium : selenium-server : 2.25.0 [test]

Struktur proyek yang saya pakai adalah struktur proyek Maven yang terlihat seperti pada gambar berikut ini:

Struktur Proyek

Struktur Proyek

Saya akan meletakkan kode program proyek di src/main/java dan file konfigurasi yang dibutuhkan di src/main/resources.  Khusus untuk kode program yang mewakili test case pengujian, saya meletakkannya di src/test/java.  File-file yang dibutuhkan untuk pengujian akan saya letakkan di src/test/resources.   Dengan struktur proyek seperti ini, pada saat aplikasi akan di-deploy atau di-built, hanya folder src/main saja yang akan disertakan (karena src/test untuk pengujian bukan untuk dipakai user).

Berikutnya saya akan membuat sebuah class bernama AbstractSeleniumTest.  Class ini nantinya wajib di-extends oleh semua class yang akan melakukan pengujian front end.  Isi dari class AbstractSeleniumTest adalah:

import static org.junit.Assert.fail;

import java.util.ArrayList;
import java.util.List;

import org.dbunit.DataSourceDatabaseTester;
import org.dbunit.dataset.IDataSet;
import org.dbunit.util.fileloader.XlsDataFileLoader;
import org.eclipse.jetty.plus.jndi.EnvEntry;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.webapp.WebAppContext;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebDriverBackedSelenium;
import org.openqa.selenium.firefox.FirefoxDriver;

import com.thoughtworks.selenium.Selenium;

public class AbstractSeleniumTest {

	protected static Server server;
	protected static DataSourceDatabaseTester dataTester;
	protected static JdbcDataSource dataSource;

	protected Selenium selenium;	

	@BeforeClass
	public static void beforeTestClass() throws Exception {

		// Setup Database H2 In-Memory
		if (dataSource==null) {
			dataSource = new JdbcDataSource();
			dataSource.setURL("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
			dataTester = new DataSourceDatabaseTester(dataSource);
		}

		// Setup Server Jetty
		if (server==null || !server.isRunning()) {
			Server server = new Server(8181);
			server.setAttribute("org.eclipse.jetty.webapp.Configuration",
				new String[]{"org.eclipse.jetty.plus.webapp.EnvConfiguration"});
			EnvEntry envEntry = new EnvEntry("java:comp/env/jdbc/testdatabase", dataSource);
			server.addBean(envEntry);

			WebAppContext context = new WebAppContext();
			context.setDescriptor("../FolderProyek/src/main/webapp/WEB-INF/web.xml");
			context.setResourceBase("../FolderProyek/src/main/webapp");
			context.setContextPath("/proyek");
			context.setParentLoaderPriority(true);

			List listOverrideDescriptors = new ArrayList();
			listOverrideDescriptors.add("override-web.xml");
			context.setOverrideDescriptors(listOverrideDescriptors);

			server.setHandler(context);			
			server.start();
		}
	}

	@AfterClass
	public static void afterTestClass() throws Exception {
	}

	@Before
	public void setUp() {

		// Database Setup
		XlsDataFileLoader xlsDataFileLoader = new XlsDataFileLoader();
		DataSets dataSetsAnnotation = this.getClass().getAnnotation(DataSets.class);
		if (dataSetsAnnotation!=null) {
			IDataSet dataSet = xlsDataFileLoader.load(dataSetsAnnotation.setUpDataSet());
			dataTester.setDataSet(dataSet);
			try {			
				dataTester.onSetup();
			} catch (Exception ex) {
				fail("Terjadi kesalahan [" + ex.getMessage() + "]");
			}
		}

		// Selenium Setup
		WebDriver driver = new FirefoxDriver();
		String baseUrl = "http://localhost:8181/";
		selenium = new WebDriverBackedSelenium(driver, baseUrl);
	}

	@After
	public void tearDown() {
		if (dataTester!=null) {
			try {
				dataTester.onTearDown();
			} catch (Exception ex) {
				fail("Terjadi kesalahan [" + ex.getMessage() + "]");
			}
		}
		if (selenium!=null) {
			selenium.stop();
		}
	}
}

Kode program pada method beforeTestClass() yang diberi annotation @BeforeClass akan dikerjakan oleh JUnit setelah instance class untuk pengujian dibuat.  Di method ini, saya membuat sebuah database in-memory H2, lalu membuat embedded  Jetty dan melewatkan data source H2 melalui JNDI sehingga bisa dipergunakan oleh aplikasi yang diuji yang berjalan di dalam Jetty.   Pada method ini saya melakukan penjagaan null untuk memastikan bahwa database dan server Jetty hanya dibuat 1 kali saja.

Method afterTestClass() yang diberi annotation @AfterClass akan dikerjakan setelah seluruh test case pada object tersebut selesai dikerjakan.  Saya tidak melakukan apa-apa disini.  Saya tidak menutup database dan server Jetty, karena mereka masih diperlukan untuk class berikutnya.  Saya tidak tahu class mana yang dikerjakan terakhir kali.  Lalu kapan database dan server Jetty akan ditutup?  Pada saat pengujian berakhir dimana  JVM JUnit dimatikan, segala sesuatu yang dibuat oleh JVM  termasuk database dan server akan dibuang dari memory secara otomatis.

Saya masih perlu membuat sebuah class lagi, yaitu annotation DataSets.  Saya melihat trik memakai DataSets ini di buku JUnit in Action (2nd edition) dan buku Spring 3 Pro.  DataSets adalah sebuah annotation yang berisi informasi file Excel apa yang akan di-load ke database oleh DbUnit.  Setiap sheet di Excel mewakili sebuah tabel, setiap kolom dalam sheet mewakili field di tabel, dan setiap baris dalam sheet mewakili record di tabel.  Pada kedua buku tersebut, DataSets akan diperiksa untuk setiap method.  Akan tetapi karena saya tidak menggunakan SpringJUnit4ClassRunner seperti di buku, saya tidak bisa memeriksa per-method.  Jalan terbaik yang bisa saya lakukan adalah memeriksa per-class.  Berikut ini adalah isi annotation DataSets saya:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface DataSets {

	String setUpDataSet() default "";

}

Sekarang, aplikasi saya bisa berjalan pada 2 container, yaitu secara normal di tc Server (Tomcat 6) dan selama pengujian selenium di Jetty.  Jika aplikasi saya berjalan di tc Server, ia akan mengakses database MySQL.  Jika aplikasi saya berjalan di Jetty, maka ia akan mengakses database in-memory H2.  Hal ini dapat diatur dengan mudah berkat Spring profiles.  Berikut ini adalah konfigurasi Spring saya:

...
<bean id="parentJpaProperties" abstract="true" class="org.springframework.beans.factory.config.PropertiesFactoryBean">
 <property name="properties">
   <props>
     <prop key="hibernate.max_fetch_depth">3</prop>
     <prop key="hibernate.jdbc.fetch_size">50</prop>
     <prop key="hibernate.jdbc.batch_size">10</prop>
     <prop key="hibernate.id.new_generator_mappings">true</prop>
     <prop key="hibernate.ejb.naming_strategy">org.hibernate.cfg.ImprovedNamingStrategy</prop>
   </props>
 </property>
</bean>

<beans profile="dev,production">
 <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" >
   <property name="driverClassName" value="com.mysql.jdbc.Driver" />
   <property name="url" value="jdbc:mysql://localhost:3306/database" />
   <property name="username" value="user" />
   <property name="password" value="password" />
 </bean>

 <bean id="jpaProperties" parent="parentJpaProperties">
   <property name="properties">
     <props merge="true">
       <prop key="hibernate.dialect">org.hibernate.dialect.MySQLDialect</prop>
     </props>
   </property>
 </bean>
</beans>

<beans profile="selenium-test">
 <jee:jndi-lookup id="dataSource" jndi-name="java:comp/env/jdbc/testdatabase" />

 <bean id="jpaProperties" parent="parentJpaProperties">
   <property name="properties">
     <props merge="true">
       <prop key="hibernate.dialect">org.hibernate.dialect.H2Dialect</prop>
       <prop key="hibernate.hbm2ddl.auto">create-drop</prop> 
     </props>
   </property>
 </bean>
</beans>
...

Informasi mengenai deklarasi inheritance yang saya pakai dapat dilihat di artikel Memakai Ulang Props Di Definisi XML Spring.  Yang jelas, pada saat aplikasi dijalankan dalam container Jetty, Spring profile yang aktif haruslah selenium-test.

Pertanyaan bagaimana membuat Spring profile selenium-test menjadi aktif saat aplikasi dijalankan dalam container Jetty?  Informasi lebih lanjut dapat dilihat di artikel Mengatur Spring Profile Untuk Embedded Jetty.   Berikut ini adalah isi file override-web.xml saya adalah:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" version="2.5">

  <context-param>
    <param-name>spring.profiles.active</param-name>
    <param-value>selenium-test</param-value>
  </context-param>

  <resource-ref>
    <res-ref-name>jdbc/testdatabase</res-ref-name>
    <res-type>javax.sql.DataSource</res-type>
    <res-auth>Container</res-auth>
  </resource-ref>

</web-app>

Setelah ini, saya bisa membuat file Excel yang berisi data yang akan dimasukkan ke database secara otomatis setiap kali sebuah method di test case dikerjakan.  File tersebut juga diletakkan di src/test/resources.  Pada akhirnya, isi folder tersebut akan terlihat seperti (file log4j.xml dan StressTest.jmx tidak termasuk dalam pembahasan di tulisan ini):

Isi folder src/test/resources

Isi folder src/test/resources

Terakhir, saya tinggal membuat class  yang memakai Selenium untuk menguji tampilan.  Berikut ini adalah contoh test case yang saya buat:

import static org.junit.Assert.*;
import org.dbunit.dataset.ITable;
import org.junit.Test;

@DataSets(setUpDataSet="/dataNormal.xls")
public class AkunSayaTest extends AbstractSeleniumTest {
  @Test
  public void testUpdateProfilUntukKaryawan() throws Exception {

  // Login Sebagai User PEGAWAI
  selenium.open("/proyek/");
  selenium.waitForPageToLoad("30000");
  selenium.click("link=Login");
  selenium.waitForPageToLoad("30000");
  selenium.type("id=login_user", "lena@gmail.com");
  selenium.type("id=login_pass", "lenalovejocki");
  selenium.click("name=login");
  selenium.waitForPageToLoad("30000");

  // Membuka Menu Akun Saya
  selenium.open("/proyek/#&panel1-2");
  selenium.click("link=Akun Saya");
  selenium.waitForPageToLoad("30000");

  // Men-klik menu Edit Profil
  selenium.click("id=editProfil");
  selenium.waitForCondition("JL.dialogUtama['dialogUser'].is(':visible')==true", "1000");

  // Memastikan tampilan di dialog benar
  assertEquals("lena@gmail.com", selenium.getValue("id=email"));
  // .. dan sebagainya ...

  // Melakukan perubahan data		
  selenium.type("id=nama", "Jona Junior");
  selenium.click("id=frmUserbtnSimpan");
  selenium.waitForCondition("JL.dialogUtama['dialogUser'].is(':visible')==false", "1000");

  // Memastikan data sudah tersimpan di database
  assertEquals("Jona Junior", dataTester.getConnection().
     createQueryTable("user", "select * from user where email='lena@gmail.com'").getValue(0, "nama"));

  // Kembali membuka menu Edit Profile
  selenium.click("id=editProfil");
  selenium.waitForCondition("JL.dialogUtama['dialogUser'].is(':visible')==true", "1000");

  // Memastikan tampilan di dialog benar
  assertEquals("lena@gmail.com", selenium.getValue("id=email"));
  // .. dan sebagainya ..
 }
}

Kode program pengujian front-end saya sekarang bisa lebih singkat dan lebih mudah dipahami.  Selain itu , kini saya  tidak perlu khawatir lagi untuk menambah/mengubah data sebelum dan setelah pengujian.

Memakai Jetty sebagai embedded web server

Jetty adalah sebuah web server yang unik. ¬†Hal ini karena Jetty dapat dipakai sebagai embedded web server. ¬†Apa maksudnya? ¬†Biasanya pada aplikasi web, program yang telah kita buat perlu di-‘copy‘ ke sebuah container (web server). ¬†Tapi embedded web server adalah kebalikannya. ¬† Aplikasi kita didalamnya telah mengandung web server, sehingga kita tidak perlu memindahkan kode program ke¬†web server lagi. ¬†Lalu apa gunanya? Embedded web server¬†dapat dipakai pada pengujian aplikasi dimana aplikasi akan dijalankan ¬†pada embedded web¬†¬†server¬†yang¬†tidak membutuhkan banyak setup¬†serta tidak ‘berat.

Sebagai contoh, saya akan menggunakan Jetty untuk front-end testing secara otomatis.  Proyek saya berjalan pada Cloud Foundry yang memakai Tomcat 6.  Dengan demikian, versi Jetty yang paling mendekati adalah Jetty 7 yang masih memakai spesifikasi Servlet 2.5 sama seperti Tomcat 6.  Sebagai informasi, Jetty sudah mencapai versi 9 yang mendukung spesifikasi Servlet 3.0 seperti Tomcat 7.

Karena memakai Apache Maven, saya dapat menambahkan dependency ke Jetty secara mudah.  Agar mudah, saya menambahkan seluruh JAR yang dibutuhkan (versi aggregate), dengan informasi seperti berikut ini:

Group Id:  org.eclipse.jetty.aggregate
Artifact Id: jetty-all
Version: 7.6.7.v20120910
Scope: test

Disini saya memakai scope test,  yang menunjukkan bahwa artifact JAR Jetty hanya dibutuhkan untuk pengujian saja, bukan untuk menjalankan aplikasi saya.

Saya juga memastikan bahwa servlet-api dan jsp-api sudah ada di dependency Maven:

Group Id: javax.servlet
Artifact Id: servlet-api
Version: 2.5
Scope: provided

Group Id: javax.servlet.jsp
Artifact Id: jsp-api
Version: 2.1
Scope: provided

Saya memakai scope provided, yang menunjukkan bahwa kedua artifact tersebut dibutuhkan untuk melakukan kompilasi kode program, tetapi tidak perlu disertakan sebagai bagian dari proyek karena mereka telah disediakan oleh server.

Dan terakhir, karena implementasi JSP yang saya pakai adalah Jasper,  saya perlu menambahkan artifact Juli (untuk logging di Tomcat) pada dependency Maven:

Group Id: org.apache.tomcat
Artifact Id: juli
Version: 6.0.36
Scope: test

Setelah memastikan  Maven telah men-download semua artifact JAR yang dibutuhkan, saya bisa membuat kode program.  Sebagai contoh, berikut ini adalah penggalan kode program yang menjalankan Jetty sebagai embedded web server:

...
Server server = new Server (8080);
WebAppContext context = new WebAppContext();
context.setDescriptor("../FolderProyek/src/main/webapp/WEB-INF/web.xml");
context.setResourceBase("../FolderProyek/src/main/webapp");
context.setContextPath("/proyek");
context.setParentLoaderPriority(true);
server.setHandler(context);
server.start();
...

Semua proyek web Java selalu memiliki file web.xml. ¬†Yang saya lakukan adalah memberitahu lokasi web.xml yang akan dijalankan pada Jetty. ¬†Selain itu, saya juga memberi tahu lokasi folder yang berisi file HTML, JSP, dan sebagainya (resource base). ¬†Dengan setContextPath(“/proyek”) maka Jetty dapat dipanggil dengan URL seperti http://localhost:8080/proyek.

Sekarang, bila kode program di atas selesai dikerjakan, saya dapat langsung membuka http://localhost:8080/proyek tanpa perlu meng-install  server seperti Tomcat atau GlassFish.

Proyek lain yang cukup menarik adalah Cargo (informasi ada di http://cargo.codehaus.org).  Dengan Cargo, saya bisa memberikan deskripsi server yang akan saya pakai  (misalnya Tomcat, Jetty, GlassFish, dan sebagainya) di kode program.  Nantinya, Cargo yang akan men-download dan men-install server yang dibutuhkan bila belum ada di komputer dimana program saya berjalan, kemudian Cargo akan memindahkan hasil build program secara otomatis.  Jadi bila saya ingin beralih dari Tomcat ke GlassFish, saya hanya perlu mengubah deskripsi server yang saya pakai.  Contoh kegunaan Cargo adalah pada server Continous Integration (CI).  Server CI akan men-checkout hasil kerja masing-masing programmer yang telah digabungkan, lalu menjalankan function testing secara otomatis.  Server CI bekerja secara periodik dan otomatis sehingga bila ditemui kesalahan, developer akan secepat mungkin mendapat notifikasi.