Unit Test #

Unit test adalah alat yang paling sering dibicarakan dan paling sering dikerjakan setengah-setengah dalam software engineering. Tim yang tidak punya unit test menghabiskan jam-jam panjang untuk debug bug yang seharusnya terdeteksi dalam milidetik. Tim yang punya unit test tapi ditulis dengan buruk — test yang terlalu terikat pada implementasi, test yang butuh database nyata, test yang tidak jelas apa yang sedang diuji — mendapat beban maintenance yang tidak sebanding dengan proteksi yang diberikan. Unit test yang baik adalah sebaliknya: cepat, terisolasi, fokus pada satu behavior, dan mudah dibaca seperti dokumentasi. Artikel ini membahas unit test dari prinsip dasarnya, perbedaan mock, stub, dan fake dengan contoh konkret, implementasi di Go dan Dart/Flutter dengan pola-pola yang digunakan di production, pola table-driven test untuk menguji banyak skenario secara efisien, coverage yang bermakna, hingga anti-pattern yang paling sering merusak nilai sebuah test suite.

Apa Itu Unit Test? #

Unit test adalah pengujian terhadap unit terkecil dari kode — biasanya satu fungsi atau method — secara terisolasi, tanpa bergantung pada sistem eksternal seperti database, jaringan, atau filesystem. Yang diuji adalah perilaku (behavior): diberi input tertentu, apakah outputnya sesuai yang diharapkan? Apakah error yang tepat dilempar pada kondisi tertentu?

flowchart LR
    subgraph NotUnit["❌ Bukan Unit Test — Terlalu Lebar"]
        T1[Test] --> DB1[("(Database\nnyata)")]
        T1 --> SRV1["HTTP Server\nnyata"]
        T1 --> SMTP1["SMTP\nnyata"]
        Note1["Lambat, flaky,\nbutuh infrastruktur"]
    end
    subgraph Unit["✅ Unit Test yang Benar"]
        T2[Test] --> FAKE["Fake Repository\nin-memory"]
        T2 --> SVC[UserService]
        SVC --> FAKE
        Note2["Cepat < 1ms,\ndeterministik"]
    end
// ANTI-PATTERN: ini integration test, bukan unit test
func TestRegisterUser(t *testing.T) {
    db := connectToTestDB()              // butuh database nyata
    server := startTestServer(db)        // butuh HTTP server nyata
    resp := http.Post(server.URL+"/register", ...) // network call
    user := db.Query("SELECT * FROM users WHERE email = ?")
    assert.NotNil(t, user)
}

// BENAR: unit test sejati — terisolasi, cepat, deterministik
func TestRegisterUser_EmailAlreadyExists(t *testing.T) {
    repo := &FakeUserRepository{
        existingEmails: map[string]bool{"[email protected]": true},
    }
    service := NewUserService(repo, &FakeEmailSender{})

    _, err := service.Register(context.Background(), RegisterRequest{
        Email: "[email protected]",
        Name:  "Test User",
    })

    assert.ErrorIs(t, err, ErrEmailAlreadyExists)
}

Sifat utama unit test yang baik dirangkum dalam prinsip FIRST:

flowchart TD
    FIRST[Prinsip FIRST] --> F["Fast\nRibuan test selesai\ndalam hitungan detik"]
    FIRST --> I["Independent\nTidak bergantung\ntest lain / state global"]
    FIRST --> R["Repeatable\nHasil selalu sama\ndi mesin manapun"]
    FIRST --> S["Self-validating\nPass/fail jelas,\ntanpa interpretasi manual"]
    FIRST --> T["Timely\nDitulis bersama\natau segera setelah kode"]
PrinsipArtinyaKonsekuensi Jika Dilanggar
FastRibuan test selesai dalam detikTest suite jarang dijalankan, feedback loop lambat
IndependentTidak bergantung test lain atau state globalTest gagal acak, urutan eksekusi mempengaruhi hasil
RepeatableHasil selalu sama di mesin manapun, kapanpunFlaky test, “works on my machine”
Self-validatingPass/fail jelas tanpa interpretasi manualEngineer harus baca log manual untuk tahu hasil
TimelyDitulis bersama/segera setelah kode produksiTest ditulis belakangan, sering dilewati saat deadline

Mock, Stub, dan Fake — Bukan Hal yang Sama #

Tiga istilah ini sering digunakan bergantian padahal punya perbedaan yang signifikan dalam cara penggunaannya.

flowchart TD
    TD[Test Double] --> ST["Stub\nMengembalikan data statis\nTidak peduli input/jumlah call"]
    TD --> MK["Mock\nBisa diverifikasi:\napakah dipanggil? berapa kali? argumen apa?"]
    TD --> FK["Fake\nImplementasi nyata tapi ringan\nBiasanya in-memory"]
    ST --> U1["Cocok: dependency sederhana\nyang hanya perlu return value"]
    MK --> U2["Cocok: verifikasi interaksi\n(apakah email terkirim?)"]
    FK --> U3["Cocok: dependency utama\nseperti repository — lebih realistis"]

Stub mengembalikan data yang telah ditentukan sebelumnya — ia tidak peduli berapa kali dipanggil atau dengan argumen apa, selalu mengembalikan nilai yang sama.

// Stub — hanya mengembalikan data statis
type StubUserRepository struct{}

func (s *StubUserRepository) FindByEmail(_ context.Context, email string) (*User, error) {
    // Selalu mengembalikan user yang sama, tidak peduli input
    return &User{ID: "1", Email: "[email protected]", Name: "Test"}, nil
}

Mock adalah objek yang bisa diverifikasi — kamu bisa mengecek apakah method tertentu dipanggil, berapa kali, dan dengan argumen apa.

// Mock — bisa dikonfigurasi DAN diverifikasi
type MockEmailSender struct {
    SentEmails []string
    ShouldFail bool
}

func (m *MockEmailSender) Send(_ context.Context, to, subject, body string) error {
    if m.ShouldFail {
        return errors.New("SMTP connection failed")
    }
    m.SentEmails = append(m.SentEmails, to) // ← rekam untuk verifikasi
    return nil
}

// Di test: verifikasi interaksi
func TestRegister_SendsWelcomeEmail(t *testing.T) {
    mockEmailer := &MockEmailSender{}
    service := NewUserService(&StubUserRepository{}, mockEmailer)

    service.Register(ctx, RegisterRequest{Email: "[email protected]"})

    // Verifikasi: email dikirim tepat satu kali ke alamat yang benar
    assert.Len(t, mockEmailer.SentEmails, 1)
    assert.Equal(t, "[email protected]", mockEmailer.SentEmails[0])
}

Fake adalah implementasi sederhana yang benar-benar bekerja, tapi menggunakan mekanisme yang lebih ringan dari implementasi produksi — biasanya in-memory.

// Fake — implementasi nyata tapi in-memory
type FakeUserRepository struct {
    users map[string]*User
    mu    sync.RWMutex
}

func NewFakeUserRepository() *FakeUserRepository {
    return &FakeUserRepository{users: make(map[string]*User)}
}

func (f *FakeUserRepository) Save(_ context.Context, user *User) error {
    f.mu.Lock()
    defer f.mu.Unlock()
    f.users[user.Email] = user
    return nil
}

func (f *FakeUserRepository) FindByEmail(_ context.Context, email string) (*User, error) {
    f.mu.RLock()
    defer f.mu.RUnlock()
    if user, ok := f.users[email]; ok {
        return user, nil
    }
    return nil, ErrUserNotFound
}

// Fake bekerja seperti repository nyata, tapi tidak butuh database
// Bisa digunakan di banyak test berbeda dengan state yang independen
Panduan praktis: gunakan fake untuk dependency utama seperti repository (lebih realistis), mock ketika perlu memverifikasi interaksi (apakah email benar-benar dikirim?), dan stub untuk dependency sederhana yang hanya perlu mengembalikan nilai tertentu.

Implementasi di Go #

Go menyediakan testing framework bawaan yang sudah sangat lengkap. Berikut pola-pola yang umum digunakan di production.

Struktur Test yang Baik — Arrange, Act, Assert #

flowchart LR
    A["Arrange\nSiapkan fake/mock,\nbuat service, set kondisi awal"] --> B["Act\nJalankan kode yang diuji"]
    B --> C["Assert\nVerifikasi hasil:\noutput, error, interaksi"]
func TestUserService_GetActiveUser_Success(t *testing.T) {
    // ARRANGE — persiapkan semua yang dibutuhkan
    fakeRepo := NewFakeUserRepository()
    fakeRepo.Save(ctx, &User{
        ID:       "user-1",
        Email:    "[email protected]",
        Name:     "Active User",
        IsActive: true,
    })
    service := NewUserService(fakeRepo, &MockEmailSender{})

    // ACT — jalankan kode yang diuji
    user, err := service.GetActiveUser(ctx, "user-1")

    // ASSERT — verifikasi hasilnya
    assert.NoError(t, err)
    assert.Equal(t, "Active User", user.Name)
    assert.True(t, user.IsActive)
}

func TestUserService_GetActiveUser_UserNotFound(t *testing.T) {
    fakeRepo := NewFakeUserRepository() // kosong — tidak ada user
    service := NewUserService(fakeRepo, &MockEmailSender{})

    _, err := service.GetActiveUser(ctx, "nonexistent-id")

    assert.ErrorIs(t, err, ErrUserNotFound)
}

func TestUserService_GetActiveUser_UserInactive(t *testing.T) {
    fakeRepo := NewFakeUserRepository()
    fakeRepo.Save(ctx, &User{
        ID: "user-2", IsActive: false,
    })
    service := NewUserService(fakeRepo, &MockEmailSender{})

    _, err := service.GetActiveUser(ctx, "user-2")

    assert.ErrorIs(t, err, ErrUserInactive)
}

Table-Driven Test — Pola Paling Efisien di Go #

Ketika ada banyak skenario yang berbeda, table-driven test jauh lebih efisien dan mudah diperluas daripada fungsi test terpisah.

func TestCalculateOrderDiscount(t *testing.T) {
    tests := []struct {
        name             string
        orderTotal       int64
        membershipTier   string
        promoCode        string
        expectedDiscount int64
        expectError      bool
    }{
        {
            name:             "no discount for non-member",
            orderTotal:       100_000,
            membershipTier:   "",
            promoCode:        "",
            expectedDiscount: 0,
        },
        {
            name:             "silver member gets 5% discount",
            orderTotal:       100_000,
            membershipTier:   "silver",
            promoCode:        "",
            expectedDiscount: 5_000,
        },
        {
            name:             "gold member gets 10% discount",
            orderTotal:       100_000,
            membershipTier:   "gold",
            promoCode:        "",
            expectedDiscount: 10_000,
        },
        {
            name:             "promo code adds flat 20k discount",
            orderTotal:       200_000,
            membershipTier:   "silver",
            promoCode:        "SAVE20K",
            expectedDiscount: 30_000, // 5% + 20k
        },
        {
            name:           "invalid promo code returns error",
            orderTotal:     100_000,
            membershipTier: "",
            promoCode:      "INVALID",
            expectError:    true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            service := NewDiscountService(NewFakePromoCodeRepository())

            discount, err := service.Calculate(tt.orderTotal, tt.membershipTier, tt.promoCode)

            if tt.expectError {
                assert.Error(t, err)
                return
            }
            assert.NoError(t, err)
            assert.Equal(t, tt.expectedDiscount, discount)
        })
    }
}

Keunggulan table-driven test: menambahkan skenario baru hanya perlu menambahkan satu entry di slice, tanpa menulis fungsi baru. Nama test yang deskriptif juga membantu identifikasi kegagalan dengan cepat.

Test dengan Dependency yang Mengembalikan Error #

// Fake repository yang bisa dikonfigurasi untuk fail
type ConfigurableFakeRepo struct {
    saveError error
    findError error
    data      map[string]*User
}

func (r *ConfigurableFakeRepo) Save(_ context.Context, user *User) error {
    if r.saveError != nil {
        return r.saveError
    }
    r.data[user.ID] = user
    return nil
}

// Test: bagaimana service berperilaku ketika repository gagal?
func TestRegisterUser_RepositoryFailure(t *testing.T) {
    failingRepo := &ConfigurableFakeRepo{
        saveError: errors.New("database connection lost"),
        data:      make(map[string]*User),
    }
    service := NewUserService(failingRepo, &MockEmailSender{})

    _, err := service.Register(ctx, RegisterRequest{
        Email: "[email protected]",
        Name:  "Test User",
    })

    // Service harus propagate error dari repository
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "database connection lost")
}

Implementasi di Dart/Flutter #

Flutter menggunakan flutter_test package yang sudah built-in, dengan pattern yang mirip dengan Go tapi menggunakan syntax yang lebih idiomatis.

// Fake repository untuk testing
type FakeProductRepository struct {
    products    map[string]*Product
    shouldThrow bool
}

func (f *FakeProductRepository) AddProduct(product *Product) {
    f.products[product.ID] = product
}

func (f *FakeProductRepository) FindByID(_ context.Context, id string) (*Product, error) {
    if f.shouldThrow {
        return nil, errors.New("Database error")
    }
    if p, ok := f.products[id]; ok {
        return p, nil
    }
    return nil, ErrProductNotFound
}

func (f *FakeProductRepository) Save(_ context.Context, product *Product) error {
    if f.shouldThrow {
        return errors.New("Database error")
    }
    f.products[product.ID] = product
    return nil
}

// Test menggunakan fake
func TestCartService_AddItem(t *testing.T) {
    fakeRepo := &FakeProductRepository{products: make(map[string]*Product)}
    fakeCart := NewFakeCartRepository()
    cartService := NewCartService(fakeRepo, fakeCart)

    t.Run("adds product to cart when product exists", func(t *testing.T) {
        // Arrange
        fakeRepo.AddProduct(&Product{ID: "prod-1", Name: "Laptop", Price: 15_000_000, Stock: 10})

        // Act
        cartService.AddItem("user-1", "prod-1", 2)

        // Assert
        cart, err := fakeCart.FindByUserID("user-1")
        assert.NoError(t, err)
        assert.Len(t, cart.Items, 1)
        assert.Equal(t, "prod-1", cart.Items[0].ProductID)
        assert.Equal(t, 2, cart.Items[0].Quantity)
    })

    t.Run("throws ProductNotFoundException when product not found", func(t *testing.T) {
        // Tidak ada product di fakeRepo
        err := cartService.AddItem("user-1", "nonexistent", 1)
        assert.ErrorIs(t, err, ErrProductNotFound)
    })

    t.Run("throws InsufficientStockException when stock is insufficient", func(t *testing.T) {
        fakeRepo.AddProduct(&Product{ID: "prod-2", Name: "Headphone", Price: 500_000, Stock: 1}) // hanya 1 stok

        err := cartService.AddItem("user-1", "prod-2", 5) // minta 5
        assert.ErrorIs(t, err, ErrInsufficientStock)
    })
}

func TestCartService_CalculateTotal(t *testing.T) {
    cartService := NewCartService(&FakeProductRepository{products: make(map[string]*Product)}, NewFakeCartRepository())

    tests := []struct {
        items    []*CartItem
        expected int64
    }{
        {items: nil, expected: 0},
        {items: []*CartItem{{ProductID: "p1", Price: 100_000, Quantity: 1}}, expected: 100_000},
        {items: []*CartItem{
            {ProductID: "p1", Price: 100_000, Quantity: 2},
            {ProductID: "p2", Price: 50_000, Quantity: 3},
        }, expected: 350_000},
    }

    for _, tt := range tests {
        assert.Equal(t, tt.expected, cartService.CalculateTotal(tt.items))
    }
}

Test Coverage — Angka yang Bermakna vs Sekedar Target #

Coverage adalah metrik yang paling sering disalahpahami. Coverage tinggi tidak berarti kode aman — coverage rendah berarti ada area yang tidak teruji sama sekali.

flowchart TD
    C[Coverage 80%] --> V1["Versi 1 — Bermakna"]
    C --> V2["Versi 2 — Sia-sia"]
    V1 --> V1A[Semua happy path diuji]
    V1 --> V1B[Semua error path kritis diuji]
    V1 --> V1C[Edge case penting diuji]
    V1 --> V1D["Assertion benar-benar\nmemverifikasi behavior"]
    V2 --> V2A["Test ada tapi\ntanpa assertion"]
    V2 --> V2B["Test verifikasi implementasi,\nbukan behavior"]
    V2 --> V2C["Test pass meski\nlogic salah"]
Lebih baik 70% coverage dengan test yang benar-benar menangkap bug daripada 95% coverage dengan test yang tidak memproteksi apa pun. Coverage adalah indikator, bukan tujuan.

Yang sebaiknya diprioritaskan untuk di-cover:

PrioritasYang Diuji
WajibSemua business rule dan kondisional
WajibError path: apa yang terjadi jika dependency gagal?
WajibBoundary condition: nilai minimum, maksimum, nol, string kosong
WajibKasus yang pernah menyebabkan bug production
OpsionalGetter/setter sederhana tanpa logic
OpsionalKode framework boilerplate
OpsionalLog statement

Jalankan coverage di Go:

# Lihat coverage per function
go test ./... -cover

# Buat HTML report untuk analisa visual
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html

# Coverage dengan race detector (wajib di CI)
go test -race ./...

Test-Driven Development — Menulis Test Sebelum Kode #

TDD mengubah urutan: tulis test dulu, baru implementasi. Siklus Red-Green-Refactor:

flowchart LR
    R["🔴 Red\nTulis test yang gagal\nkarena belum ada implementasi"] --> G["🟢 Green\nImplementasi minimal\nagar test pass"]
    G --> RF["🔵 Refactor\nPerbaiki kode tanpa\nmengubah behavior"]
    RF --> R
// Red: Tulis test yang gagal
func TestTransfer_InsufficientBalance(t *testing.T) {
    account := Account{Balance: 100_000}
    err := account.Transfer(200_000, "target-account")
    assert.ErrorIs(t, err, ErrInsufficientBalance)
}
// → Test ini akan FAIL karena Transfer belum diimplementasikan

// Green: Implementasi minimal yang membuat test pass
func (a *Account) Transfer(amount int64, toID string) error {
    if a.Balance < amount {
        return ErrInsufficientBalance
    }
    a.Balance -= amount
    return nil
}
// → Test PASS

// Refactor: perbaiki kode tanpa mengubah behavior
// → Nama lebih jelas, error handling lebih baik, dst.
// → Test tetap PASS karena behavior tidak berubah

Manfaat TDD yang tidak selalu disadari: karena test ditulis sebelum implementasi, kamu terpaksa berpikir tentang interface dan behavior sebelum memikirkan implementasi. Ini secara alami mendorong design yang lebih baik.


Unit Test vs Integration Test vs E2E Test #

Ketiga level test saling melengkapi dan punya posisi yang berbeda dalam piramida test.

flowchart TD
    E2E["E2E Test\nSedikit, lambat, mahal\nUji alur user end-to-end\n~10%"]
    INT["Integration Test\nSedang, lebih lambat\nUji interaksi antar komponen\n~20%"]
    UNIT["Unit Test\nBanyak, sangat cepat, murah\nUji satu unit terisolasi\n~70%"]
    UNIT --> INT
    INT --> E2E
LevelKecepatanCakupan untuk “Transfer Saldo”
Unit Test (milidetik)Sangat cepatApakah balance dikurangi dengan benar? Apakah error dilempar jika balance tidak cukup? Apakah event TransferCompleted dipublish?
Integration Test (detik)SedangApakah data tersimpan ke database dengan benar? Apakah transaksi di-rollback saat ada error?
E2E Test (menit)LambatApakah user bisa transfer dari UI sampai selesai?

Anti-Pattern Unit Test yang Harus Dihindari #

// ✗ Test tanpa assertion — test yang selalu hijau, tidak memproteksi apa pun
func TestCalculateDiscount(t *testing.T) {
    service := NewDiscountService()
    service.Calculate(100_000, "PROMO10") // tidak ada assert!
}
// ✓ Selalu assert hasil, error, dan jika perlu, interaksi

// ✗ Test yang bergantung pada urutan eksekusi
var globalCounter int
func TestA(t *testing.T) { globalCounter++ }
func TestB(t *testing.T) {
    assert.Equal(t, 1, globalCounter) // bergantung pada TestA dijalankan dulu
}
// ✓ Setiap test harus bisa dijalankan independen dalam urutan apapun

// ✗ Test yang memverifikasi implementasi, bukan behavior
func TestCreateOrder(t *testing.T) {
    mockRepo := &MockOrderRepository{}
    service := NewOrderService(mockRepo)
    service.CreateOrder(req)
    // Hanya cek bahwa repo.Save dipanggil — tidak cek apakah order benar
    verify(mockRepo).Save(any()) // ← ini tidak membuktikan behavior yang benar
}
// ✓ Verifikasi behavior (output, error, state) bukan sekadar call count

// ✗ Nama test yang tidak deskriptif
func TestOrder1(t *testing.T) { ... }
func TestOrder2(t *testing.T) { ... }
// ✓ Nama yang menjelaskan skenario dan expected behavior
func TestCreateOrder_WithExpiredPromoCode_ReturnsError(t *testing.T) { ... }

// ✗ Test yang terlalu besar — satu test menguji banyak hal
func TestUserWorkflow(t *testing.T) {
    // register, login, update profile, change password, delete account
    // semua dalam satu test — jika satu gagal, sulit tahu yang mana
}
// ✓ Satu test, satu behavior

// ✗ Setup yang sangat panjang dan kompleks — tanda kode sulit di-test
func TestSomething(t *testing.T) {
    db := setupTestDatabase()
    redis := setupTestRedis()
    kafka := setupTestKafka()
    server := setupTestServer(db, redis, kafka)
    // 30 baris setup sebelum actual test
}
// ✓ Jika setup terlalu panjang, kode produksi perlu di-refactor agar lebih testable

Checklist Unit Test #

PRINSIP FIRST:
  □ Setiap test selesai dalam milidetik, tidak ada I/O nyata
  □ Test bisa dijalankan dalam urutan apapun atau paralel
  □ Hasil test selalu sama di mesin manapun
  □ Pass/fail jelas tanpa perlu membaca log manual

TEST DOUBLE:
  □ Repository di-fake dengan in-memory implementation
  □ Interaksi penting (email, notifikasi, payment) di-mock dan diverifikasi
  □ Dependency sederhana di-stub dengan nilai statis

STRUKTUR:
  □ Setiap test mengikuti pola Arrange-Act-Assert
  □ Nama test deskriptif: menjelaskan skenario dan expected behavior
  □ Skenario berulang menggunakan table-driven test

COVERAGE:
  □ Semua business rule dan kondisional teruji
  □ Error path dari setiap dependency teruji
  □ Boundary condition (nol, kosong, maksimum) teruji
  □ Bug production yang pernah terjadi punya regression test

CI/CD:
  □ go test -race dijalankan di setiap pipeline
  □ Coverage report dihasilkan dan dipantau trennya
  □ Test suite menjadi gate sebelum merge

Ringkasan #

  • Unit test menguji satu unit kode secara terisolasi — cepat (milidetik), deterministik, dan tidak butuh infrastruktur.
  • Prinsip FIRST: Fast, Independent, Repeatable, Self-validating, Timely — checklist untuk mengevaluasi kualitas test.
  • Tiga jenis test double: stub (data statis), mock (bisa diverifikasi), fake (implementasi nyata tapi ringan, biasanya in-memory) — masing-masing punya use case berbeda.
  • Table-driven test di Go adalah pola paling efisien untuk menguji banyak skenario — tambah skenario baru hanya dengan menambah entry di slice.
  • Dependency Injection adalah prasyarat unit test yang baik — tanpa DI, dependency tidak bisa diganti mock, unit test terpaksa jadi integration test.
  • Coverage yang bermakna: prioritaskan business rule, error path, dan boundary condition — bukan sekedar kejar angka persentase.
  • TDD (Red-Green-Refactor) memaksa berpikir tentang behavior sebelum implementasi, secara alami menghasilkan design yang lebih baik dan testable.
  • Piramida test: unit test banyak (~70%), integration test sedang (~20%), E2E sedikit (~10%) — unit test paling cepat dan paling murah.
  • Anti-pattern utama: test tanpa assertion, bergantung urutan eksekusi, memverifikasi implementasi bukan behavior, nama tidak deskriptif, dan satu test terlalu banyak hal.

← Sebelumnya: DLQ   Berikutnya: Big O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact