Belajar TDD di Go
Akhirnya kesampaian juga cita-cita saya menjadi programmer backend. Penantian selama 4 tahun berakhir sudah. Nah, sekalian mumpung ambisi TDD saya belum luntur, marilah kita jelajahi bagaimana kita bisa menerapkan TDD di Go.
Berkaca dari testing framework idaman
Kita sering lupa bahwa kode-kode tes juga perlu dijaga kebersihan dan kerapiannya. Tes adalah pengejawantahan spesifikasi pengguna ke dalam kode. Kalo tesnya ruwet, tentu saja kita bakalan pusing untuk faham bagaimana cara kerja dari kode produksi yang kita tulis. Nah, ada satu struktur yang menurut saya enak untuk menulis tes. Struktur seperti ini pertama kali diperkenalkan di RSpec sebelum diadopsi oleh yang lain-lain seperti catch2.
# RSpec
describe "callbacks" do
describe "before validation" do
let(:blog) { create(:blog) }
context "when slug is not set" do
it "generates it" do
item = Post.new(blog: blog)
item.validate
expect(item.slug).not_to be_nil
end
end
context "when slug is set" do
it "does not change it" do
item = Post.new(blog: blog)
item.slug = "in the darkness"
item.validate
expect(item.slug).to eq "in the darkness"
end
end
end
end
//catch2
SCENARIO( "vector can be sized and resized" ) {
GIVEN( "An empty vector" ) {
auto v = std::vector<std::string>{};
// Validate assumption of the GIVEN clause
THEN( "The size and capacity start at 0" ) {
REQUIRE( v.size() == 0 );
REQUIRE( v.capacity() == 0 );
}
// Validate one use case for the GIVEN object
WHEN( "push_back() is called" ) {
v.push_back("hullo");
THEN( "The size changes" ) {
REQUIRE( v.size() == 1 );
REQUIRE( v.capacity() >= 1 );
}
}
}
}
Mereka memang agak beda. RSpec menggunakan describe
, context
, dan it
sementara catch2 menggunakan GIVEN
, WHEN
, dan THEN
. Keduanya sama-sama bergaya BDD. Kata-kata Given-When-Then dicetuskan oleh Dan North, si pemrakarsa BDD, pada tahun 2006. Satu tahun sebelumnya, teman main si Dan North, Steven Baker meluncurkan RSpec. Semangat yang melandasi keduanya sama, bagaimana caranya beranjak dari terlalu banyak menyebut kata test dan bagaimana bisa mengungkapkan spesifikasi (karena tadi tidak boleh menyebut kata test) dalam kalimat. Hasil akhirnya adalah struktur bertingkat seperti yang ada di dua contoh di atas.
Saat ini, BDD telah berkembang lebih jauh menjadi ATDD. Di antara artefak teknisnya adalah Cucumber dan Gherkin. Saya tidak demen sama Gherkin dan tidak suka melakukan ATDD. BDD jaman sekarang, yang disuruh menulis spesifikasi itu langsung pengguna atau si domain expert. Pendapat saya, yang kayak begitu nggak bakal jalan dengan mulus. Lihat saja, FitNesse buatan Uncle Bob jauh kalah laku ketimbang buku-buku dan video-videonya. Pada akhirnya, yang menulis Gherkin dan ATDD programmer sendiri. Kalau begitu, mengapa Gherkin tidak dihilangkan saja sekalian?
Itulah testing framework idaman saya. Mau ngoding bahasa apapun, saya selalu mencari testing framework yang seperti ini. Syarat-syaratnya ada tiga:
-
Bisa menulis spesifikasi dengan kalimat biasa
-
Bisa menyusun spesifikasi-spesifikasi dalam konteks-konteks yang berbeda secara bertingkat
-
Bisa menulis kode untuk Setup / Teardown.
Syarat yang ketiga ini muncul supaya kita tidak perlu memanggil fungsi untuk mempersiapkan jalannya tes kita secara berulang-ulang. Biasanya, fungsinya dinamai dengan "BeforeEach", "BeforeAll", "AfterEach", dan "AfterAll". Fungsi "BeforeEach" akan dijalankan setiap kali tes yang ada di dalam konteks itu dijalankan. "BeforeAll" hanya dijalankan sekali sebelum ada tes di dalam konteks itu yang dijalankan. "AfterEach" dijalankan setelah setiap tes, sementara "AfterAll" dijalankan sekali setelah semua tes selesai.
Menilik pengalaman bahasa-bahasa yang lalu
Sebelum Go, saya sudah mencoba menerapkan TDD model ini dalam beberapa bahasa: C++, Java, C#, Python, dan Lua. Kesimpulan sementara saya, tidak ada yang lebih bagus dan lebih matang daripada catch2 punya C++. Mengherankan memang. Why can’t we have all the nice things in the world?. Padahal, sebenarnya membuat testing framework itu tidak susah-susah amat. Sebagai contoh, di Lua ada framework yang namanya telescope, itu cuma satu file.
Java
Pertama kali saya mencoba gaya BDD secara diam-diam adalah di Java dengan JUnit-nya. Ya, saya tahu bahwa Java ada JBehave dan Cucumber. Masalahnya, mereka harus ada file terpisah buat menuliskan spesifikasinya sebelum diperinci menjadi langkah-langkah dalam bentuk kode. Ribet. Akhirnya, saya waktu itu menulis yang mirip seperti di bawah ini:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.EmptyStackException;
import java.util.Stack;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@DisplayName("A stack")
class TestingAStackDemo {
Stack<Object> stack;
@Test
@DisplayName("is instantiated with new Stack()")
void isInstantiatedWithNew() {
new Stack<>();
}
@Nested
@DisplayName("when new")
class WhenNew {
@BeforeEach
void createNewStack() {
stack = new Stack<>();
}
@Test
@DisplayName("is empty")
void isEmpty() {
assertTrue(stack.isEmpty());
}
@Test
@DisplayName("throws EmptyStackException when popped")
void throwsExceptionWhenPopped() {
assertThrows(EmptyStackException.class, stack::pop);
}
@Test
@DisplayName("throws EmptyStackException when peeked")
void throwsExceptionWhenPeeked() {
assertThrows(EmptyStackException.class, stack::peek);
}
@Nested
@DisplayName("after pushing an element")
class AfterPushing {
String anElement = "an element";
@BeforeEach
void pushAnElement() {
stack.push(anElement);
}
@Test
@DisplayName("it is no longer empty")
void isNotEmpty() {
assertFalse(stack.isEmpty());
}
@Test
@DisplayName("returns the element when popped and is empty")
void returnElementWhenPopped() {
assertEquals(anElement, stack.pop());
assertTrue(stack.isEmpty());
}
@Test
@DisplayName("returns the element when peeked but remains not empty")
void returnElementWhenPeeked() {
assertEquals(anElement, stack.peek());
assertFalse(stack.isEmpty());
}
}
}
}
Masih harus nulis dua kali, di display name dan method name. Tapi, ternyata itu bisa diakali dengan DisplayNameGenerator
. Baru tahu saya.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.IndicativeSentencesGeneration;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class DisplayNameGeneratorDemo {
@Nested
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class A_year_is_not_supported {
@Test
void if_it_is_zero() {
}
@DisplayName("A negative value for year is not supported by the leap year computation.")
@ParameterizedTest(name = "For example, year {0} is not supported.")
@ValueSource(ints = { -1, -4 })
void if_it_is_negative(int year) {
}
}
@Nested
@IndicativeSentencesGeneration(separator = " -> ", generator = DisplayNameGenerator.ReplaceUnderscores.class)
class A_year_is_a_leap_year {
@Test
void if_it_is_divisible_by_4_but_not_by_100() {
}
@ParameterizedTest(name = "Year {0} is a leap year.")
@ValueSource(ints = { 2016, 2020, 2048 })
void if_it_is_one_of_the_following_years(int year) {
}
}
}
Boleh lah.
C#
Yang rada katrok malah justru C#. Duh, padahal bahasa ibu saya ini. Ngoding Go pun saya masih nerjemahin dari C# dulu. Iya, saya tahu kalo ada SpecFlow. Tapi, lagi-lagi pokoknya saya gak mau pake. Ada nspec ataupun mspec memang, tapi mereka sudah lama tidak diurus. Yang tersisa hanyalah xUnit dan NUnit, dua makhluk yang belum mati-mati sejak dulu.
Akhirnya, saya cuma bisa begini…
using NUnit.Framework;
namespace My.Namespace
{
public class Given_a_context
{
[SetUp]
public void SetUp()
{
}
[TestFixture]
public class And_another_context : Given_a_context
{
[Test]
public void When_some_condition__Then_do_something()
{
}
[Test]
public void When_some_other_condition__Then_do_something_else())
{
}
}
}
}
Cuman, harus hati-hati kalo mau pake trik turunan begini. Klas yang luar gak boleh ada test method sama sekali. Kalo nggak, nanti bakalan jadi duplikat dengan klas turunannya dan bisa jadi selalu gagal tesnya karena ada kontradiksi dengan klas turunannya.
Python
Waktu kemarin saya pakai unittest
, saya belum ngeh kalau ada yang namanya subTest. Akibatnya, malah jadi lebih terbatas lagi dari yang C#. Coba saya tahu ada subTest, kan jadi bisa ngoding begini:
class Given_a_context(unittest.TestCase):
def test_And_a_sub_context(self):
with self.subTest('When condition, then do something'):
pass
with self.subTest('When another condition, then do something else'):
pass
def test_And_another_sub_context(self):
with self.subTest('When different condition, then something happens'):
pass
Memang, masih ada awalan test_
yang mengganggu. Tapi, biarlah.
Testing di Go, bisa apa?
Paket bawaan yang keren
Yang membuat saya terkejut senang, modul testing bawaan Go ini ternyata sudah punya fitur-fitur keren
-
TestMain
Ini kalo kita pengen melakukan setup dan teardown sekali saja ketika mulai semua tes dan ketika semua tes sudah selesai
func TestMain(m *testing.M) { log.Println("Do stuff BEFORE the tests!") exitVal := m.Run() log.Println("Do stuff AFTER the tests!") os.Exit(exitVal) }
-
Susunan tes bertingkat
Ini membuat Go jadi hampir sama bagusnya dengan
catch2
. Di banyak framework lain, kita hanya bisa nulis kode di blok terdalam. Di blok luar, yang bisa kita lakukan cuma mendeklarasikan variabel. Tapi, kalau di Go, sudah dari sononya dia bisa nulis kode di tingkatan blok manapunfunc TestGivenAContext(t *testing.T) { t.Run("And a subcontext", func(t *testing.T) { Setup() defer Teardown() t.Run("When a sub-subcontext, Then something", func(t *testing.T) { }) t.Run("When another sub-subcontext, Then something else", func(t *testing.T) { }) }) t.Run("When another subcontext", func(t *testing.T) { }) }
-
Code coverage dan benchmark
Ini lagi yang keren di Go. Mereka juga sudah menyediakan fasilitas untuk code coverage dan benchmark. Bisa dibilang, testing di Go ini hampir mendekati paket komplit.
Kok ya masih butuh assertion library?
Akan tetapi, assertion di Go masih ribet
func TestSomething (t *testing.T) {
if something(2) != “some” {
t.Error(“Expected returned string to be 'some'”)
}
}
Ini anehnya. Kalau sedari awal testing di Go ada library untuk assertion, harusnya nulis tes bisa lebih enak. Salah satu solusinya adalah dengan package bernama testify
func TestSomething (t *testing.T) {
assert.Equal(t, something(2), "some")
}
Bisa lebih keren lagi nggak ya?
Tentu saja bisa! Dua solusi populer ada goconvey
dan ginkgo
. Bintang mereka sama-sama udah lebih dari lima ribu. Saya baca sekilas, Ginkgo fiturnya jauh lebih lengkap. Awalnya, saya mau coba pake Ginkgo aja. Akan tetapi, setelah melihat "Getting Started" yang terlalu ribet, akhirnya gak jadi. Ya sudah, saya pake GoConvey aja.
Kode di bawah ini:
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestSpec(t *testing.T) {
// Only pass t into top-level Convey calls
Convey("Given some integer with a starting value", t, func() {
x := 1
Convey("When the integer is incremented", func() {
x++
Convey("The value should be greater by one", func() {
So(x, ShouldEqual, 2)
})
})
})
}
Hasilnya jadi begini kalo pake go test -v
:
=== RUN TestSpec Given some integer with a starting value When the integer is incremented The value should be greater by one ✔ 1 total assertion --- PASS: TestSpec (0.00s) PASS ok mbuh 0.462s
Asyik kan bacanya?
Selanjutnya apa?
Akan tetapi, setelah dipikir-pikir, kan ya goconvey
itu "cuma" beda cara assert aja dan outputnya "cuma" sedikit lebih enak dibaca. Udah gitu, nggak ada integrasi ke GoLand, nggak kayak t.Run()
. Ngapain saya pake nyobain GoConvey segala? Harusnya kan all out aja pake Ginkgo, atau malah pake testify
aja sudah cukup.
Gak tau lah. Udah PW, males ganti-ganti lagi.