Throughout my engineering career, I’ve witnessed teams celebrate hitting “100% coverage” while shipping code riddled with bugs. This fixation on a single metric often leads teams astray, mistaking code execution for comprehensive testing.

After spending years deep in the work, setting up production systems and guiding teams in different places, I’ve developed a nuanced perspective on code coverage that I’d like to share with you today—especially as it applies to Go, where the testing philosophy often differs from languages like C# or Java.

What Is Code Coverage, Really?

Code coverage measures the percentage of your codebase that’s executed during testing. It’s a metric that can provide insights into untested areas, but it tells you nothing about test quality. It seems straightforward, but different languages approach it differently.

In Go, the philosophy is refreshingly simple: a line is covered if it’s executed at least once.
This contrasts with other languages that may track branch coverage, condition coverage, and path coverage separately.

When you run:

go test -cover ./...
9c7bccb36e489fd422332d55a7d9012d12341d8d-6060112

Go reports a percentage showing executed lines. But this simplicity is both a strength and a weakness.

Understanding Coverage: Go vs Other Languages

While Go’s approach to code coverage is straightforward (a line is covered if executed once), it’s worth understanding how this differs from other languages to appreciate both the strengths and limitations of Go’s approach.

In languages like C# and Java, tools such as JaCoCo or dotCover provide more granular metrics:

  • Line coverage: Was each line executed? (Similar to Go’s basic approach)
  • Branch coverage: Were all branches of control structures (if/else, switch) taken?
  • Condition coverage: Was each boolean subexpression evaluated to both true and false?
  • Path coverage: Were all possible paths through a function executed?

Go’s built-in tooling focuses primarily on line coverage, which makes reaching “100% coverage” seem more attainable but potentially misleading. Without branch and condition coverage, Go’s metrics might miss critical test gaps that would be obvious in other languages.
This distinction is important because it affects how we should interpret and use coverage metrics in Go projects. The simplicity of Go’s coverage model means we need to be even more thoughtful about test quality and completeness beyond what the coverage number tells us.

The False Promise of 100% Coverage

There are several fundamental problems with chasing 100% coverage:

1. Coverage Doesn’t Measure Test Quality

Consider this real-world example:

package users

import (
    "errors"
    "fmt"
)

// Database interface represents methods to interact with user storage
type Database interface {
    FetchUser(id string) (*UserData, error)
}

// UserData represents information about a user
type UserData struct {
    ID    string
    Name  string
    Email string
}

// GetUserData retrieves user information from the database
func GetUserData(db Database, id string) (*UserData, error) {
    if id == "" {
        return nil, errors.New("empty ID")
    }
    
    data, err := db.FetchUser(id)
    if err != nil {
        return nil, fmt.Errorf("database error: %w", err)
    }
    
    return data, nil
}

In code reviews, I’ve seen teams write “tests” like this:

package users

import (
    "testing"
    
    "github.com/stretchr/testify/assert"
)

// MockDB is a test double for the Database interface
type MockDB struct{}

func (m *MockDB) FetchUser(id string) (*UserData, error) {
    return &UserData{ID: id, Name: "Test User"}, nil
}

func TestGetUserData(t *testing.T) {
    db := &MockDB{}
    user, err := GetUserData(db, "user-123")
    
    // Proper test assertions
    assert.NoError(t, err)
    assert.NotNil(t, user)
    assert.Equal(t, "user-123", user.ID)
    assert.Equal(t, "Test User", user.Name)
}

func TestGetUserDataEmptyID(t *testing.T) {
    db := &MockDB{}
    user, err := GetUserData(db, "")
    
    assert.Error(t, err)
    assert.Nil(t, user)
    assert.Equal(t, "empty ID", err.Error())
}
d4255718c1867bf62c4bddbe0674d12e81f57269-7997888

This achieves code coverage but provides zero confidence that the function works correctly.

It doesn’t verify that:

  • Valid IDs return correct data
  • Empty IDs properly trigger errors
  • Database errors are handled correctly
  • The returned data matches expectations

2. Some Code Paths Are Difficult to Test

Consider error handling for rare conditions:

package payment

import (
    "errors"
    "log"
    "time"
)

// Gateway represents a payment processing service
type Gateway interface {
    Process(tx Transaction) (*Result, error)
}

// Transaction represents a payment transaction
type Transaction struct {
    ID     string
    Amount float64
}

// Result represents the outcome of a payment transaction
type Result struct {
    Success bool
    RefID   string
}

// NetworkError represents a network-related issue
type NetworkError struct {
    Message string
}

func (e NetworkError) Error() string {
    return e.Message
}

// ProcessTransaction handles a payment transaction with retry logic
func ProcessTransaction(gateway Gateway, tx Transaction) error {
    // Normal processing...
    
    // Network call to payment gateway
    result, err := gateway.Process(tx)
    if err != nil {
        if _, ok := err.(NetworkError); ok {
            // Retry once after a delay
            time.Sleep(2 * time.Second)
            result, err = gateway.Process(tx)
            if err != nil {
                log.Printf("Transaction failed after retry: %v", err)
                return errors.New("payment processing failed")
            }
        } else {
            log.Printf("Transaction failed: %v", err)
            return errors.New("payment processing failed")
        }
    }
    
    if !result.Success {
        return errors.New("transaction was declined")
    }
    
    return nil
}

Testing the retry logic requires:

  • Mocking the gateway to fail with a network error
  • Verifying it was called twice
  • Ensuring the delay happened

This is difficult, and many teams either skip testing these paths or write fragile tests that break with refactoring—all to chase coverage numbers.

3. Coverage Tells You What Code Ran, Not If It’s Correct

package pricing

// CalculateDiscount determines the discount amount based on user type
func CalculateDiscount(price float64, userType string) float64 {
    var discount float64
    
    switch userType {
    case "new":
        discount = 0.1
    case "regular":
        discount = 0.05
    case "vip":
        discount = 0.15
    }
    
    return price * discount
}

A test that calls this function with all user types would show 100% coverage, but what if the discount calculation is wrong? Or if the VIP discount should be 0.2 instead of 0.15? Coverage tells you nothing about these functional correctness issues.

How Teams Cheat on Coverage

When 100% coverage becomes the goal, teams find creative ways to achieve it without actually improving quality:

1. Writing Tests Without Assertions

As shown earlier, you can achieve high coverage without a single meaningful assertion.

2. Excluding “Hard-to-Test” Code

Many languages allow excluding code from coverage calculations:

// Go
//go:build !test
// +build !test

// C#
[ExcludeFromCodeCoverage]

// JavaScript
/* istanbul ignore next */

This artificially inflates coverage percentages without improving quality.

3. Focusing on Easy Paths

Teams chase coverage by extensively testing simple code paths while ignoring complex scenarios that are more likely to contain bugs.

Beyond Coverage: Effective Testing Strategies

Instead of chasing coverage percentages, focus on these more valuable approaches:

1. Domain-Driven Testing

Test the domain cases that matter to your application, not just lines of code:

package validation

import (
    "testing"
    
    "github.com/stretchr/testify/assert"
)

func TestUserValidation(t *testing.T) {
    testCases := []struct {
        name        string
        user        User
        expectedErr string
    }{
        {
            name: "valid user passes validation",
            user: User{
                Name:  "John Doe",
                Age:   25,
                Email: "[email protected]",
            },
            expectedErr: "",
        },
        {
            name: "empty name fails validation",
            user: User{
                Name:  "",
                Age:   25,
                Email: "[email protected]",
            },
            expectedErr: "name is required",
        },
        {
            name: "underage user fails validation",
            user: User{
                Name:  "Young User",
                Age:   16,
                Email: "[email protected]",
            },
            expectedErr: "must be 18 or older",
        },
        {
            name: "invalid email fails validation",
            user: User{
                Name:  "Bad Email",
                Age:   30,
                Email: "not-an-email",
            },
            expectedErr: "invalid email",
        },
    }
    
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            err := ValidateUser(tc.user)
            if tc.expectedErr == "" {
                assert.NoError(t, err)
            } else {
                assert.EqualError(t, err, tc.expectedErr)
            }
        })
    }
}

This approach ensures that your tests validate what matters to your users and stakeholders, not just exercising code paths.

2. Property-Based Testing

For complex algorithms or data transformations, property-based testing can find edge cases humans might miss:

package sorting

import (
    "sort"
    "testing"
    
    "github.com/stretchr/testify/assert"
    "github.com/leanovate/gopter"
    "github.com/leanovate/gopter/prop"
    "github.com/leanovate/gopter/gen"
)

func TestSortedListProperties(t *testing.T) {
    parameters := gopter.DefaultTestParameters()
    parameters.MinSuccessfulTests = 1000
    
    properties := gopter.NewProperties(parameters)
    
    properties.Property("adding element to sorted list maintains sortedness", prop.ForAll(
        func(list []int, newElement int) bool {
            // Start with a sorted list
            sort.Ints(list)
            originalLen := len(list)
            
            // Add new element and sort again
            listWithNewElement := append(list, newElement)
            sort.Ints(listWithNewElement)
            
            // Check length increased by 1
            if len(listWithNewElement) != originalLen+1 {
                return false
            }
            
            // Check list is still sorted
            for i := 1; i < len(listWithNewElement); i++ {
                if listWithNewElement[i] < listWithNewElement[i-1] {
                    return false
                }
            }
            
            return true
        },
        gen.SliceOf(gen.Int()),
        gen.Int(),
    ))
    
    // Run the property tests
    properties.TestingRun(t)
}

3. Strategic Integration Testing

Instead of excessive mocking, test how components work together. While unit tests are valuable, integration tests often provide more confidence that your system works correctly:

package orders

import (
    "testing"
    "time"
    
    "github.com/stretchr/testify/assert"
)

// Product represents an item for sale
type Product struct {
    ID    string
    Name  string
    Price float64
    Stock int
}

// OrderItem represents a product in an order
type OrderItem struct {
    ProductID string
    Quantity  int
}

// Order represents a customer order
type Order struct {
    ID        string
    UserID    string
    Items     []OrderItem
    CreatedAt time.Time
}

// ProductRepository handles product storage
type ProductRepository interface {
    GetByID(id string) (*Product, error)
    Update(product *Product) error
}

// OrderRepository handles order storage
type OrderRepository interface {
    Create(order Order) (*Order, error)
}

// InventoryService manages product inventory
type InventoryService struct {
    productRepo ProductRepository
}

// OrderService handles order processing
type OrderService struct {
    orderRepo        OrderRepository
    inventoryService *InventoryService
}

// TestOrderCreationFlow demonstrates integration testing
func TestOrderCreationFlow(t *testing.T) {
    // This is just the test structure - in a real test, you would:
    // 1. Setup test database
    testDB := setupTestDatabase()
    defer cleanupDatabase(testDB)
    
    // 2. Create real repositories with the test database
    productRepo := NewProductRepository(testDB)
    orderRepo := NewOrderRepository(testDB)
    
    // 3. Create services with real repositories
    inventoryService := NewInventoryService(productRepo)
    orderService := NewOrderService(orderRepo, inventoryService)
    
    // 4. Create test product with inventory
    product := &Product{
        ID:    "prod-1",
        Name:  "Test Product",
        Price: 29.99,
        Stock: 10,
    }
    err := productRepo.Save(product)
    assert.NoError(t, err)
    
    // 5. Test order creation with real components
    order, err := orderService.CreateOrder(Order{
        UserID: "user-1",
        Items: []OrderItem{
            {ProductID: product.ID, Quantity: 3},
        },
    })
    
    // 6. Verify order was created successfully
    assert.NoError(t, err)
    assert.NotNil(t, order)
    assert.Equal(t, "user-1", order.UserID)
    
    // 7. Verify inventory was correctly updated
    updatedProduct, err := productRepo.GetByID(product.ID)
    assert.NoError(t, err)
    assert.Equal(t, 7, updatedProduct.Stock)
}

These tests may cover fewer lines of code than exhaustive unit tests, but they verify that the system works correctly as a whole.

4. The Testing Diamond Approach

Rather than following the traditional testing pyramid,

  • many unit tests
  • fewer integration tests
  • few end-to-end tests
8413f61bc293bc5fe9a88f58ca2cafb6d725eab0-2667499

consider adopting a “testing diamond” strategy:

  • Some unit tests: Focus on complex algorithms and critical business logic
  • Many integration tests: Verify component interactions and system behaviors
  • Some end-to-end tests: Validate critical user flows end-to-end
0f18736b1bcfbb4d2f326377f1b63e0ec357557e-4478291

This balanced approach often yields better quality with less effort than pursuing 100% unit test coverage alone.

Setting Reasonable Coverage Goals

Instead of aiming for an arbitrary 100%, consider these more practical targets:

  • 70-80% overall coverage is often sufficient for most applications
  • Focus on 100% coverage of your domain core (the business logic)
  • Accept lower coverage in infrastructure code (database adapters, API clients)
  • Aim for high domain-scenario coverage rather than high line coverage

Remember that the quality and relevance of your tests matter far more than the raw coverage percentage.

What Actually Matters in Testing

After working on numerous production systems, I’ve found these factors matter far more than coverage percentages:

  • Test the critical paths: Ensure core business flows work correctly
  • Test edge cases: Validate boundary conditions and error scenarios
  • Test for regressions: Prevent fixed bugs from coming back
  • Test for security: Verify authentication, authorization and input validation
  • Test for performance: Ensure system meets speed and scalability requirements

These aspects contribute more to software quality and reliability than high coverage percentages with low-quality tests.

A Better Approach: Behavior-Driven Development

Rather than thinking about coverage, think about behaviors:

Feature: Order Processing
  Scenario: Creating a valid order
    Given a product with 10 items in stock
    When a user orders 3 items
    Then the order should be created successfully
    And the product stock should be reduced to 7
    
  Scenario: Ordering out-of-stock products
    Given a product with 2 items in stock
    When a user orders 5 items
    Then the order should be rejected
    And an "insufficient stock" error should be returned
    And the product stock should remain at 2

This approach naturally leads to testing what matters to users, not just code execution.

The Perils of Coverage Obsession

On one project I worked on, the team achieved over “90% coverage” but still faced frequent production issues.

Investigation revealed:

  • Most tests had no assertions (they just called the functions)
  • Critical error scenarios were untested

    Integration between components wasn’t verified

After refocusing on behavioral testing rather than coverage, production issues decreased dramatically—even though the average “coverage percentage” actually dropped to around 70%.

Use Coverage as a Tool, Not a Target

Code coverage is valuable as a tool to identify untested areas, but it’s a terrible goal in itself.

Instead:

  • Use coverage to identify potential testing gaps
  • Focus on testing behaviors, not just executing code
  • Prioritize integration tests for critical flows
  • Write high-quality tests for your domain core

Remember:

  • Users don’t care about your coverage percentage—they care that the software works correctly, reliably, and securely.
  • Focus your testing efforts accordingly.
  • High-quality tests for 80% of your code will serve you better than low-quality tests for 100%.

By focusing on what truly matters in testing rather than arbitrary coverage targets, you’ll build more reliable software with less wasted effort.

Have you found other effective approaches to balance coverage with meaningful testing? I’d love to hear your thoughts in the comments!


Discover more from FastCode

Subscribe to get the latest posts sent to your email.

Leave a Reply

Index