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. Panduan 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 tabel 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?

Bukan unit test (terlalu lebar):
  func TestRegisterUser(t *testing.T) {
      // setup database connection
      db := connectToTestDB()
      // setup HTTP server
      server := startTestServer(db)
      // kirim HTTP request
      resp := http.Post(server.URL + "/register", ...)
      // cek database
      user := db.Query("SELECT * FROM users WHERE email = ?")
      assert.NotNil(t, user)
  }
  → Ini integration test, bukan unit test

Unit test yang benar:
  func TestRegisterUser_EmailAlreadyExists(t *testing.T) {
      // fake repository — tidak butuh database
      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)
  }
  → Berjalan dalam milidetik, tidak butuh infrastruktur

Sifat utama unit test yang baik dirangkum dalam prinsip FIRST:

F — Fast         Ribuan test harus selesai dalam hitungan detik
I — Independent  Test tidak bergantung pada test lain atau state global
R — Repeatable   Hasil selalu sama, di mesin mana pun, kapan pun
S — Self-validating  Pass atau fail jelas, tidak butuh interpretasi manual
T — Timely       Ditulis bersama atau sesaat setelah kode produksi

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

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

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 #

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
class FakeProductRepository implements ProductRepository {
  final Map<String, Product> _products = {};
  bool shouldThrow = false;

  void addProduct(Product product) => _products[product.id] = product;

  @override
  Future<Product?> findById(String id) async {
    if (shouldThrow) throw Exception('Database error');
    return _products[id];
  }

  @override
  Future<void> save(Product product) async {
    if (shouldThrow) throw Exception('Database error');
    _products[product.id] = product;
  }
}

// Test menggunakan fake
void main() {
  late FakeProductRepository fakeRepo;
  late FakeCartRepository fakeCart;
  late CartService cartService;

  setUp(() {
    // Dipanggil sebelum setiap test — state selalu bersih
    fakeRepo = FakeProductRepository();
    fakeCart = FakeCartRepository();
    cartService = CartService(
      productRepository: fakeRepo,
      cartRepository: fakeCart,
    );
  });

  group('CartService.addItem', () {
    test('adds product to cart when product exists', () async {
      // Arrange
      fakeRepo.addProduct(Product(
        id: 'prod-1',
        name: 'Laptop',
        price: 15_000_000,
        stock: 10,
      ));

      // Act
      await cartService.addItem('user-1', 'prod-1', quantity: 2);

      // Assert
      final cart = await fakeCart.findByUserId('user-1');
      expect(cart, isNotNull);
      expect(cart!.items.length, equals(1));
      expect(cart.items.first.productId, equals('prod-1'));
      expect(cart.items.first.quantity, equals(2));
    });

    test('throws ProductNotFoundException when product not found', () async {
      // Tidak ada product di fakeRepo

      expect(
        () => cartService.addItem('user-1', 'nonexistent', quantity: 1),
        throwsA(isA<ProductNotFoundException>()),
      );
    });

    test('throws InsufficientStockException when stock is insufficient', () async {
      fakeRepo.addProduct(Product(
        id: 'prod-2',
        name: 'Headphone',
        price: 500_000,
        stock: 1, // hanya 1 stok
      ));

      expect(
        () => cartService.addItem('user-1', 'prod-2', quantity: 5), // minta 5
        throwsA(isA<InsufficientStockException>()),
      );
    });
  });

  // Parameterized test untuk multiple skenario
  group('CartService.calculateTotal', () {
    final testCases = [
      (items: <CartItem>[], expected: 0),
      (items: [CartItem(productId: 'p1', price: 100_000, quantity: 1)],
       expected: 100_000),
      (items: [
        CartItem(productId: 'p1', price: 100_000, quantity: 2),
        CartItem(productId: 'p2', price: 50_000, quantity: 3),
      ], expected: 350_000),
    ];

    for (final tc in testCases) {
      test('calculates total correctly for ${tc.items.length} items', () async {
        final total = cartService.calculateTotal(tc.items);
        expect(total, equals(tc.expected));
      });
    }
  });
}

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.

Coverage 80% bisa berarti dua hal yang sangat berbeda:

Versi 1 — 80% bermakna:
  Semua happy path diuji
  Semua error path yang critical diuji
  Edge case yang penting diuji
  Test yang ada benar-benar memverifikasi behavior

Versi 2 — 80% sia-sia:
  Test ada tapi tidak ada assertion (hanya memanggil method)
  Test memverifikasi implementasi, bukan behavior
  Test berhasil meski logic salah karena assertion lemah

Lebih baik 70% coverage dengan test yang benar-benar menangkap bug
daripada 95% coverage dengan test yang tidak memproteksi apa pun.

Yang sebaiknya diprioritaskan untuk di-cover:

WAJIB diuji:
  □ Semua business rule dan kondisional
  □ Error path: apa yang terjadi jika dependency gagal?
  □ Boundary condition: nilai minimum, maksimum, nol, string kosong
  □ Kasus yang pernah menyebabkan bug production

OPSIONAL (bukan prioritas utama):
  □ Getter/setter sederhana tanpa logic
  □ Kode framework boilerplate
  □ Log 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:

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.

                    / \
                   / E2E \         Sedikit, lambat, mahal
                  /       \        Uji alur user end-to-end
                 /---------\
                /Integration\      Sedang, lebih lambat
               /             \     Uji interaksi antar komponen
              /---------------\
             /    Unit Test    \   Banyak, sangat cepat, murah
            /                   \  Uji satu unit terisolasi
           /---------------------\


Proporsi yang direkomendasikan:
  Unit test:        70%
  Integration test: 20%
  E2E test:         10%

Contoh perbedaan cakupan untuk fitur “Transfer Saldo”:

Unit test (milisecond):
  → Apakah balance dikurangi dengan benar?
  → Apakah error dilempar jika balance tidak cukup?
  → Apakah event TransferCompleted dipublish?

Integration test (detik):
  → Apakah data tersimpan ke database dengan benar?
  → Apakah transaksi di-rollback saat ada error?

E2E test (menit):
  → Apakah 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

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