Learning Golang - Error Handling, Testing, and Basic Debugging
Episode 6 of 19

Learning Golang - Error Handling, Testing, and Basic Debugging

This episode sharpens your Go error handling idioms with error, fmt.Errorf, errors.Is, and errors.As. You will also write unit tests with the testing package, table-driven tests, and benchmarks, then debug code with go test and delve.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

In episode 3 you already learned the if err != nil pattern. Now we deepen it into a complete system: how errors are created, compared, and debugged. On the other side, an untested program is not a production program — Go provides testing and benchmarking tooling directly in the standard library.

Episode 6 covers three pillars of Go code quality: advanced error handling idioms with errors.Is and errors.As, writing unit tests with the testing package including table-driven tests and benchmarks, and debugging techniques with go test and delve.

Advanced Error Handling Idioms

Building Structured Errors

An error in Go is an ordinary value implemented through the error interface with a single method Error() string. To create an error with context, use fmt.Errorf and wrap the original error with %w:

Wrapping errors
package main
 
import (
	"errors"
	"fmt"
)
 
var ErrNotFound = errors.New("data tidak ditemukan")
 
func cariData(id int) (string, error) {
	if id != 42 {
		return "", fmt.Errorf("cari id %d: %w", id, ErrNotFound)
	}
	return "data rahasia", nil
}

Sentinel variables like ErrNotFound let callers compare errors without unpacking their strings.

errors.Is and errors.As

errors.Is walks the chain of %w-wrapped errors to check whether a specific error is present. errors.As extracts an error from the chain into a target type:

errors.Is and errors.As
_, err := cariData(1)
if errors.Is(err, ErrNotFound) {
	fmt.Println("kategori: not found")
}
 
var target *TipeCustomError
if errors.As(err, &target) {
	fmt.Println("detail kustom:", target.Kode)
}

The golden rule: use errors.Is for sentinel errors, and errors.As for custom-typed errors. Never compare errors with == unless the variables are truly identical.

When to Wrap

Wrap an error every time it crosses an architectural layer boundary — from repository to service, from service to handler. Don't wrap errors that are already informative enough, and avoid redundant wording like "error when ... an error occurred". Common convention: start messages with a lowercase letter because messages are often concatenated.

Unit Testing with the testing Package

Test File Structure

Test files end in _test.go and live in the same directory as the source. Test functions start with Test and accept a *testing.T parameter. Run them with go test ./....

kalkulator_test.go
package kalkulator
 
import "testing"
 
func TestTambah(t *testing.T) {
	hasil := Tambah(2, 3)
	if hasil != 5 {
		t.Errorf("Tambah(2, 3) = %d, harap 5", hasil)
	}
}

t.Errorf marks a test as failed and continues execution, while t.Fatalf stops the test immediately. For regular logging while a test runs, use t.Logf.

Table-Driven Tests

This pattern is the de facto standard in the Go ecosystem: one slice containing test cases, then a single loop running them all. Adding a new case is just adding one row to the table.

Table-driven test
func TestBagi(t *testing.T) {
	cases := []struct {
		nama    string
		a, b    float64
		ingin   float64
		harapErr bool
	}{
		{nama: "pembagian normal", a: 10, b: 2, ingin: 5},
		{nama: "pembagian nol", a: 10, b: 0, harapErr: true},
	}
	for _, tc := range cases {
		t.Run(tc.nama, func(t *testing.T) {
			hasil, err := Bagi(tc.a, tc.b)
			if tc.harapErr && err == nil {
				t.Fatal("harapkan error")
			}
			if !tc.harapErr && hasil != tc.ingin {
				t.Errorf("hasil = %v, harap %v", hasil, tc.ingin)
			}
		})
	}
}

The t.Run subtest makes each case reported separately, so a failing test is immediately visible in the go test -v output.

Benchmarking and Basic Profiling

Writing Benchmarks

Benchmark functions start with Benchmark and accept *testing.B. The b.N loop is run adaptively by the framework until it is accurate.

Tambah benchmark
func BenchmarkTambah(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = Tambah(i, 1)
	}
}

Run them with the -bench and -benchmem flags to see memory allocations:

Running benchmarks
go test -bench=. -benchmem ./...

The metrics shown: number of iterations, duration per operation, bytes allocated per operation, and number of allocations. These benchmarks become the starting point for episode 13 on performance optimization.

Basic Debugging

go test and Verbose Output

go test -v shows the names of running tests and t.Logf output. -run filters tests by name pattern, which is very useful when a package contains hundreds of tests.

Run a specific test
go test -run TestBagi -v ./...

Delve for Interactive Debugging

When fmt.Println is not enough, use delve — the official debugger for Go. Install it with go install github.com/go-delve/delve/cmd/dlv@latest, then start a debugging session. Delve integrates with editors through DAP, so you can set breakpoints directly from VS Code or Neovim.

Start a debugging session
dlv debug main.go

Inside the session: break main.go:10 for a breakpoint, continue to keep going, print variabel to inspect, and next to execute line by line. Exit with exit.

Closing

Episode 6 completed the quality pillars of Go code: creating and comparing errors with errors.Is and errors.As, writing unit tests and table-driven tests with the testing package, measuring performance with benchmarks, and debugging with go test and delve.

Key takeaways:

  • Errors are values; wrap them with %w to preserve the chain.
  • errors.Is for sentinels, errors.As for custom types.
  • Test files end in _test.go and run with go test.
  • Table-driven tests with t.Run subtests are the industry standard.
  • Benchmarks use *testing.B and the -benchmem flag.
  • Delve provides full interactive debugging for Go.

In the next episode we will discuss application configuration, environment variables, and flags — reading configuration from the environment and .env files, handling CLI flags with the flag package, modern frameworks like cobra, and configuration best practices for local, staging, and production.

Learning Golang - Error Handling, Testing, and Basic Debugging | Learning Golang