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.