Unit Test #
Unit testing is the most talked-about and most half-heartedly executed tool in software engineering. Teams without unit tests spend long hours debugging bugs that should have been caught in milliseconds. Teams with unit tests but poorly written ones — tests too tied to implementation, tests needing a real database, tests where it’s unclear what’s being tested — carry a maintenance burden that isn’t worth the protection they provide. Good unit tests are the opposite: fast, isolated, focused on one behavior, and as easy to read as documentation. This article covers unit testing from its foundational principles, the difference between mocks, stubs, and fakes with concrete examples, Go and Dart/Flutter implementations with production-used patterns, table-driven tests for efficiently covering many scenarios, meaningful coverage, and the anti-patterns that most often destroy the value of a test suite.
What Is a Unit Test? #
A unit test is testing the smallest unit of code — usually one function or method — in isolation, without depending on external systems like databases, networks, or filesystems. What’s being tested is behavior: given a certain input, is the output as expected? Is the right error thrown under certain conditions?
flowchart LR
subgraph NotUnit["❌ Not a Unit Test — Too Broad"]
T1[Test] --> DB1[("(Real Database)")]
T1 --> SRV1["Real HTTP Server"]
T1 --> SMTP1["Real SMTP"]
Note1["Slow, flaky,\nneeds infrastructure"]
end
subgraph Unit["✅ A Proper Unit Test"]
T2[Test] --> FAKE["Fake Repository\nin-memory"]
T2 --> SVC[UserService]
SVC --> FAKE
Note2["Fast < 1ms,\ndeterministic"]
end// ANTI-PATTERN: this is an integration test, not a unit test
func TestRegisterUser(t *testing.T) {
db := connectToTestDB() // needs a real database
server := startTestServer(db) // needs a real HTTP server
resp := http.Post(server.URL+"/register", ...) // network call
user := db.Query("SELECT * FROM users WHERE email = ?")
assert.NotNil(t, user)
}
// CORRECT: a true unit test — isolated, fast, deterministic
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)
}
The core characteristics of good unit tests are summarized in the FIRST principles:
flowchart TD
FIRST[FIRST Principles] --> F["Fast\nThousands of tests finish\nin seconds"]
FIRST --> I["Independent\nDoesn't depend on\nother tests / global state"]
FIRST --> R["Repeatable\nSame result\non any machine"]
FIRST --> S["Self-validating\nClear pass/fail,\nno manual interpretation"]
FIRST --> T["Timely\nWritten with or\nright after the code"]| Principle | What It Means | Consequence If Violated |
|---|---|---|
| Fast | Thousands of tests finish in seconds | Test suite rarely runs, slow feedback loop |
| Independent | Doesn’t depend on other tests or global state | Random failures, execution order affects results |
| Repeatable | Same result on any machine, any time | Flaky tests, “works on my machine” |
| Self-validating | Clear pass/fail without manual interpretation | Engineers must read logs manually to know the result |
| Timely | Written with/right after production code | Tests written late, often skipped under deadline pressure |
Mock, Stub, and Fake — Not the Same Thing #
These three terms are often used interchangeably, but they differ significantly in how they’re used.
flowchart TD
TD[Test Double] --> ST["Stub\nReturns static data\nDoesn't care about input or call count"]
TD --> MK["Mock\nCan be verified:\nwas it called? how many times? with what arguments?"]
TD --> FK["Fake\nA real but lightweight implementation\nUsually in-memory"]
ST --> U1["Good for: simple dependencies\nthat only need to return a value"]
MK --> U2["Good for: verifying interactions\n(was the email sent?)"]
FK --> U3["Good for: main dependencies\nlike repositories — more realistic"]A stub returns predetermined data — it doesn’t care how many times it’s called or with what arguments; it always returns the same value.
// Stub — only returns static data
type StubUserRepository struct{}
func (s *StubUserRepository) FindByEmail(_ context.Context, email string) (*User, error) {
// Always returns the same user, regardless of input
return &User{ID: "1", Email: "[email protected]", Name: "Test"}, nil
}
A mock is an object that can be verified — you can check whether a specific method was called, how many times, and with what arguments.
// Mock — can be configured AND verified
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) // ← record for verification
return nil
}
// In the test: verify the interaction
func TestRegister_SendsWelcomeEmail(t *testing.T) {
mockEmailer := &MockEmailSender{}
service := NewUserService(&StubUserRepository{}, mockEmailer)
service.Register(ctx, RegisterRequest{Email: "[email protected]"})
// Verify: the email was sent exactly once to the right address
assert.Len(t, mockEmailer.SentEmails, 1)
assert.Equal(t, "[email protected]", mockEmailer.SentEmails[0])
}
A fake is a simple implementation that actually works, but uses a lighter mechanism than the production implementation — usually in-memory.
// Fake — a real but in-memory implementation
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
}
// The fake behaves like a real repository, but needs no database
// Can be used across many different tests with independent state
Practical guidance: use fakes for main dependencies like repositories (more realistic), mocks when you need to verify interactions (was the email actually sent?), and stubs for simple dependencies that only need to return a value.
Go Implementation #
Go provides a built-in testing framework that’s already very complete. Here are the patterns commonly used in production.
Good Test Structure — Arrange, Act, Assert #
flowchart LR
A["Arrange\nSet up fakes/mocks,\ncreate the service, set initial conditions"] --> B["Act\nRun the code under test"]
B --> C["Assert\nVerify the result:\noutput, error, interactions"]func TestUserService_GetActiveUser_Success(t *testing.T) {
// ARRANGE — prepare everything needed
fakeRepo := NewFakeUserRepository()
fakeRepo.Save(ctx, &User{
ID: "user-1",
Email: "[email protected]",
Name: "Active User",
IsActive: true,
})
service := NewUserService(fakeRepo, &MockEmailSender{})
// ACT — run the code under test
user, err := service.GetActiveUser(ctx, "user-1")
// ASSERT — verify the result
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() // empty — no users
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 Tests — The Most Efficient Pattern in Go #
When there are many different scenarios, table-driven tests are far more efficient and easier to extend than separate test functions.
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)
})
}
}
The advantage of table-driven tests: adding a new scenario only requires adding one entry to the slice, without writing a new function. Descriptive test names also help quickly identify failures.
Testing with Dependencies That Return Errors #
// A fake repository configurable to 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: how does the service behave when the repository fails?
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",
})
// The service must propagate the error from the repository
assert.Error(t, err)
assert.Contains(t, err.Error(), "database connection lost")
}
Dart/Flutter Implementation #
Flutter uses the built-in flutter_test package, with patterns similar to Go but more idiomatic syntax.
// Fake repository for 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
}
// Tests using the 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) {
// No product in 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}) // only 1 in stock
err := cartService.AddItem("user-1", "prod-2", 5) // asks for 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 — Meaningful Numbers vs Just a Target #
Coverage is the most misunderstood metric. High coverage doesn’t mean the code is safe — low coverage means there are areas not tested at all.
flowchart TD
C[Coverage 80%] --> V1["Version 1 — Meaningful"]
C --> V2["Version 2 — Pointless"]
V1 --> V1A[All happy paths tested]
V1 --> V1B[All critical error paths tested]
V1 --> V1C[Important edge cases tested]
V1 --> V1D["Assertions genuinely\nverify behavior"]
V2 --> V2A["Tests exist but\nwithout assertions"]
V2 --> V2B["Tests verify implementation,\nnot behavior"]
V2 --> V2C["Tests pass even\nwhen the logic is wrong"]Better 70% coverage with tests that actually catch bugs than 95% coverage with tests that protect nothing. Coverage is an indicator, not a goal.
What should be prioritized for coverage:
| Priority | What to Test |
|---|---|
| Mandatory | All business rules and conditionals |
| Mandatory | Error paths: what happens if a dependency fails? |
| Mandatory | Boundary conditions: minimum, maximum, zero, empty string |
| Mandatory | Cases that have caused production bugs |
| Optional | Simple getters/setters without logic |
| Optional | Framework boilerplate code |
| Optional | Log statements |
Running coverage in Go:
# See coverage per function
go test ./... -cover
# Create an HTML report for visual analysis
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
# Coverage with the race detector (mandatory in CI)
go test -race ./...
Test-Driven Development — Writing Tests Before Code #
TDD changes the order: write the test first, then the implementation. The Red-Green-Refactor cycle:
flowchart LR
R["🔴 Red\nWrite a failing test\nbecause there's no implementation yet"] --> G["🟢 Green\nMinimal implementation\nto make the test pass"]
G --> RF["🔵 Refactor\nImprove the code without\nchanging behavior"]
RF --> R// Red: Write a failing test
func TestTransfer_InsufficientBalance(t *testing.T) {
account := Account{Balance: 100_000}
err := account.Transfer(200_000, "target-account")
assert.ErrorIs(t, err, ErrInsufficientBalance)
}
// → This test will FAIL because Transfer isn't implemented yet
// Green: Minimal implementation that makes the test pass
func (a *Account) Transfer(amount int64, toID string) error {
if a.Balance < amount {
return ErrInsufficientBalance
}
a.Balance -= amount
return nil
}
// → Test PASSES
// Refactor: improve the code without changing behavior
// → Clearer names, better error handling, etc.
// → Tests still PASS because behavior didn't change
A benefit of TDD that isn’t always recognized: because tests are written before the implementation, you’re forced to think about interfaces and behavior before thinking about implementation. This naturally encourages better design.
Unit Test vs Integration Test vs E2E Test #
The three test levels complement each other and occupy different positions in the testing pyramid.
flowchart TD
E2E["E2E Tests\nFew, slow, expensive\nTest end-to-end user flows\n~10%"]
INT["Integration Tests\nMedium, slower\nTest component interactions\n~20%"]
UNIT["Unit Tests\nMany, very fast, cheap\nTest one isolated unit\n~70%"]
UNIT --> INT
INT --> E2E| Level | Speed | Scope for “Transfer Balance” |
|---|---|---|
| Unit Test (milliseconds) | Very fast | Is the balance correctly reduced? Is an error thrown if the balance is insufficient? Is the TransferCompleted event published? |
| Integration Test (seconds) | Medium | Is the data correctly saved to the database? Is the transaction rolled back on error? |
| E2E Test (minutes) | Slow | Can the user complete a transfer from the UI? |
Unit Test Anti-Patterns to Avoid #
// ✗ Tests without assertions — always-green tests that protect nothing
func TestCalculateDiscount(t *testing.T) {
service := NewDiscountService()
service.Calculate(100_000, "PROMO10") // no assertion!
}
// ✓ Always assert results, errors, and if needed, interactions
// ✗ Tests depending on execution order
var globalCounter int
func TestA(t *testing.T) { globalCounter++ }
func TestB(t *testing.T) {
assert.Equal(t, 1, globalCounter) // depends on TestA running first
}
// ✓ Every test must be runnable independently in any order
// ✗ Tests verifying implementation, not behavior
func TestCreateOrder(t *testing.T) {
mockRepo := &MockOrderRepository{}
service := NewOrderService(mockRepo)
service.CreateOrder(req)
// Only checks that repo.Save was called — not whether the order is correct
verify(mockRepo).Save(any()) // ← this doesn't prove correct behavior
}
// ✓ Verify behavior (output, error, state), not just call counts
// ✗ Non-descriptive test names
func TestOrder1(t *testing.T) { ... }
func TestOrder2(t *testing.T) { ... }
// ✓ Names that explain the scenario and expected behavior
func TestCreateOrder_WithExpiredPromoCode_ReturnsError(t *testing.T) { ... }
// ✗ Tests that are too big — one test covering many things
func TestUserWorkflow(t *testing.T) {
// register, login, update profile, change password, delete account
// all in one test — if one fails, it's hard to know which
}
// ✓ One test, one behavior
// ✗ Very long, complex setup — a sign the code is hard to test
func TestSomething(t *testing.T) {
db := setupTestDatabase()
redis := setupTestRedis()
kafka := setupTestKafka()
server := setupTestServer(db, redis, kafka)
// 30 lines of setup before the actual test
}
// ✓ If setup is too long, the production code needs refactoring to be more testable
Unit Test Checklist #
FIRST PRINCIPLES:
□ Every test finishes in milliseconds, no real I/O
□ Tests runnable in any order or in parallel
□ Test results always the same on any machine
□ Clear pass/fail without needing to read logs manually
TEST DOUBLES:
□ Repositories faked with in-memory implementations
□ Important interactions (email, notifications, payment) mocked and verified
□ Simple dependencies stubbed with static values
STRUCTURE:
□ Every test follows the Arrange-Act-Assert pattern
□ Descriptive test names: explain the scenario and expected behavior
□ Repeating scenarios use table-driven tests
COVERAGE:
□ All business rules and conditionals tested
□ Error paths of every dependency tested
□ Boundary conditions (zero, empty, maximum) tested
□ Past production bugs have regression tests
CI/CD:
□ go test -race runs in every pipeline
□ Coverage reports generated and trends monitored
□ Test suite is a gate before merge
Summary #
- Unit tests test one unit of code in isolation — fast (milliseconds), deterministic, and infrastructure-free.
- FIRST principles: Fast, Independent, Repeatable, Self-validating, Timely — a checklist for evaluating test quality.
- Three test double types: stubs (static data), mocks (verifiable), fakes (real but lightweight implementations, usually in-memory) — each has a different use case.
- Table-driven tests in Go are the most efficient pattern for testing many scenarios — add a new scenario by adding an entry to the slice.
- Dependency Injection is a prerequisite for good unit tests — without DI, dependencies can’t be swapped for mocks, forcing unit tests to become integration tests.
- Meaningful coverage: prioritize business rules, error paths, and boundary conditions — not just chasing percentage numbers.
- TDD (Red-Green-Refactor) forces thinking about behavior before implementation, naturally producing better and more testable designs.
- The testing pyramid: many unit tests (~70%), medium integration tests (~20%), few E2E tests (~10%) — unit tests are the fastest and cheapest.
- Main anti-patterns: tests without assertions, dependence on execution order, verifying implementation instead of behavior, non-descriptive names, and one test covering too much.